SQL

第12章 幽灵文件

当一个理论看上去像是唯一可能的理论时,那意味着你既不理解这个理论,也不理解它所要解决的问题。 -- 卡尔•波普尔

你的数据库服务器在大灾难中没有幸存下来,在清理时发现硬盘机架整个倾倒了,并且摔坏了。幸运的是,没有人因此而受伤,但是大量的硬盘因此而损坏,甚至存放机架的楼层在机架倾倒时被砸穿了。所幸,IT部门的灾备做得比较好:他们每天都为每个重要的系统做了备份,并且快速地在新的服务器上部署了新的服务,恢复了数据库。

冒烟测试进行没多久后发现了一个问题:你的程序将图像与很多数据库字段进行了关联,但是所有这些图片都不见了!你立刻打给了IT技术部门。

“我们恢复了数据库并且验证了这是和上次备份一样的完整副本,”这个技术人员说:“图片是存在哪里的?”

你现在想起来了,在这个应用中,图片是存在数据库之外的,普通文件都是存在文件系统中的。数据库里只存了图片的路径,通过程序去打开对应路径的图片。“图片是以文件形式存储的。它们是在/var目录下,和数据库一起。”

这个技术人员摇了摇头。“除非你做过特殊说明,否则我们不备份/var下的内容。当然我们会备份数据库的文件,但/var目录经常是存放日志、缓存或其他临时文件的地方。默认情况下,这些数据都是不备份的。”

你心痛啊。那个目录下存了超过11 000张在产品分类数据库中使用的图片。大多数可能在其他地方还有备份,但要把它们都放到一起,重新编排,并且为网络搜索重做缩略图要花好几个星期啊!

12.1 目标:存储图片或其他多媒体大文件

如今,图片等多媒体文件已经广泛使用在很多程序中。有时,多媒体文件和数据库中的一些实体关联。比如,你可能会允许一个用户在提交评论时显示一个头像。在我们的Bug数据库中,Bug通常需要附带一个截屏来展示具体情况。

本章的目标就是要存储这些图片并且将其和数据库实体(诸如用户账户或者Bug)关联起来。当查询这些实体时,我们需要确保同时能获取与其关联的图片。

12.2 反模式:假设你必须使用文件系统

理论上来说,图片是一张表中的一个字段,在Accounts表中可能会有一个portrait_image列。

Phantom-Files/anti/create-accounts.sql
CREATE TABLE Accounts (account_id SERIAL PRIMARY KEY account_name VARCHAR(20), portrait_image BLOB);

同样地,你可以在从属表中存储多张同类型的图片。比如,每个Bug都可以有多张屏幕截图。

Phantom-Files/anti/create-screenshots.sql
CREATE TABLE Screenshots (
   bug_id BIGINT UNSIGNED NOT NULL,
   image_id SERIAL NOT NULL,
   screenshot_image BLOB,
   caption VARCHAR(100),
   PRIMARY KEY (bug_id, image_id),
   FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id)
);

这些都不难理解,但真正的重点问题是选择什么样的数据类型来存储图片?原始图片文件可以以二进制格式存储在BLOB类型中,就像之前我们存储超长字段那样。然而,很多人选择将图片存储在文件系统中,然后在数据库里用VARCHAR类型来记录对应的路径。

Phantom-Files/anti/create-screenshots-path.sql
CREATE TABLE Screenshots (
   bug_id BIGINT UNSIGNED NOT NULL,
   image_id BIGINT UNSIGNED NOT NULL,
   screenshot_path VARCHAR(100),
   caption VARCHAR(100),
   PRIMARY KEY (bug_id, image_id),
   FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id)
);

开发人员激烈地争论着这个问题。两种方案都有很好的立足点,但是对于程序员来说通常只有一种选择,就是我们应该将文件存在数据库之外。可能我的观点有点不合时宜,但我依旧要在接下来的几节中指明这样的设计所面临的很现实的风险。

12.2.1 文件不支持DELETE

第一个问题就是垃圾回收。如果图片在数据库之外,并且你想要删除包含这个路径的记录,没有什么办法能自动地将路径对应的文件移除。

Phantom-Files/anti/delete.sql
DELETE FROM Screenshots WHERE bug_id = 1234 and image_id = 1;

除非你的应用程序设计成在删除记录的同时删除这些“无人领养”的图片,不然这些图片就会堆积在那里。

12.2.2 文件不支持事务隔离

通常,当更新或删除数据时,在使用COMMIT指令完成事务操作之前,所有的改变都对其他的客户端不可见。

然而,任何对数据库之外的文件的操作并非如此。如果你删除了一个文件,对于其他的客户端来说就立刻无法访问该图片了。并且如果你改变了文件的内容,其他的客户端可以立刻看到这些变更,而不是看到在事务未提交之前的文件状态。

Phantom-Files/anti/transaction.php
<?php
  $stmt = $pdo->query("DELETE FROM Screenshots
                      WHERE bug_id = 1234 AND image_id =1");
  unlink('images/screenshot1234-1.jpg'); // Other clients still see the row in the database, // but not the image file.
  $pdo->commit();

在实际生产过程中,这样的特例可能并不常见。同样,本例的影响也比较小;在Web程序中,丢失图片的情况很少见。但在别的情况下,后果就可能非常严重。

12.2.3 文件不支持回滚操作

出错情况下,或者程序逻辑要求取消变更时,对数据进行回滚操作是很平常的事情。

比如,你最开始执行了一句DELETE语句来删除一条记录并同时移除了对应的截屏文件,然后你回滚了这个操作,被删除的数据回来了,但文件已经没了。

Phantom-Files/anti/rollback.php
<?php
  $stmt = $pdo->query("DELETE FROM Screenshots
                      WHERE bug_id = 1234 AND image_id =1");
  unlink("images/screenshot1234-1.jpg");
  $pdo->rollback();

数据库中的记录能够恢复,但文件不能。

12.2.4 文件不支持数据库备份工具

大多数数据库产品都提供客户端工具来协助备份使用中的数据库。比如MySQL提供了一个叫做mysqldump的组件,Oracle提供了rman,PostgreSQL提供了pg_dump,SQLite提供了.dump命令等。使用备份工具非常重要,如果同一时刻别的客户端正在进行变更操作,将可能使你的备份包含不完整的变更,造成潜在的引用不完整性,甚至使得整个备份被破坏以至于无法用于恢复数据库。

但是备份工具并不知道如何将通过路径引用的那些文件也包含在备份操作当中。因此在备份一个数据库时,你需要记住执行一个两步操作:使用数据库备份工具,然后使用文件系统备份工具来收集外部图像文件。

即使在备份时包含了外部文件,也很难保证这些文件备份和你执行备份数据库的事务是同步的。程序可能在任何时间对图片文件做变更,也许就在你开始备份数据库之后不久。

12.2.5 文件不支持SQL的访问权限设置

外部文件会绕开通过GRANT和REVOKE SQL语句设定的访问权限。SQL权限管理着对表和列的访问,但它们并不能应用到外部文件。

12.2.6 文件不是SQL数据类型

在screenshot_path字段中存储的路径就是一个字符串。数据库并不会验证这个字符串是一个有效的路径,也不会验证对应的文件是否存在。如果这个文件被重命名、移动或者删除了,数据库并不会自动更新对应的路径。任何将这个字符串作为路径处理的逻辑都依赖于你的程序逻辑。

Phantom-Files/anti/file-get.php
<?php
  define('DATA_DIRECTORY', '/var/bugtracker/data/');
  $stmt = $pdo->query("SELECT image_path FROM Screenshots
                      WHERE bug_id = 1234 AND image_id = 1");
  $row = $stmt->fetch();
  $image_path = $row[0];  // Read the actual image -- I hope the path is correct!
  $image = file_get_contents(DATA_DIRECTORY . $image_path);

使用数据库的好处之一在于它能帮助我们保持数据完整性。当你将数据放在外部文件中时,就抛弃了数据库提供的这个好处,并且你不得不写更多的程序代码来执行本该由数据库进行的检查操作。

12.3 如何识别反模式

要发现这个反模式需要一定的调查。如果一个项目有指导软件管理员的文档,或者你有机会与设计项目的程序员(或者就是你自己)面谈,考虑一下如下几个问题的答案。

  • 数据备份和恢复的过程是怎样的?怎么对一个备份进行验证?你有没有在一个干净的系统或者在别的系统上对备份恢复的数据进行测试?
  • 图片文件堆积在那里,还是当它们孤立的时候就从系统中移除?移除它们的过程是怎样的?这是一个自动的还是手动的过程?
  • 系统中的哪些用户有权限查看这些图片?进入权限是怎么限制的?当用户请求看他们无权查看的图片时会发生什么?
  • 我能撤销对图片的变更吗?如果能,是应用程序来负责恢复图片之前的状态吗?

典型的使用反模式的项目通常没有考虑以上的几个或者全部的问题。并不是每个程序都需要有针对图片的很强的事务管理或者SQL访问控制。可能在备份过程中将数据库下线也是一个很好的方案。如果以上这些问题的回答不明确或者并没有立刻给出回答,那可以假设这个项目对于外部文件使用的并没有经过精心设计。

12.4 合理使用反模式

如下是一些将图片或者大文件存储在数据库之外的好理由。

  • 这个数据库在没有图片的时候能精益很多,因为图片相比于简单的数据类型(比如整形和字符串)来说更大。
  • 当不包含图片时备份数据库会更快并且备份的文件更小。你必须额外执行一次文件备份,但这比备份一个大型数据库要更容易管理。
  • 如果图片是存储在数据库之外的文件系统里,对图片的预览或者编辑就能够使用更简单直接的处理方式。比如,如果你需要执行一个批处理编辑所有的图片,将其保存在数据库之外就是一个特别好的选择。

如果这些将图片存在文件系统中的好处是重要的,并且之前几节所描述的那些事项并不会破坏你的系统,那就可以肯定将图片存在数据库之外对于你的项目来说是正确的决定。

一些数据库产品提供了一些特殊的SQL数据类型,为支持对外部文件引用提供或多或少的透明性。Oracel称这种类型为BFILE,SQL Server 2008称之为FILESTREAM。

不要排除任何设计

我在1992年的时候为一个外包工程设计了一个将图片存储在数据库之外的程序。我的雇主承接了一个技术会议的注册程序。在会议即将开始之前,我们用一个照相机对每个与会人士拍照,并将他们的照片加到他们的注册信息中,同时也打印在他们的通行证上。

我的程序非常简单。每个图片都只能被一个客户端插入或者更新(如果这个人在拍照时眨眼了,或者不喜欢他们的照片,我们会在注册时就更换它)。没有复杂的事务处理的需求,也没有多客户端的并发访问或者回滚需求。我们甚至没有使用SQL的权限控制。预览图片的逻辑简单到根本不需要将其从数据库中提取出来。

我开发这个项目的时候,数据库及客户端技术还不像现在这么发达。于是我们有很充分的理由在这种情况下将图片直接存在文件系统目录中,并且使用应用层代码来维护。

你需要规划你的程序如何使用这些文件,并且了解本章所描述的反模式是否会影响到你的系统。做一个明智的决定,而不仅仅听那些将图片存在数据库之外的程序员的泛泛之谈。

12.5 解决方案:在需要时使用BLOB类型

如果在12.2节中所描述的任何问题适用于你的项目,你应该要考虑将图片从数据库之外转移到数据库之内。所有的数据库产品都支持BLOB类型,支持你存储任何二进制数据。

Phantom-Files/soln/create-screenshots.sql
CREATE TABLE Screenshots (
   bug_id BIGINT UNSIGNED NOT NULL,
   image_id BIGINT UNSIGNED NOT NULL,
   screenshot_image BLOB,
   caption VARCHAR(100),
   PRIMARY KEY (bug_id, image_id),
   FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id)
);

如果你将图片存在一个BLOB类型的列中,所有的问题都将得到解决。

  • 图片数据存储在数据库中。不需要额外的步骤加载它,也就没有“文件路径不正确”这样的风险。
  • 删除一条记录同时也自动地删除了图片。
  • 直到你提交了事务,否则对图片的改变是对其他客户端不可见的。
  • 回滚事务会恢复图片之前的状态。
  • 更新记录的时候会加锁,因此不会有别的客户端并发更新图片。
  • 数据库备份会包含所有的图片。
  • SQL权限控制对图片也有效。

BLOB类型的最大值依据不同的数据库产品而不同,但对于存储大部分的图片文件来说都是足够的。所有的数据库都支持BLOB或者类似的类型。比如,MySQL提供了一个叫做MEDIUMBLOB的类型,支持最大16MB的数据,对于绝大部分图片来说都足够了。Oracle提供了一个LONG RAW或者BLOB的类型,最大支持2GB或者4GB的长度。类似的类型在其他数据库产品中也能找到。

图片文件最开始都是以文件形式存储的,因此你需要一些途径将它们载入BLOB列。一些数据库产品提供了加载外部文件的函数。比如,MySQL有个函数叫做LOAD_FILE(),你可以用它来读取一个文件,然后将内容存到BLOB列中。

Phantom-Files/soln/load-file.sql
UPDATE Screenshots SET screenshot_image = LOAD_FILE('images/screenshot1234-1.jpg') WHERE bug_id = 1234 AND image_id = 1;

同样地,你也可以将BLOB列的内容存储到一个文件中去。比如,MySQL有一个可选的SELECT子句能将一个查询完整地不加修改地存储到文件中去。

Phantom-Files/soln/dumpfile.sql
SELECT screenshot_image INTO DUMPFILE 'images/screenshot1234-1.jpg'
FROM Screenshots WHERE bug_id = 1234 AND image_id =1;

你也能够直接从BLOB字段中提取图片并且输出。在Web程序中,你可以将二进制数据以图片输出,但你需要将内容类型设置为合适的值。

Phantom-Files/soln/binary-content.php
<?php
  header('Content-type: image/jpg');
  $stmt = $pdo->query("SELECT screenshot_image FROM Screenshots
                      WHERE bug_id = 1234 AND image_id = 1");
  $row = $stmt->fetch();
  print $row[0];

存储在数据库之外的数据不由数据库管理。

下一节:无论何时,在机器的帮助下找到结果,另一个问题就冒出来了——通过何种计算过程,能让机器最快地求出结果? -- 查尔斯•巴贝奇,《哲学家的生命历程》(1864)