Justin G. 2020-01-21
在构建和维护应用程序时,你最不想操心的就是数据完整性问题——向客户收取错误的金额或丢失用户数据都可能是灾难性的。值得庆幸的是,你所使用的数据库(如 MySQL 和 PostgreSQL)会采取特殊措施来避免这类问题的发生。但这些数据库背后究竟做了些什么?大多数现代 SQL 数据库使用像 ACID 这样的事务标准,以确保数据完整性,并防止用户看到错误或过期的数据。本文将深入探讨其工作原理。
你的事务可能出错的各种情况
数据科学家关注的是长时间运行的分析查询和数据仓库,而对开发者而言,数据库的核心在于事务。数据库事务是一组逻辑上关联的数据库操作:在这里插入一行,在那里更新一条记录,等等。每当有新用户注册,或现有用户更新账户信息时,你的应用代码其实都在不断地执行事务。
然而,事务很容易出错。在向数据库写入数据的过程中,可能会发生各种意外:比如与远程数据库实例失去连接、遇到数值错误,或者其他任何不可预见的问题。你肯定遇到过、处理过这些问题,而它们可能对底层数据造成严重破坏。我们来看一个亚马逊这类公司可能遇到的例子:
- 用户修改订单数量并点击“立即下单” →
- 在 pending_orders(待处理订单)表中更新订单数量
- 向 orders(订单)表中添加一行记录
- 从用户余额中扣除费用 / 向信用卡收费
如果这组操作在中途某一步失败,但系统仍继续执行后续步骤,用户就可能被多收费;而如果扣款失败,用户甚至可能白拿商品。实际上,这类数据错误都有专门的名称,而且种类繁多。以下是几个典型例子:
脏读(Dirty Reads)
如果某个事务正在更新某些数据但尚未提交(commit),而另一个事务却读取了这些未提交的数据,这就叫“脏读”,可能导致你的应用显示后来被回滚(rollback)的错误数据。
举个例子:当用户更改密码时,一个事务负责使旧的登录令牌失效。如果第一个事务刚加载了该令牌,第二个事务就在第一个事务将其标记为无效之前读取了它,这就构成了脏读。
用 SQL 表示,脏读可能如下所示:
-- 事务 1 --
SELECT user_login_token_id FROM tokens;
UPDATE tokens
SET token_status = "INVALID"
WHERE token_id = user_login_token_id;
-- 事务 2 --
SELECT user_login_token_id FROM tokens;
不可重复读(Non-Repeatable Reads)
如果在一个事务中连续两次读取同一数据,而在这两次读取之间,另一个并发事务修改了该数据,那么这两次读取的结果就会不一致——即使它们属于同一个事务。
例如,两位作者共同编辑一篇博客。第一位用户开启一个事务:先读取文章标题,然后编辑内容,再重新读取标题。如果在此期间第二位用户修改了文章标题,第一位用户就会发现两次读取的标题不同,这就是“不可重复读”。
SQL 示例:
-- 事务 1 --
SELECT post_title FROM posts;
SELECT post_title, post_content FROM posts;
-- 事务 2 --
UPDATE posts
SET post_title = "something_new"
WHERE post_title = post_title;
幻读(Phantom Reads)
如果一个事务读取了一组数据,而在此之后,另一个并发事务插入了符合原查询条件的新数据,那么原事务在后续再次读取时会“凭空”看到新数据,这种现象称为“幻读”。
仍以博客为例:如果第二位用户在第一位用户的两次读取之间新增了一篇帖子,那么第一次查询看不到这篇新帖,而第二次却能看到——就像幽灵一样突然出现。
SQL 示例:
-- 事务 1 --
SELECT post_title FROM posts;
SELECT post_title, post_content FROM posts;
-- 事务 2 --
INSERT INTO posts VALUES ("something_new", ...);
以上三种事务错误是由 SQL 标准定义的“三大经典问题”。它们听起来很相似,在实际中也常常重叠,所以不必过分纠结细节。若想深入了解,推荐阅读 Vlad Mihalcea 关于该主题的系列博客。
那么关键问题来了:我们如何避免这些问题?
ACID:可靠的那种“酸”
像 MySQL 这样的主流关系型数据库通过遵循一套核心原则来避免上述数据完整性问题,这套原则就是事务标准——ACID。ACID 是四个英文单词的首字母缩写,本质上可归纳为两大核心理念:完整性 和 并发控制。具体来说:
- 原子性(Atomicity):事务遵循“全有或全无”原则——要么全部成功执行,要么完全不执行。
- 一致性(Consistency):事务执行前后,数据库始终处于一致状态,不会出现中间残缺状态。
- 隔离性(Isolation):多个事务可以并发执行,彼此不会读取到对方未提交的中间数据。
- 持久性(Durability):一旦事务成功提交,其结果将永久保存,即使系统崩溃也不会丢失。
这些特性相互关联,但核心目标很明确:
- 即使事务失败,也不会破坏数据完整性;
- 多个事务并发执行时,不会因互相干扰而读写错误数据。
需要注意的是,ACID 是一组属性,而非具体实现机制。那么,我们常用的 SQL 数据库究竟是如何实现 ACID 的呢?答案是:锁机制(locking)。
锁的工作方式正如其名:当一个事务开始时,数据库引擎会对它要操作的数据加锁,直到事务完成(有时甚至更久)。这样,其他并发事务就无法同时修改这些被锁定的数据。
一旦事务获得锁,它要么成功完成并提交(commit),要么遇到错误并中止(abort)。这有点像在 Excel 中编辑内容但尚未保存——改动只是临时的,只有点击“保存”(commit)才会真正生效,或者点击“撤销”(abort)让一切恢复原状。
回到亚马逊的例子:如果在更新用户订单数量后系统出错,整个事务就会中止,仿佛那条更新从未发生;如果错误发生在扣款环节之前,信用卡根本不会被扣费。要么全部成功,要么全部失败——这就是 ACID 的精髓。
提交与锁机制的具体实现相当复杂,若想深入,推荐阅读 Methods and Tools 发表的相关论文。
ACID 在 NoSQL 与分布式系统中的演变
ACID 是传统可靠关系型数据库的基石,但 NoSQL 的兴起改变了游戏规则:许多 NoSQL 数据库构建在分布式系统之上,难以保证完全的事务一致性。事实上,有一个理论专门描述这一限制——CAP 定理:在分布式系统中,你无法同时实现强一致性(Consistency)和高可用性(Availability),必须有所取舍。
那么,像 MongoDB 或 Cassandra 这类 NoSQL 数据库是如何处理事务的呢?
一种新的、适用于 NoSQL 的“准标准”应运而生,名为 BASE(这是个罕见的 SQL 与化学双关梗),它是一种弱一致性模型,通过放宽 ACID 的某些要求来换取更高的可扩展性:
- 基本可用(Basic Availability):系统大部分时间可用,即使并非完美无缺;
- 软状态(Soft-State):数据库各节点的状态不一定时刻一致;
- 最终一致性(Eventual Consistency):数据最终会在所有节点间达成一致,比如在下一次读取时。
从很多角度看,BASE 几乎是 ACID 的对立面——它优先保障可用性,而非绝对一致性。而这恰恰是 NoSQL 设计的初衷。随着 NoSQL 在应用开发中日益普及(目前已有超过 25% 的开发者在使用 MongoDB),我们可以期待这一领域持续进步。
如今,我们处理的数据量前所未有,数据库系统也在不断演进以应对高负载和大规模需求。ACID 可能不会成为未来的唯一事务标准,但它的核心思想无疑将继续影响下一代数据库的设计。