ACID 事务

在用户认证系统中,数据一致性至关重要。ACID 是数据库事务的四个核心特性。

什么是 ACID?

ACID 事务特性

Atomicity(原子性)

要么全部完成,要么全部失败。

Consistency(一致性)

事务前后数据状态都合法。

Isolation(隔离性)

并发事务互不干扰。

Durability(持久性)

提交后永久保存。

A - Atomicity 原子性

场景:用户转账

转账是检验 ACID 最直接的场景:两个账户、两次写入,中间任何一步失败都不该留下「钱少了但没到账」的中间状态。所以它天然是一个事务,而不是两个事务。

设计流程

场景:用户转账

  1. 步骤 1:确认两个账户在同一个数据库里,跨库就无法用本地事务保证
  2. 步骤 2:在同一个事务中先扣减付款方余额,并校验余额是否充足
  3. 步骤 3:再在同一事务中增加收款方余额,并同步写入转账流水
  4. 步骤 4:提交后校验两边总额守恒,任何一步失败就整体回滚

关注点:两次余额变更是否被绑在同一个事务边界内。

原子性保证

事务执行过程:

BEGIN
  ├─ 操作 1:A 账户 -100  ✓
  ├─ 操作 2:B 账户 +100  ✓
  └─ COMMIT               ← 全部提交

如果任何一步失败:
BEGIN
  ├─ 操作 1:A 账户 -100  ✓
  ├─ 操作 2:B 账户 +100  ✗
  └─ ROLLBACK             ← 全部回滚,A 账户恢复原状

C - Consistency 一致性

一致性示例

事务前状态:
用户 A: balance = 500
用户 B: balance = 300
总计:800

事务:A 转账 100 给 B

事务后状态:
用户 A: balance = 400
用户 B: balance = 400
总计:800  ← 守恒!

违反一致性的情况

一致性不是数据库自动给的,它是业务约束和事务边界共同保证的。上面几种写法都会让「总额守恒」被破坏,只是破坏的方式不同——把不变量收回事务里才是正解。

设计流程

违反一致性的情况

  1. 步骤 1:把扣款和入账拆成两个事务,中途失败就会凭空丢钱
  2. 步骤 2:在事务外读取余额再更新余额,两个并发转账就会互相覆盖
  3. 步骤 3:扣款时不做余额校验,出现负余额这种业务上非法的状态
  4. 步骤 4:定位破坏一致性的具体环节,把它收回同一个事务和同一把锁里

关注点:哪些写法会破坏业务约束,以及破坏的到底是哪一条。

I - Isolation 隔离性

并发问题

场景:A 账户有 500 元,同时发起两笔转账

时间线:
T1: 事务 1 读取 A 余额 = 500
T2: 事务 2 读取 A 余额 = 500   ← 脏读
T3: 事务 1 转账 100 给 B,A = 400
T4: 事务 2 转账 200 给 C,A = 300  ← 错误!应该是 100

结果:A 的余额计算错误

隔离级别

隔离级别脏读不可重复读幻读性能
READ UNCOMMITTED可能可能可能最高
READ COMMITTED避免可能可能高
REPEATABLE READ避免避免可能中
SERIALIZABLE避免避免避免最低

MySQL 中的使用

落到 MySQL 上,ACID 不只是概念,它对表结构有具体要求:引擎要用 InnoDB,金额要用 DECIMAL,索引要能支撑行级锁。结构不对,事务写得再小心也没用。

设计流程

MySQL 中的使用:数据模型

  1. 步骤 1:余额字段用 DECIMAL 而不是 FLOAT,避免浮点误差不断累积
  2. 步骤 2:给账户表主键和流水表的账户列建索引,把锁持有时间压到最短
  3. 步骤 3:把转账流水按账户和时间建复合索引,方便按账户维度对账
  4. 步骤 4:明确哪些表使用 InnoDB,保证它们支持事务与行级锁

关注点:表结构如何配合事务与行级锁工作。

D - Durability 持久性

持久化机制

MySQL InnoDB 持久化保证

Redo Log(重做日志)

  • 记录所有修改
  • 先写日志,再写数据
  • 崩溃后可恢复

Binlog(二进制日志)

  • 记录 SQL 语句
  • 用于主从复制
  • 用于时间点恢复

Checkpoint(检查点)

  • 定期刷盘
  • 加速恢复

配置建议

MySQL 的默认值不一定适合我们的负载,调整参数前要先想清楚牺牲什么:持久性换吞吐,还是一致性换延迟。这一节的每个配置都该有监控数据支撑,而不是凭感觉调。

设计流程

配置建议:运行配置

  1. 步骤 1:按业务一致性要求选择隔离级别,在 READ COMMITTED 与 REPEATABLE READ 之间取舍
  2. 步骤 2:明确 innodb_flush_log_at_trx_commit 的取值,一致性优先时设为 1
  3. 步骤 3:保持 autocommit 默认开启,由业务显式开启和提交事务
  4. 步骤 4:监控 redo log 刷盘延迟与锁等待时长,作为后续调参的依据

关注点:隔离级别与持久化参数的取值,及其对性能和安全的影响。

事务最佳实践

1. 保持事务简短

事务越长,持锁越久,并发能力下降得越厉害。所以最有效的优化往往不是调参数,而是把不该在事务里做的事挪出去——尤其是网络调用。

设计流程

1. 保持事务简短

  1. 步骤 1:把事务范围限制在写操作内,查询和远程调用一律挪到事务之外
  2. 步骤 2:避免在事务里做网络请求、文件 IO 或等待用户输入
  3. 步骤 3:按固定顺序访问多张表,必要时按同一顺序分段加锁
  4. 步骤 4:用监控观察事务平均耗时与锁等待时长,确认没有长事务存在

关注点:事务持锁时长与并发吞吐之间的取舍。

2. 避免死锁

死锁不是靠重试就能掩盖的偶发故障,它几乎总是加锁顺序不一致造成的。先把顺序统一,再补上重试兜底,否则重试只是把死锁换成更高的负载。

设计流程

2. 避免死锁

  1. 步骤 1:统一多表访问的加锁顺序,避免不同请求以相反顺序加锁
  2. 步骤 2:缩小加锁范围,用索引精确定位行,并捕获死锁异常做有限次带退避的重试
  3. 步骤 3:监控 InnoDB 死锁计数,定位高频出现的具体 SQL

关注点:加锁顺序的统一,以及死锁发生后的重试策略。

3. 使用连接池

连接池解决的是连接本身的创建开销,但它自己也会成为瓶颈——池太小请求在排队,池太大数据库扛不住。特别是事务,连接被占用的时间比普通查询长得多。

设计流程

3. 使用连接池

  1. 步骤 1:按并发量设定连接池大小,并给获取连接设置明确的超时时间
  2. 步骤 2:事务开始时取连接、提交后立即归还,不要跨业务逻辑长期持有
  3. 步骤 3:配置连接的存活检测,剔除已被数据库端断开的死连接
  4. 步骤 4:监控池内活跃连接数与等待队列,连接不够时能提前发现

关注点:连接池的容量、超时设置,以及事务期间的连接占用。

发现内容有问题?

告诉我们哪里需要改进。