Database System(八)
2025-5-18
| 2025-5-18
Words 4410Read Time 12 min
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 四大属性

  1. 原子性(Atomicity):
      • 一个事务只能有两种结果:提交(commit)回滚(abort)
      • 要么事务中的所有操作都发生,要么都不发生。
  1. 一致性(Consistency):
      • 如果数据库开始时是“合法”的(符合各种约束和业务规则),事务结束后也必须保持一致性。
  1. 隔离性(Isolation):
      • 每个事务的执行过程相互隔离,好像数据库里一次只运行一个事务。
      • 实际上,DBMS(数据库管理系统)会把多个事务的操作交错执行,但最终的效果就像每个事务是单独执行的一样。
  1. 持久性(Durability):
      • 一旦事务提交了,其结果就是永久性的,即使系统崩溃也不会丢失。

总结

ACID保证数据库即使在高并发、多用户场景下,也能避免更新丢失、脏读、不可重复读等问题,保障数据的可靠性和正确性。

并发执行的动机

看起来并发执行带来了很多麻烦,那我们为什么还要支持它?
其实,实现并发有两个主要好处:
  1. 吞吐量提升(Increase in Throughput)
      • 吞吐量指每秒钟能处理的事务数(transactions per second)。
      • 举例:如果数据库系统只用一个核心,一个事务可以用CPU,另一个事务可以同时从磁盘读写。
      • 如果是多核CPU,可以通过并发让吞吐量理想情况下随着核心数线性提升。
  1. 延迟降低(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)

理想情况下,我们希望能够并发执行事务,但是最终效果和某个串行调度一样。这就引入了“可串行化”概念:

可串行化调度的条件

  1. 包含同样的事务
  1. 每个事务内部操作顺序与串行调度相同
  1. 最终让数据库达到和串行调度相同的状态
如果某个调度满足这些条件,我们称其为可串行化(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)**来判断。

两个操作发生冲突的条件:

  1. 操作属于不同的事务
  1. 操作对象是同一个资源(如同一条记录/变量);
  1. 至少有一个是写操作

判断方法:

  • 检查两个调度是否对每一对冲突操作的顺序一致
  • 如果一致,则两个调度最终数据库的状态一定相同。

专用名词:

  • 冲突等价(conflict equivalent):
    • 如果两个调度对所有冲突操作的顺序都一致,则称这两个调度冲突等价。
  • 冲突可串行化(conflict serializable):
    • 如果某个调度与某个串行调度冲突等价,那么称它是冲突可串行化的。
注意:
  • 冲突等价比一般的等价条件要强。
  • 如果一个调度是冲突可串行化的,则它一定是可串行化的(能保证安全正确性)。

总结

  • 只要对每对冲突操作的顺序一致,就可以保证调度是安全的。
  • 不需要真的跑一遍,可以通过分析调度顺序、冲突对,就能判断是否安全。

什么是依赖图

依赖图是一个有向图,用于判断一个并发调度是否等价于某个串行调度。
  • 每个事务(Xact)是一个节点(node)
  • 如果Ti中的某个操作与Tj中的某个操作冲突,并且Ti的操作在Tj之前执行,就从Ti画一条有向边到Tj
    • 冲突操作=访问同一数据项,且至少有一个是写操作
    • 操作的先后顺序以实际schedule顺序为准
结论:
调度的依赖图如果没有环(acyclic),就冲突可串行化。如果有环,不可串行化。

依赖图怎么画?(步骤)

  1. 遍历所有操作,找出所有冲突对
  1. 确定冲突对的顺序(谁先谁后)
  1. Ti先、Tj后,就画一条有向边 Ti→Tj
  1. 画完后看依赖图有没有环

图里两个例子的详细解释

例子1(无环,冲突可串行化)

操作序列

  • T1先读A、写A,然后T2也读A、写A,接着T2还读写B

冲突

  • T1的R(A)/W(A)和T2的W(A)有冲突,且T1的操作在T2之前,画T1→T2
  • 没有其他需要添加的边(T1的操作都在T2之前)
notion image

依赖图

  • 只有一条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
notion image

依赖图

  • 既有T1→T2,也有T2→T1,形成了环。
  • 有环,不能串行化,这种调度不能等价于任何一个串行schedule。

什么是视图可串行化(View Serializability)

视图可串行化是一种判断调度是否等价于串行调度的更宽松的方法。它只关心“最终的读写结果是否一样”,不关心中间步骤的操作顺序。

判断两个schedule是否视图等价(view equivalent),需满足:

  1. 初始读相同(Same initial reads)
    1. 每个事务对某个数据的第一次读取,必须是从同一个地方读的(要么是初始值,要么是相同的写操作)。
  1. 依赖读相同(Same dependent reads)
    1. 如果一个事务T1读取了另一个事务T2最后一次写的数据,T1在两个schedule里都应该读到同一个写值。
  1. 最终写相同(Same winning writes)
    1. 对每个数据,最后写入它的事务在两个schedule里都必须是同一个。
只要满足这三点,两个调度就是视图等价(view equivalent),这样的schedule就是视图可串行化(view serializable)

和冲突可串行化的关系

  • 所有冲突可串行化的schedule都是视图可串行化的。
  • 但反之不成立。
  • 也就是说,视图可串行化允许更多调度,但实现更复杂;实际数据库通常只检测冲突可串行化。

直观例子(图解)

notion image
例子展示了blind write(盲写)带来的特殊情况:
无论W(A)顺序如何,只要最终“谁写最后一次”一致,且每个R(A)读取来源一致,就可以视图等价(view equivalent)。
比如:
  • 左边和右边的写顺序不同,但只要读到的内容和最终A的值一样,就是view equivalent。
冲突等价就要求写操作顺序不能换,否则会“冲突”——所以,视图可串行化比冲突可串行化更宽松

总结

  • 视图可串行化:只要求读写依赖和最终值一样,不要求操作顺序严格一致,能识别更多合法schedule。
  • 冲突可串行化:操作顺序严格,安全,但会漏掉一些其实是安全的schedule(如blind write)。
  • 数据库实现通常用冲突可串行化,因为更容易判断和实现。

结论(Conclusion)

在这节内容中,我们摒弃了“只有一个用户访问数据库”的天真假设。我们讨论了如果数据库系统不保证 ACID(原子性、一致性、隔离性、持久性)属性,可能会出现哪些并发异常现象。我们了解到,事务(transaction) 是一个强大的机制,用来将一系列操作封装成单个、原子的、逻辑上的单元来执行。
在下一节内容中,我们将学习如何实际地为我们的事务调度强制实现冲突可串行化(conflict serializability)
 
  • database
  • transaction
  • 从斐波那契看动态规划DS7.2 Query Optimization
    Loading...