讲给老婆的数据系统设计-ACID事务

LuYanFCP

2025/03/09

为什么会有事务

“在数据系统的残酷现实中,很多事情都可能出错:

  1. 数据库软件、硬件可能在任意时刻发生故障(包括写操作进行到一半时)。
  2. 应用程序可能在任意时刻崩溃(包括一系列操作的中间)。
  3. 网络中断可能会意外切断数据库与应用的连接,或数据库之间的连接。
  4. 多个客户端可能会同时写入数据库,覆盖彼此的更改。
  5. 客户端可能读取到无意义的数据,因为数据只更新了一部分。
  6. 客户之间的竞争条件可能导致令人惊讶的错误。” — 《数据密集型应用系统设计》

在并发编程中,我们常常会关注多线程/进程/协程对同一块内存进行修改时候的正确性,这个时候我们因为了原子变量和锁来实现临界区去解决这个问题。在实际生活和开发中更多是操作主体总是会通过一系列的操作来完成一件事,这个时候我们也希望能提供类似于原子变量的工作来讲我们一系列操作进行保护,从而防止出现多个主体操作的非预期情况。

想象一下,你在用手机银行给朋友转账。这个过程其实有两步:从你的账户扣钱,然后把钱加到朋友的账户上。那么问题来了:

  1. 如果只完成一半怎么办?【原子】

假设银行系统在扣完你的钱后突然断电了,没能把钱加到朋友账户上。这时候钱就凭空消失了!你的账户少了钱,但朋友并没有收到。

事务就是为了解决这个问题:它确保一组相关操作要么全部完成,要么一个都不做。就像是给这些操作绑了个保险绳,不让它们半途而废。

  1. 多人同时操作怎么办?【隔离】

想象你和家人共用一个银行账户,账户里有1000元。你在商场准备刷卡买800元的东西,同一时间,你爱人在超市也想用这个账户买500元的菜。

如果系统没有适当控制,可能会出现这种情况:系统先检查账户有1000元(够付800元),然后你老婆那边系统也检查有1000元(够付500元)。结果你们俩同时消费了1300元,超出了账户里的1000元!

事务能够解决这个问题,它会确保在一个人操作的时候,其他人要么看到操作前的状态,要么看到操作后的状态,不会看到中间状态,避免大家操作冲突

  1. 数据会不会突然丢失?【持久】

假设你正在网上填写一个很长的表单,好不容易填完提交,系统提示"保存成功",但第二天你登录发现信息不见了!原来是系统在显示成功后,还没来得及真正保存到硬盘上就崩溃了。

事务确保一旦系统告诉你"操作成功",那么即使下一秒系统崩溃,你的数据也不会丢失。它就像是给你的重要资料上了一个保险锁。

总结来说事务模型就是为多种操作提供原子化/隔离性/持久性的一个编程模型。也就是事务的ACID模型。 【A:Atomicity,C:Consistency,I:Isolation,D:Durability】

ACID事务特性:

原子性:

这个原子性其实是计算机中“原子”概念的衍生,计算机的原子性指一个操作或者数据没有中间状态,只有开始和结束,没有中间状态。而事务提到的原子其实是有中间状态,但是中间状态对于其他的操作主体是无法干预的,同时如果中止,则在事务中所有操作都会被回滚。DDIA书中更多的称为可中止性(abortability):“能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力”

一致性:

事务一致性是ACID特性中最为核心却也最容易被误解的概念。从形式化的角度看,一致性指的是事务执行前后,数据库必须从一个一致性状态转变为另一个一致性状态。所谓一致性状态,是指数据库中的数据满足所有预定义的完整性约束(integrity constraints)。

  1. 实体完整性(Entity Integrity): 通过主键约束实现,确保每个实体具有唯一标识
  2. 参照完整性(Referential Integrity): 通过外键约束实现,保证实体间引用关系的有效性
  3. 域完整性(Domain Integrity): 通过数据类型、CHECK约束等实现,确保属性值满足预定义规则
  4. 用户定义完整性(User-Defined Integrity): 通过触发器、存储过程等实现特定业务规则

一致性可被视为一个不变量(invariant)的保持过程,它确保数据库状态转换符合业务规则和领域逻辑。与原子性和隔离性不同,一致性的保证不仅依赖于数据库系统本身,还需要应用程序正确实现业务逻辑。同时原子性、隔离性和持久性是实现一致性的技术手段

隔离性:

隔离性指的是两个事务之间的操作和中间状态和变量互相看不见,即使一个用户做了一个写操作其他用户也看不到。

  sequenceDiagram
    participant User1 as User 1
    participant DB as Database
    participant User2 as User 2

    
    User1->>DB: get counter
    DB-->>User1: 42
    
    Note over User1: [42 + 1 = 43]
    
    User2->>DB: get counter
    DB-->>User2: 42
    
    Note over User2: [42 + 1 = 43]
    
    User1->>DB: set counter = 43
    DB-->>User1: ok
    
    User2->>DB: set counter = 43 
    DB-->>User2: ok

最简单的时间就是事务之间完全串行,这样的话就会完全保证事务之间完全是看不到中间状态,但是在实际情况情况下为了提高效率,一般会将隔离优化为4个等级:

  1. 读未提交(Read Uncommitted)

  2. 读已提交(Read Committed)

  3. 可重复读(Repeatable Read)

  4. 可串行化(Serializable) 这四个等级从上到下越来越严格。同时不同隔离性主要会产生三个问题:

  5. 脏读:指读取到其他事务中未提交的内容。

  6. 不可重复读:指在自己事务未提交的时候读取到了其他事务中提交的内容。两次读取的不同

  sequenceDiagram
    participant T1 as 事务 T1
    participant T2 as 事务 T2
    T1->>DB: 开始事务
    T2->>DB: 开始事务
    T1->>DB: 查询X
    Note over T1: X = 10
    T2->>DB: 查询数值 X
    Note over T2: X = 10
    T2->>DB: 插入新记录,数值 X = 15
    T2->>DB: 提交事务
    T1->>DB: 查询数值 X
    Note over T1: X = 15 
    T1->>DB: 提交事务
  1. 幻读:同一事务内,相同的查询条件执行两次,返回的记录集合不同。例如:事务A查询余额>500的所有账户,得到3条记录;同时事务B插入一个余额为1000的新账户并提交;事务A用相同条件再次查询时,得到4条记录。主要是涉及INSERT或DELETE操作导致符合条件的记录数量变化。他和不可重复读核心是因为幻读并不是某个数据的值发生了变化,也不是数据之间产生的竞争,而是因为其他事务的插入导致查询的记录的集合发生了变化。
  2. 写偏斜:幻读更极端的一种方式,这个是因为在事务中使用记录集合进行判断,从而执行后一个步骤,例如在DDIA中有这样一个场景,两个医生同时要下班,而医院要求必须有一个在岗,因此两人在提交下班的时候发生了如下情况:

Image

因为判断的原因两个人同时下班的事务完成了,并违反了只有一个人下班的要求。

Mysql InnDB通过提供间隙锁的方式避免幻读和写偏斜问题【“物化冲突(materializing conflicts)"】,间隙锁是可以锁定一个范围内B+树的前后pre/next指针,因此可以防止在区域范围内更新,从而防止select的时候记录不同导致的问题。 使用方式:SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE:

隔离级别 脏读
(Dirty Read)
不可重复读
(Non-repeatable Read)
幻读
(Phantom Read)
写偏斜
(Write Skew)
读未提交
(Read Uncommitted)
✓ 会发生 ✓ 会发生 ✓ 会发生 ✓ 会发生
读已提交
(Read Committed)
✗ 不会发生 ✓ 会发生 ✓ 会发生 ✓ 会发生
可重复读
(Repeatable Read)
✗ 不会发生 ✗ 不会发生 ✓ 会发生+ ✓ 会发生
可串行化
(Serializable)
✗ 不会发生 ✗ 不会发生 ✗ 不会发生 ✗ 不会发生
注:某些数据库实现(如MySQL InnoDB)在可重复读级别下通过间隙锁也防止了幻读,但标准SQL规范中的可重复读级别并不保证防止幻读。

ACID事务模型的实现

ACID实现起来一般指的是满足ACID要求,且隔离等级为可串行化的实现。

真的串行化

事务之间不并发,完全的按顺序执行,常见于单线程轻量级操作的数据库,比如Redis,由于内存的不断变大【内存数据库】以及OTPL事务越来愈短,同时单线程没有那么锁成本比较低。

一般来说这种实现需要满足:

  1. 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
  2. 仅限于活跃数据集可以放入内存的情况。很少访问的数据可能会被移动到磁盘,但如果需要在单线程执行的事务中访问,系统就会变得非常慢x。
  3. 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调
  4. 跨分区事务是可能的,但是它们能被使用的程度有很大的限制。

2PL 两阶段锁

实现了两个锁,共享锁【以下使用S表示】和排他锁【以下使用X来表示】,其实对应的就是读写锁的读锁和所有读写操作都要进行锁保护,锁的优先级写优先于读。

  1. 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
  2. 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
  3. 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
  4. 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。

两阶段锁可以解决脏读/可重复读的问题,但是无法解决幻读/读偏移的问题:

实现核心:

  1. LockManager 提供了基础的排查锁的实现,主要实现了锁升级【X-S锁之间的更换】和锁的记录【跟事务关联】
当前资源状态 请求锁类型 结果 说明
无锁 共享锁(S) 获取成功 资源从无锁状态转为共享锁状态
无锁 排他锁(X) 获取成功 资源从无锁状态转为排他锁状态
共享锁(事务A) 共享锁(事务B) 获取成功 多个事务可同时持有共享锁
共享锁(事务A) 排他锁(事务A) 获取成功 锁升级:只有当A是唯一持有共享锁的事务
共享锁(事务A) 排他锁(事务B) 获取失败 排他锁与任何其他锁不兼容
共享锁(多个事务) 排他锁(其中一个事务) 获取失败 锁升级失败:其他事务仍持有共享锁
排他锁(事务A) 共享锁(事务A) 已持有更强锁 排他锁权限包含共享锁权限
排他锁(事务A) 共享锁(事务B) 获取失败 排他锁与任何其他锁不兼容
排他锁(事务A) 排他锁(事务A) 已持有锁 重复获取相同锁
排他锁(事务A) 排他锁(事务B) 获取失败 排他锁与任何其他锁不兼容
  1. Transaction:事务过程中,如果任务有读写操作开始加锁,用户管理S-X锁的状态机,记录在Transaction上的读写记录。
  2. TransactionManager:用于记录多个Transaction之间的冲突和记录,管理多个Transaction,同时是事务的借口,提供了事务的各种操作,比如说开始事务,提交事务。
  3. Database:提供了读写接口,同时提供事务的beginShrinkingPhase 显示让事务进入收缩阶段,方法进入,之后禁止访问新资源。releaseLocks 事务Commit用户释放整体的锁。

一个简单的实现逻辑:

  classDiagram
    class LockType {
        <<enumeration>>
        SHARED
        EXCLUSIVE
    }

    class TransactionState {
        <<enumeration>>
        ACTIVE
        COMMITTED
        ABORTED
    }

    class LockRequest {
        +int transactionId
        +LockType lockType
        +bool granted
        +LockRequest(int tid, LockType type)
    }

    class LockManager {
        -lockTable Map
        -mutex mtx
        +acquireLock(int, string, LockType) bool
        +releaseLock(int, string) void
        +releaseAllLocks(int) void
    }

    class Transaction {
        -int id
        -TransactionState state
        -bool growing
        -Database* db
        -vector accessedResources
        +getId() int
        +beginShrinking() void
        +recordAccess(string) void
        +commit() void
        +abort() void
        +getState() TransactionState
        +isGrowing() bool
        +getAccessedResources() vector
    }

    class Database {
        -LockManager lockManager
        -Map data
        -mutex dataMutex
        +read(Transaction*, string, T&) bool
        +write(Transaction*, string, T) bool
        +releaseLocks(Transaction*) void
        +beginShrinkingPhase(Transaction*, string) void
    }

    class TransactionManager {
        -Database db
        -int nextTransactionId
        -Map activeTransactions
        -mutex txnMutex
        +beginTransaction() shared_ptr
        +commitTransaction(int) void
        +abortTransaction(int) void
        +getDatabase() Database&
    }

    LockManager "1" --* "many" LockRequest : 管理
    Database "1" --* "1" LockManager : 包含
    Transaction "many" --o "1" Database : 使用
    TransactionManager "1" --* "1" Database : 包含
    TransactionManager "1" --* "many" Transaction : 管理
    Transaction --> TransactionState : 使用
    LockRequest --> LockType : 使用
  sequenceDiagram
    participant Client
    participant TM as TransactionManager
    participant T as Transaction
    participant DB as Database
    participant LM as LockManager
    
    Client->>TM: beginTransaction()
    TM->>T: 创建新事务
    TM-->>Client: 返回事务对象
    
    Client->>DB: read(txn, key, result)
    DB->>T: 检查事务状态与阶段
    DB->>LM: acquireLock(tid, key, SHARED)
    LM-->>DB: 返回锁状态
    
    alt 获取锁成功
        DB->>T: recordAccess(key)
        DB->>DB: 读取数据
        DB-->>Client: 返回数据
    else 获取锁失败
        DB-->>Client: 返回失败
    end
    
    Client->>DB: write(txn, key, value)
    DB->>T: 检查事务状态与阶段
    DB->>LM: acquireLock(tid, key, EXCLUSIVE)
    LM-->>DB: 返回锁状态
    
    alt 获取锁成功
        DB->>T: recordAccess(key)
        DB->>DB: 写入数据
        DB-->>Client: 返回成功
    else 获取锁失败
        DB-->>Client: 返回失败
    end
    
    Client->>TM: commitTransaction(tid)
    TM->>T: commit()
    T->>T: state = COMMITTED
    T->>DB: releaseLocks(this)
    DB->>LM: releaseAllLocks(tid)
    LM-->>DB: 完成
    DB-->>T: 完成
    T-->>TM: 完成
    TM-->>Client: 完成

多版本并发控制【MVCC】:

MVCC并不是实现一个数据库事务必然的途径,但是提供的多版本隔离确实可以提高并行度,一般用来实现“读已提交"和"可重复读"两个隔离机制。通过维护数据的多个版本实现非阻塞的读-写并发,同时保证事务隔离性。其核心机制是为每行数据维护版本链,每个数据版本关联创建和删除的事务ID,查询时根据当前事务的快照仅访问对其可见的版本。例如,在PostgreSQL中,当事务T1(txid=100)开始后,事务T2(txid=101)更新了表users中id=1的记录,将name从"Alice"改为"Bob"并提交,此时系统会创建一个新版本(name=“Bob”,xmin=101)并标记旧版本(name=“Alice”,xmin=95,xmax=101);当T1查询该记录时,由于遵循快照隔离规则,它只能看到xmin<100且xmax为空或xmax>100的版本,因此会返回"Alice",而新事务T3则会看到"Bob",从而在不使用锁的情况下实现了事务的隔离性和并发性。

一个简单的MVCC实现,这里借鉴了LevelDB的MVCC实现:

  1. DataVersion:基础的数据记录的DataModel
  2. Transactions:用于事务管理,write_set用来记录写事件来最后仲裁冲突。
  3. MVCCDataBase:用户数据接口的管理:
    1. active_txns_: 提供了基础的事务
    2. data_:用与存储版本信息,按时间顺序存储。

读逻辑:一个事务只能读到在它开始时已经存在(已提交)的数据版本。这确保了可重复读,因为无论其他事务如何修改数据,当前事务总是看到它开始时数据的一致性快照。

  1. 首先,获取事务锁txn_mutex_
  2. 检查事务ID是否有效(是否存在于活跃事务中)
  3. 检查该事务的写集中是否已经有这个键的写入记录
    • 如果有,直接返回写集中的值(这确保事务能看到自己的修改)
  4. 如果事务写集中没有这个键,则需要从数据存储中读取
    • 获取数据共享锁data_mutex_(读锁)
    • 查找键对应的所有版本
    • 如果键不存在,返回空
  5. 在找到的所有版本中,从最新的版本开始向前遍历,寻找符合条件的版本:
    • 版本的开始时间戳begin_ts小于等于事务的开始时间戳txn.start_ts
    • 版本的结束时间戳end_ts大于事务的开始时间戳或者版本的结束时间戳是最大值(表示当前版本有效)
  6. 返回找到的第一个符合条件的版本的值

写逻辑:特点:写操作不直接修改数据库中的数据,仅在事务的私有缓存(write_set)中记录写入意图, 此阶段的修改只对当前事务可见,对其他事务不可见,没有创建新的数据版本,只是记录了将要写入的值。

  1. 直接将写操作存储在意图表txn.write_set中,提交的时候产生新的版本。

提交逻辑:检查冲突,提交新版本到_data中

  1. 冲突检测
    • 检查是否有其他事务在当前事务开始后创建了新版本
    • 如存在冲突,则回滚事务并放弃所有写入
  2. 版本更新
    • 将当前活跃的版本标记为过期(设置其end_ts为当前提交时间戳)
    • 为每个修改的键创建新的数据版本
    • 新版本使用提交时间戳作为其开始时间戳(begin_ts)
  3. 写入特性
    • 多个写入项作为一个原子单元一起提交或回滚
    • 写入通过创建新版本而不是修改现有数据来实现
    • 所有写入共享相同的提交时间戳,确保一致性
  classDiagram
    class MVCCDatabase {
        -atomic<uint64_t> next_txn_id_
        -atomic<uint64_t> next_timestamp_
        -unordered_map<string, vector<DataVersion>> data_
        -shared_mutex data_mutex_
        -map<uint64_t, Transaction> active_txns_
        -mutex txn_mutex_
        +begin(bool read_only) uint64_t
        +commit(uint64_t txn_id) bool
        +rollback(uint64_t txn_id) void
        +read(uint64_t txn_id, string key) optional<string>
        +write(uint64_t txn_id, string key, string value) bool
    }

    class DataVersion {
        +uint64_t txn_id
        +uint64_t begin_ts
        +uint64_t end_ts
        +string value
        +DataVersion(uint64_t txn, uint64_t begin, string val)
    }

    class Transaction {
        +uint64_t id
        +uint64_t start_ts
        +bool read_only
        +unordered_map<string, string> write_set
    }

    MVCCDatabase *-- DataVersion : contains
    MVCCDatabase *-- Transaction : manages

Mysql中如何实现MVCC

mysql中是基于undolog实现的MVCC首先InnoDB在每行数据中维护两个隐藏列:

写逻辑:

  1. 写意图阶段

    • 获取排他锁(X锁)
    • 将原始数据复制到undo日志中
    • undo日志条目链接在一起形成版本链
  2. 实际修改

    • 直接修改表中的数据行
    • 用当前事务ID更新DB_TRX_ID
    • 用新undo日志记录的指针更新DB_ROLL_PTR
  3. 提交阶段

    • 记录redo日志并刷盘(持久化)
    • 释放行锁
    • 标记事务为已提交

读逻辑,读操作创建读视图(Read View),包含以下信息:

版本可见性判断规则:

  1. 如果行的DB_TRX_ID < up_limit_id,说明该版本由早已提交的事务创建,可见
  2. 如果行的DB_TRX_ID >= low_limit_id,说明该版本由在视图创建后才开始的事务创建,不可见
  3. 如果行的DB_TRX_IDtrx_ids列表中,说明由一个还未提交的事务创建,不可见
  4. 否则,版本由已提交事务创建,可见

如果当前版本不可见,则沿着DB_ROLL_PTR访问undo日志中的历史版本,直到找到可见版本。


查看原始Issue