身为一个程序员,你一定遇到过这种场景:写了个抢票系统,上线后用户反馈说“明明只剩一张票,我和朋友居然都抢成功了,现在两个人都有票,电影院会不会超售?”你翻开数据库一看,座位数确实变成负数了。这就是典型的并发事务闯的祸。
数据库的并发控制就像一场混乱的派对,各种事务进进出出,互相踩脚、抢椅子。为了终结这种混乱,数据库界的前辈们祭出了终极法宝——可串行化(Serializability)。它的目标很纯粹:让一堆并发执行的事务,最终效果就像它们一个接一个串行执行一样,彻底杜绝各种妖魔鬼怪般的竞态条件。今天我们就来扒一扒,实现可串行化到底有哪几种套路,它们各自有什么脾气秉性。
为什么需要可串行化?
在没遇到可串行化之前,你可能已经被各种弱隔离级别坑过无数次了。脏读(Dirty Read) 让你看到别人还没提交的“幽灵数据”;读倾斜(Read Skew) 让你算账户总资产时忽多忽少;丢失更新(Lost Update) 让你明明点了两次加一,计数器却只涨了一次;最阴险的是写倾斜(Write Skew)——两个人同时查会议室空闲,然后都抢着预订,结果会议室被双倍预订,老板找你谈话。
可串行化就是要把这些乱七八糟的异常一锅端。但天下没有免费的午餐,要实现它,数据库得使出浑身解数。目前主流的实现方案有三条路,我们挨个来品鉴。
实际串行执行——只有一个窗口
最朴素的思路:既然并发容易出乱子,那我干脆不让它们并发,一次只处理一个事务,这总该天下太平了吧?这就是实际串行执行(Actual Serial Execution)。
想象一下你去政务大厅办事,只有一个窗口在服务,所有人都得老老实实排队。虽然慢,但绝对不会出现两个人同时抢一个号的情况。数据库也是这样,把所有事务放到一个单线程里挨个执行,彻底避免了冲突检测、锁等待这些麻烦事。
以前人们觉得这方法不现实,因为数据都在磁盘上,一个事务可能要等很久的I/O,单线程根本扛不住。但现在内存便宜了,很多数据库可以把全部数据放在内存里,事务执行极快,单线程反而能跑出高吞吐——因为没有锁竞争的开销。VoltDB、Redis就是这条路上的代表。
当然,这种模式对事务要求很苛刻:必须短、快、不能有交互(比如等待用户输入),否则一个慢事务就会堵死所有人。而且它只能用上一个CPU核,想扩展就得分片(Sharding),让每个核处理一个分片。但一旦事务涉及多个分片,又得跨片协调,性能直接跳水。
两阶段锁定——锁住一切,谁也别动
另一种经典方案是两阶段锁定(Two-Phase Locking,2PL)。这个名字听着唬人,其实核心思想很直白:读之前加共享锁(Shared Lock),写之前加独占锁(Exclusive Lock),而且所有锁必须等到事务结束后才能释放(这就是“两阶段”:加锁阶段和释放阶段)。
这就好比图书馆的阅览室:你可以和很多人一起看书(共享锁),但只要有人想涂改书上的内容,他就得独占这本书,把其他人都赶走(独占锁)。而且他改完之前,别人连看都不能看。
2PL确实能保证可串行化,但代价是并发度极低。一个写事务会阻塞所有读事务,反之亦然。更可怕的是容易产生死锁:事务A等B,事务B等A,最后数据库只好杀掉一个,让应用程序重试。在争用激烈的场景下,系统可能频繁死锁,性能一落千丈。
此外,2PL还要处理幻读(Phantom Reads) 的问题——比如你查询电影院座位空闲情况,另一个事务偷偷插入了一条新订单,导致你的查询结果“变”了。为了解决这个,数据库引入了谓词锁(Predicate Lock) 或索引范围锁(Index-Range Lock),锁定一个范围而不是具体行。这又进一步加剧了锁开销。
所以,2PL虽然能保你数据绝对一致,但往往把性能拖垮。就像在高速公路上设无数关卡,车是安全了,但谁也动不了。
可串行化快照隔离——乐观主义者的胜利
既然悲观锁太沉重,能不能换个思路,让事务先乐观地执行,提交时再检查有没有冲突?这就是可串行化快照隔离(Serializable Snapshot Isolation,SSI)。
SSI建立在快照隔离(Snapshot Isolation) 之上。快照隔离本身就是一种很实用的弱隔离级别:每个事务看到的是数据库在某个时刻的“快照”,读写互不阻塞。但它仍然可能出现写倾斜。
SSI在快照隔离的基础上增加了一个检测层:当事务提交时,数据库会检查是否存在“基于过时前提的写入”。我们用电影院座位预订的场景来说明。假设某场电影只剩一张票,两个用户同时发起购买请求,就会上演下面这场“手速大赛”:
sequenceDiagram
participant T1 as 事务T1
participant DB as 数据库
participant T2 as 事务T2
T1->>DB: BEGIN (获取快照)
T2->>DB: BEGIN (获取快照)
T1->>DB: SELECT seats FROM movies WHERE id=1
DB-->>T1: seats=1
T2->>DB: SELECT seats FROM movies WHERE id=1
DB-->>T2: seats=1 (来自快照)
T1->>DB: UPDATE movies SET seats=0 WHERE id=1
DB-->>T1: OK
T1->>DB: COMMIT
DB-->>T1: committed
T2->>DB: UPDATE movies SET seats=0 WHERE id=1
DB->>DB: 检测到冲突 (T1已修改T2读取的数据)
DB-->>T2: ABORT (事务中止)
如图所示,T1和T2都基于快照看到剩余1张票,然后T1抢先提交,把座位数改为0。当T2想要提交自己的更新时,数据库发现T2之前读取的seats值已经被T1修改了——这意味着T2的决策(“还有票,可以买”)已经过时。于是SSI果断中止T2,让它重试。这样既保证了最终只有一个人买到票,又避免了T2在等待锁的过程中被阻塞。
这种检测需要数据库记录每个事务的读写集合,并在提交时检查是否有其他事务修改了它读过但未写的数据。这有点像用“绊网”代替了“锁墙”——不阻塞,但一旦有人踩到线就拉警报。
SSI的性能比2PL好得多,尤其适合读多写少的场景。它避免了锁等待,延迟更稳定。而且它可以很好地扩展到分布式环境,比如FoundationDB就是用类似原理实现分布式可串行化。
不过,SSI也不是免费的午餐。如果冲突率很高,大量事务会被中止重试,反而浪费资源。所以它适合冲突不那么频繁的乐观场景。
到底该怎么选?
说了这么多,回到现实:你的应用到底该用哪种可串行化实现?
- 如果业务简单,数据量不大,且可以接受单线程,实际串行执行是最省心的选择,比如用Redis的事务功能。
- 如果必须高并发且冲突较少,SSI是主流,PostgreSQL、CockroachDB都在用它。
- 如果冲突极其激烈,比如秒杀场景,也许需要退回到弱隔离级别(如读已提交),并在应用层做补偿逻辑,比如用乐观锁重试。
可串行化是数据库并发控制的珠穆朗玛峰,攀登它需要权衡性能与一致性。幸运的是,现代数据库已经给了我们多种工具,让我们可以根据业务场景灵活选择。