ACID 事务
在用户认证系统中,数据一致性至关重要。ACID 是数据库事务的四个核心特性。
Atomicity(原子性)
要么全部完成,要么全部失败。
Consistency(一致性)
事务前后数据状态都合法。
Isolation(隔离性)
并发事务互不干扰。
Durability(持久性)
提交后永久保存。
场景:用户转账
转账是检验 ACID 最直接的场景:两个账户、两次写入,中间任何一步失败都不该留下「钱少了但没到账」的中间状态。所以它天然是一个事务,而不是两个事务。
场景:用户转账
- 步骤 1:确认两个账户在同一个数据库里,跨库就无法用本地事务保证
- 步骤 2:在同一个事务中先扣减付款方余额,并校验余额是否充足
- 步骤 3:再在同一事务中增加收款方余额,并同步写入转账流水
- 步骤 4:提交后校验两边总额守恒,任何一步失败就整体回滚
关注点:两次余额变更是否被绑在同一个事务边界内。
原子性保证
事务执行过程:
BEGIN
├─ 操作 1:A 账户 -100 ✓
├─ 操作 2:B 账户 +100 ✓
└─ COMMIT ← 全部提交
如果任何一步失败:
BEGIN
├─ 操作 1:A 账户 -100 ✓
├─ 操作 2:B 账户 +100 ✗
└─ ROLLBACK ← 全部回滚,A 账户恢复原状
一致性示例
事务前状态:
用户 A: balance = 500
用户 B: balance = 300
总计:800
事务:A 转账 100 给 B
事务后状态:
用户 A: balance = 400
用户 B: balance = 400
总计:800 ← 守恒!
违反一致性的情况
一致性不是数据库自动给的,它是业务约束和事务边界共同保证的。上面几种写法都会让「总额守恒」被破坏,只是破坏的方式不同——把不变量收回事务里才是正解。
违反一致性的情况
- 步骤 1:把扣款和入账拆成两个事务,中途失败就会凭空丢钱
- 步骤 2:在事务外读取余额再更新余额,两个并发转账就会互相覆盖
- 步骤 3:扣款时不做余额校验,出现负余额这种业务上非法的状态
- 步骤 4:定位破坏一致性的具体环节,把它收回同一个事务和同一把锁里
关注点:哪些写法会破坏业务约束,以及破坏的到底是哪一条。
并发问题
场景: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:余额字段用 DECIMAL 而不是 FLOAT,避免浮点误差不断累积
- 步骤 2:给账户表主键和流水表的账户列建索引,把锁持有时间压到最短
- 步骤 3:把转账流水按账户和时间建复合索引,方便按账户维度对账
- 步骤 4:明确哪些表使用 InnoDB,保证它们支持事务与行级锁
关注点:表结构如何配合事务与行级锁工作。
持久化机制
Redo Log(重做日志)
- 记录所有修改
- 先写日志,再写数据
- 崩溃后可恢复
Binlog(二进制日志)
- 记录 SQL 语句
- 用于主从复制
- 用于时间点恢复
Checkpoint(检查点)
- 定期刷盘
- 加速恢复
配置建议
MySQL 的默认值不一定适合我们的负载,调整参数前要先想清楚牺牲什么:持久性换吞吐,还是一致性换延迟。这一节的每个配置都该有监控数据支撑,而不是凭感觉调。
配置建议:运行配置
- 步骤 1:按业务一致性要求选择隔离级别,在 READ COMMITTED 与 REPEATABLE READ 之间取舍
- 步骤 2:明确 innodb_flush_log_at_trx_commit 的取值,一致性优先时设为 1
- 步骤 3:保持 autocommit 默认开启,由业务显式开启和提交事务
- 步骤 4:监控 redo log 刷盘延迟与锁等待时长,作为后续调参的依据
关注点:隔离级别与持久化参数的取值,及其对性能和安全的影响。
1. 保持事务简短
事务越长,持锁越久,并发能力下降得越厉害。所以最有效的优化往往不是调参数,而是把不该在事务里做的事挪出去——尤其是网络调用。
1. 保持事务简短
- 步骤 1:把事务范围限制在写操作内,查询和远程调用一律挪到事务之外
- 步骤 2:避免在事务里做网络请求、文件 IO 或等待用户输入
- 步骤 3:按固定顺序访问多张表,必要时按同一顺序分段加锁
- 步骤 4:用监控观察事务平均耗时与锁等待时长,确认没有长事务存在
关注点:事务持锁时长与并发吞吐之间的取舍。
2. 避免死锁
死锁不是靠重试就能掩盖的偶发故障,它几乎总是加锁顺序不一致造成的。先把顺序统一,再补上重试兜底,否则重试只是把死锁换成更高的负载。
2. 避免死锁
- 步骤 1:统一多表访问的加锁顺序,避免不同请求以相反顺序加锁
- 步骤 2:缩小加锁范围,用索引精确定位行,并捕获死锁异常做有限次带退避的重试
- 步骤 3:监控 InnoDB 死锁计数,定位高频出现的具体 SQL
关注点:加锁顺序的统一,以及死锁发生后的重试策略。
3. 使用连接池
连接池解决的是连接本身的创建开销,但它自己也会成为瓶颈——池太小请求在排队,池太大数据库扛不住。特别是事务,连接被占用的时间比普通查询长得多。
3. 使用连接池
- 步骤 1:按并发量设定连接池大小,并给获取连接设置明确的超时时间
- 步骤 2:事务开始时取连接、提交后立即归还,不要跨业务逻辑长期持有
- 步骤 3:配置连接的存活检测,剔除已被数据库端断开的死连接
- 步骤 4:监控池内活跃连接数与等待队列,连接不够时能提前发现
关注点:连接池的容量、超时设置,以及事务期间的连接占用。
发现内容有问题?
告诉我们哪里需要改进。
