type
status
date
slug
summary
tags
category
icon
password
引言
在大多数情况下,访问数据库的不会只有一个人。许多用户可能会同时向数据库发出请求,这就会引发并发性问题。当一个用户写入数据,另一个用户又从相同资源读取时会发生什么?如果两个用户都想写同一个资源会发生什么?如果我们不小心,当多个用户同时操作数据库时,会遇到很多问题,例如:
- 不一致读(写-读冲突,Inconsistent Reads, Write-Read Conflict):
- 用户1先更新了表1,然后更新表2。
- 用户2读取了表2(此时用户1还未更新),然后又读取了表1(此时用户1已经更新),导致用户2读到的是一个“中间态”的数据库。
用户只读到了部分已更新的数据。
- 更新丢失(写-写冲突,Lost Update, Write-Write Conflict):
- 用户1把玩具的价格更新为 price × 2。
- 用户2把玩具价格更新为 price + 5,覆盖了用户1的更新,导致用户1的修改丢失。
两个用户同时尝试更新同一条记录,导致某个更新丢失。例如:
- 脏读(写-读冲突,Dirty Reads, Write-Read Conflict):
- 用户1修改了玩具的价格,但这个事务最后回滚了。
- 用户2在用户1回滚前读到了这个被修改但未提交的数据。
一个用户读取到了未提交的更新。
- 不可重复读(读-写冲突,Unrepeatable Reads, Read-Write Conflict):
- 用户1读取玩具价格。
- 用户2把价格改为 price × 2 并提交。
- 用户1再次读取玩具价格。用户1在同一事务中,前后两次读到的价格不一致,因为用户2的写入操作发生在两次读之间。理论上,用户1应该在同一个事务中读到相同的值,因此此时用户1的事务需要中止。
一个用户在同一事务中两次读取同一条记录,因为另一个用户在两次读取之间更新了这条记录,导致读到两个不同的值。
为什么更新丢失是问题?
1. 什么是“更新丢失”?
更新丢失指的是:两个用户几乎同时对同一条记录做了更新操作,其中一个用户的修改被另一个用户的写入覆盖,从而“丢失”了,像没发生过一样。
比如:
- 初始价格:100
- 用户1操作:“价格 × 2”,想把价格变成200。
- 用户2操作:“价格 + 5”,想把价格变成105。
如果这两个人几乎同时读取并更新:
- 用户1读到100,算出200,准备写。
- 用户2也读到100,算出105,准备写。
- 用户1先写入200,紧接着用户2又写入105,结果最后数据库里是105。
用户1的修改(200)完全被覆盖,相当于“没发生”。
2. 为什么是个问题?
不是因为只剩一条记录不对,而是因为这样操作丢失了用户1的意图和数据,造成业务逻辑上的错误。
- 假如你的数据库是账户余额、库存等关键数据,这种“无声覆盖”就会导致数据错乱和损失。
- 多个用户的更新,本应“累加”或“合并”,但却因为写-写覆盖,只保留了最后一个人的结果,完全忽略了其他人的操作。
3. 如果我们希望“所有操作都起作用”呢?
如果正确处理,“价格 × 2”和“价格 + 5”这两个操作,无论顺序,都应该产生不同于只执行最后一步的结果。理想情况下:
- 先×2再+5:100 → 200 → 205
- 先+5再×2:100 → 105 → 210
而更新丢失只得到一个105或200,中间的操作被无声吞掉了,这不是你想要的“最终结果”。
4. 现实应用举例
- 电商抢购库存:“库存-1”被覆盖,就会超卖或漏卖。
- 银行账户转账,多人同时转账,“余额-100”被覆盖,少了一笔钱。
5. 为什么要防止?(并发控制的意义)
数据库要保证多个用户的操作不会互相丢失或覆盖,要么让一个用户“等另一个完成再操作”,要么采用乐观锁、悲观锁、版本号等机制确保所有人的操作都被正确合并或报错提示。
总结
“更新丢失”的问题在于丢失了某些用户操作的效果,而不是只剩一条记录。它威胁到了数据的可靠性和业务的正确性,所以数据库必须防止这种情况发生。
Transactions(事务)
为了解决前面提到的那些并发问题,数据库定义了一组关于操作的规则和保障,我们称之为事务(transaction)。
一个事务是由多个动作组成的,这些动作应当作为一个单独的、逻辑上的、不可分割的单位来执行。
事务通过ACID 属性来保证数据的安全,避免前述问题:
ACID 四大属性
- 原子性(Atomicity):
- 一个事务只能有两种结果:提交(commit) 或 回滚(abort)。
- 要么事务中的所有操作都发生,要么都不发生。
- 一致性(Consistency):
- 如果数据库开始时是“合法”的(符合各种约束和业务规则),事务结束后也必须保持一致性。
- 隔离性(Isolation):
- 每个事务的执行过程相互隔离,好像数据库里一次只运行一个事务。
- 实际上,DBMS(数据库管理系统)会把多个事务的操作交错执行,但最终的效果就像每个事务是单独执行的一样。
- 持久性(Durability):
- 一旦事务提交了,其结果就是永久性的,即使系统崩溃也不会丢失。
总结
ACID保证数据库即使在高并发、多用户场景下,也能避免更新丢失、脏读、不可重复读等问题,保障数据的可靠性和正确性。
并发执行的动机
看起来并发执行带来了很多麻烦,那我们为什么还要支持它?
其实,实现并发有两个主要好处:
- 吞吐量提升(Increase in Throughput)
- 吞吐量指每秒钟能处理的事务数(transactions per second)。
- 举例:如果数据库系统只用一个核心,一个事务可以用CPU,另一个事务可以同时从磁盘读写。
- 如果是多核CPU,可以通过并发让吞吐量理想情况下随着核心数线性提升。
- 延迟降低(Decrease in Latency)
- 延迟指单个事务的响应时间(response time)。
- 多个事务可以同时运行,这样一个事务的延迟不需要等另一个无关事务完成。
简而言之:
- 并发让数据库能更高效地利用硬件资源,提升整体性能;
- 同时降低单个事务的等待时间,改善用户体验。
并发控制 Concurrency Control
本节讨论如何保证事务的隔离性(isolation)。我们会分析事务调度(transaction schedules),即事务各操作的执行顺序。事务常见操作包括:Begin、Read、Write、Commit 和 Abort。
串行调度(Serial Schedule)
最简单的方式是让一个事务所有操作全部执行完,再开始下一个事务。
如图表第一个例子:
T1: 从A转100到B | T2: 给A&B加10%利息 |
begin | ㅤ |
read(A) | ㅤ |
A = A - 100 | ㅤ |
write(A) | ㅤ |
read(B) | ㅤ |
B = B + 100 | ㅤ |
write(B) | ㅤ |
commit | ㅤ |
ㅤ | begin |
ㅤ | read(A) |
ㅤ | A = A * 1.1 |
ㅤ | write(A) |
ㅤ | read(B) |
ㅤ | B = B * 1.1 |
ㅤ | write(B) |
ㅤ | commit |
这种做法非常安全,但效率低,因为必须等一个事务完成才能执行下一个。
可串行化调度(Serializable Schedule)
理想情况下,我们希望能够并发执行事务,但是最终效果和某个串行调度一样。这就引入了“可串行化”概念:
可串行化调度的条件
- 包含同样的事务
- 每个事务内部操作顺序与串行调度相同
- 最终让数据库达到和串行调度相同的状态
如果某个调度满足这些条件,我们称其为可串行化(serializable)。
可串行化调度举例
如图表第二个例子:
T1: Transfer $100 from A to B | T2: Add 10% interest to A & B |
begin | ㅤ |
read(A) | ㅤ |
A = A - 100 | ㅤ |
write(A) | ㅤ |
ㅤ | begin |
ㅤ | read(A) |
ㅤ | A = A * 1.1 |
ㅤ | write(A) |
read(B) | ㅤ |
B = B + 100 | ㅤ |
write(B) | ㅤ |
commit | ㅤ |
ㅤ | read(B) |
ㅤ | B = B * 1.1 |
ㅤ | write(B) |
ㅤ | commit |
说明:
- T1 先开始,并完成对 A 的扣款和写入(write(A))。
- T2 随后开始,只对 A 读和写(读到的是 T1 已经更新后的 A)。
- T1 再继续读写 B,commit。
- T2 最后再处理 B,最终 commit。
这样交错执行的调度叫可串行化调度,其效果和某个串行顺序(比如 T1 完全跑完再 T2,或者反过来)在数据最终状态上一致,即便事务实际执行时是交错的。
如果你还想要把具体的值(比如 A、B 的初值、T1、T2 操作的结果)都推导出来,也可以继续问我!
可以验证,不管顺序怎么交错,只要每个事务内部顺序不变,且最终结果与某个串行执行一致,就是安全的。
总结
- 串行调度安全但效率低。
- 可串行化调度既保证正确性,又能提升并发性能。
- 目标是找到等价于串行调度的并发调度方式,实现高效安全的并发事务执行。
冲突可串行化 Conflict Serializability
问题:
我们怎么能确保两个调度(schedule)在不完全跑一遍的情况下,能让数据库最终达到相同的状态?
我们可以通过**冲突操作(conflicting operations)**来判断。
两个操作发生冲突的条件:
- 操作属于不同的事务;
- 操作对象是同一个资源(如同一条记录/变量);
- 至少有一个是写操作。
判断方法:
- 检查两个调度是否对每一对冲突操作的顺序一致。
- 如果一致,则两个调度最终数据库的状态一定相同。
专用名词:
- 冲突等价(conflict equivalent):
如果两个调度对所有冲突操作的顺序都一致,则称这两个调度冲突等价。
- 冲突可串行化(conflict serializable):
如果某个调度与某个串行调度冲突等价,那么称它是冲突可串行化的。
注意:
- 冲突等价比一般的等价条件要强。
- 如果一个调度是冲突可串行化的,则它一定是可串行化的(能保证安全正确性)。
总结
- 只要对每对冲突操作的顺序一致,就可以保证调度是安全的。
- 不需要真的跑一遍,可以通过分析调度顺序、冲突对,就能判断是否安全。
什么是依赖图
依赖图是一个有向图,用于判断一个并发调度是否等价于某个串行调度。
- 每个事务(Xact)是一个节点(node)
- 如果Ti中的某个操作与Tj中的某个操作冲突,并且Ti的操作在Tj之前执行,就从Ti画一条有向边到Tj
- 冲突操作=访问同一数据项,且至少有一个是写操作
- 操作的先后顺序以实际schedule顺序为准
结论:
调度的依赖图如果没有环(acyclic),就冲突可串行化。如果有环,不可串行化。
依赖图怎么画?(步骤)
- 遍历所有操作,找出所有冲突对
- 确定冲突对的顺序(谁先谁后)
- Ti先、Tj后,就画一条有向边 Ti→Tj
- 画完后看依赖图有没有环
图里两个例子的详细解释
例子1(无环,冲突可串行化)
操作序列
- T1先读A、写A,然后T2也读A、写A,接着T2还读写B
冲突
- T1的R(A)/W(A)和T2的W(A)有冲突,且T1的操作在T2之前,画T1→T2
- 没有其他需要添加的边(T1的操作都在T2之前)

依赖图
- 只有一条T1→T2,没有环。
- 所以是冲突可串行化的,等价于T1串行执行完再T2。
例子2(有环,不可串行化)
操作序列
- T1对A操作后,T2再对A操作;但后面T2对B操作后,T1又来读B
冲突
- T1的W(A)和T2的W(A)冲突,T1在前,画T1→T2
- T2的W(B)和T1的R(B)冲突,T2在前,画T2→T1

依赖图
- 既有T1→T2,也有T2→T1,形成了环。
- 有环,不能串行化,这种调度不能等价于任何一个串行schedule。
什么是视图可串行化(View Serializability)
视图可串行化是一种判断调度是否等价于串行调度的更宽松的方法。它只关心“最终的读写结果是否一样”,不关心中间步骤的操作顺序。
判断两个schedule是否视图等价(view equivalent),需满足:
- 初始读相同(Same initial reads)
每个事务对某个数据的第一次读取,必须是从同一个地方读的(要么是初始值,要么是相同的写操作)。
- 依赖读相同(Same dependent reads)
如果一个事务T1读取了另一个事务T2最后一次写的数据,T1在两个schedule里都应该读到同一个写值。
- 最终写相同(Same winning writes)
对每个数据,最后写入它的事务在两个schedule里都必须是同一个。
只要满足这三点,两个调度就是视图等价(view equivalent),这样的schedule就是视图可串行化(view serializable)。
和冲突可串行化的关系
- 所有冲突可串行化的schedule都是视图可串行化的。
- 但反之不成立。
- 也就是说,视图可串行化允许更多调度,但实现更复杂;实际数据库通常只检测冲突可串行化。
直观例子(图解)

例子展示了blind write(盲写)带来的特殊情况:
无论W(A)顺序如何,只要最终“谁写最后一次”一致,且每个R(A)读取来源一致,就可以视图等价(view equivalent)。
比如:
- 左边和右边的写顺序不同,但只要读到的内容和最终A的值一样,就是view equivalent。
而冲突等价就要求写操作顺序不能换,否则会“冲突”——所以,视图可串行化比冲突可串行化更宽松。
总结
- 视图可串行化:只要求读写依赖和最终值一样,不要求操作顺序严格一致,能识别更多合法schedule。
- 冲突可串行化:操作顺序严格,安全,但会漏掉一些其实是安全的schedule(如blind write)。
- 数据库实现通常用冲突可串行化,因为更容易判断和实现。
结论(Conclusion)
在这节内容中,我们摒弃了“只有一个用户访问数据库”的天真假设。我们讨论了如果数据库系统不保证 ACID(原子性、一致性、隔离性、持久性)属性,可能会出现哪些并发异常现象。我们了解到,事务(transaction) 是一个强大的机制,用来将一系列操作封装成单个、原子的、逻辑上的单元来执行。
在下一节内容中,我们将学习如何实际地为我们的事务调度强制实现冲突可串行化(conflict serializability)。