大家好,我是小耶,写功课是为了我踩过的坑,你们别再踩了!
你有没有遇到过这种情况:数据库 TPS 明明不高,但偶尔会卡死。查慢查询日志,没发现什么大 SQL。查 CPU 和 IO,都挺正常。最后发现是死锁。
更诡异的是,死锁自动回滚了,业务也没报错,但就是偶尔响应慢。
这不是玄学。这是 OLTP 并发控制的底层机制在"暗中作祟"。
一、为什么 OLTP 的并发控制这么难?
OLTP 的核心挑战是:大量事务同时操作同一批数据,既要保证隔离性(互不干扰),又要尽可能提高并发度。
这就像早高峰的十字路口------每辆车都想 fastest 通过,但如果没有红绿灯,结果就是谁都走不了。
数据库的"红绿灯"就是并发控制机制。但比红绿灯复杂一万倍,因为:
- 事务有优先级之分(不是所有车都一样)
- 有的事务只看数据不加锁(有的车只是路过不转弯)
- 有的事务既要读又要写(有的车要掉头)
- 有的事务要同时锁定多行(有的车要占两条车道)
二、MVCC:OLTP 高并发的关键技术
2.1 MVCC 是什么?
MVCC(多版本并发控制)是现代 OLTP 存储引擎的核心并发机制。它的核心思想:数据的每个修改都保留历史版本,读操作不需要加锁。
InnoDB 的每一行记录都包含两个隐藏列:
sql
Row Structure:
[trx_id] -- 创建或修改此行的事务 ID
[roll_ptr] -- 指向 Undo Log 中的历史版本
[data...] -- 用户数据
当事务读取数据时:
- 事务启动时生成一个 Read View(包含当前活跃事务 ID 列表)
- 读取行记录时,检查
trx_id是否在活跃列表中 - 如果是未提交事务创建的版本,就沿着
roll_ptr回溯历史版本
2.2 MVCC 的实战影响
优势:
- 读操作不需要加锁,大幅提升并发读性能
- 可重复读隔离级别下,不需要范围锁来防止幻读
- 快照读和当前读分离
代价:
- 历史版本占用额外存储空间
- 版本链过长时,回溯开销增大
- purge 线程必须及时清理旧版本
2.3 长事务是 OLTP 杀手
我在厂里排查过一次性能问题:MES 系统的报工接口突然从 50ms 变成 2 秒。查了一圈:
- Buffer Pool 命中率正常(98%+)
- 没有慢查询
- 锁等待时间也很短
最后发现是一个后台报表查询跑了 3 分钟没提交。因为 Read View 一直挂着,Undo Log 里的旧版本无法清理,版本链越来越长,后续的读写操作都要遍历很长的版本链才能找到可见数据。
教训:长事务不提交,MVCC 的版本链就会越堆越长,最终拖垮整个 OLTP 系统。一定要设事务超时限制。
另外还有一个容易被忽视的点:事务号耗尽。老版本 MySQL 用 32 位事务号,在高并发 OLTP 系统里(日均千万级事务),事务号可能不到一年就耗尽,届时需要停机维护。金仓 KES 用了 64 位事务号,从架构层面规避了这个问题。在做 Oracle 迁移的时候,如果源端 Oracle 的并发量很高,目标端的事务号位宽一定要提前评估,否则迁移完成运行一段时间就可能出现性能骤降。
2.4 怎么监控 MVCC 健康状态?
sql
-- 查看 Undo Log 版本链长度
SHOW GLOBAL STATUS LIKE 'Innodb_history_list_length';
这个值持续增长,说明 purge 跟不上,需要排查长事务。
三、锁:写操作的保护伞
MVCC 处理的是"读"的并发。对于"写",仍然需要锁。
3.1 InnoDB 的锁类型
| 锁类型 | 作用范围 | 说明 |
|---|---|---|
| 行锁(Record Lock) | 具体的索引记录 | UPDATE/DELETE 对特定行加锁 |
| 间隙锁(Gap Lock) | 索引记录之间的间隙 | 防止其他事务在间隙中插入新行 |
| Next-Key Lock | 行锁 + 间隙锁 | InnoDB RR 级别下的默认锁 |
| 意向锁(Intention Lock) | 表级别 | 声明事务打算对表中哪些行加锁 |
3.2 悲观锁 vs 乐观锁
悲观锁:假设冲突经常发生,操作前先加锁。
sql
-- SELECT ... FOR UPDATE:对查询结果加排他锁
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
乐观锁:假设冲突很少发生,提交时检查是否有冲突。
ini
-- 通过版本号字段实现
UPDATE accounts SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;
选型建议:高冲突场景用悲观锁,低冲突场景用乐观锁。电商库存扣减通常用悲观锁,因为并发冲突概率高。
四、死锁:检测、排查与预防
4.1 死锁是怎么形成的?
当两个事务互相等待对方释放锁时,就会发生死锁:
css
事务 A:持有 row1 的锁,等待 row2 的锁
事务 B:持有 row2 的锁,等待 row1 的锁
→ 死锁
InnoDB 使用等待图(Wait-For Graph) 检测死锁:将事务和锁依赖关系建模为有向图,检测到环就回滚代价较小的事务。
4.2 怎么排查死锁?
sql
SHOW ENGINE INNODB STATUS\G
在输出的 LATEST DETECTED DEADLOCK 段可以看到:
- 哪个事务被回滚了
- 每个事务持有的锁和等待的锁
- 涉及的 SQL 语句
4.3 死锁预防策略
策略一:固定访问顺序
sql
-- 所有事务都按 id 升序访问
UPDATE accounts SET balance = balance - 100 WHERE id IN (1, 2) ORDER BY id;
这样就不会出现 T1 锁 row1 等 row2、T2 锁 row2 等 row1 的情况。
策略二:降低隔离级别
RC 级别的 InnoDB 不使用 Gap Lock,只使用 Record Lock,死锁概率低于 RR 级别。但需要接受不可重复读的可能性。
策略三:缩短事务持有锁的时间
sql
-- ❌ 长事务,锁持有时间长
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ... 执行大量非数据库操作(2秒)...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- ✅ 短事务,锁持有时间短
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- ... 非数据库操作放在事务外 ...
五、OLTP 性能基准:TPC-C 与实战方法
5.1 TPC-C 是什么?
TPC-C 是 OLTP 领域最权威的性能基准,模拟一个批发供应商的业务系统,包含 5 种事务:
| 事务 | 占比 | 描述 |
|---|---|---|
| New-Order | 45% | 创建新订单,涉及多表写入 |
| Payment | 43% | 处理客户付款 |
| Order-Status | 4% | 查询最近订单 |
| Delivery | 4% | 批量发货 |
| Stock-Level | 4% | 查询库存水平 |
核心指标:
- tpmC:每分钟完成的新订单事务数
- Price/tpmC:每 tpmC 的成本(性价比)
5.2 实用 OLTP 压测方法
ini
sysbench oltp_read_write \
--table-size=1000000 \
--tables=16 \
--threads=64 \
--time=300 \
--db-driver=mysql \
run
关注指标:
- transactions:总事务数和 TPS
- latency:avg / min / max / 95th / 99th percentile
- thread fairness:各线程执行时间分布(均匀说明无热点)
5.3 生产环境性能基线
| 指标 | 小型 OLTP | 中型 OLTP | 大型 OLTP |
|---|---|---|---|
| TPS | 500-2,000 | 2,000-10,000 | 10,000-100,000+ |
| P99 延迟 | <200ms | <100ms | <50ms |
| 并发连接 | 50-200 | 200-1,000 | 1,000-10,000+ |
六、分布式 OLTP 的并发控制
6.1 CAP 定理的取舍
| 选择 | 代表系统 | 一致性 | 可用性 | OLTP 适用性 |
|---|---|---|---|---|
| CP | Spanner、CockroachDB | 强一致 | 分区时不可用 | 金融级 OLTP |
| AP | DynamoDB、Cassandra | 最终一致 | 分区时可用 | 不适合强一致 OLTP |
金融级 OLTP 几乎都选 CP:宁可分区时不可用,也不能接受数据不一致。
6.2 分布式事务协议
两阶段提交(2PC) :
- Phase 1:协调者问所有参与者"准备好了吗"
- Phase 2:如果都准备好了就提交,否则回滚
2PC 的问题:协调者单点故障、性能开销大(两次网络往返)、锁持有时间长。
优化方案:
- Percolator(Google):用乐观并发控制替代 2PC 的悲观锁
- Spanner TrueTime:用硬件原子钟实现全局时序,减少锁等待
6.3 全局时钟
分布式 OLTP 需要全局时钟来给事务排序:
| 方案 | 精度 | 代表系统 |
|---|---|---|
| NTP 时间同步 | 毫秒级 | 大多数分布式数据库 |
| HLC(混合逻辑时钟) | 逻辑顺序 | TiDB、CockroachDB |
| TrueTime(硬件时钟) | 微秒级 | Google Spanner |
| TSO(集中授时服务) | 毫秒级 | OceanBase |
七、HTAP:并发控制的新维度
HTAP 试图在同一套引擎中同时支持 OLTP 和 OLAP 负载,这给并发控制增加了新的维度。
资源隔离方案:通过资源组和连接池隔离 OLTP/OLAP 负载。金仓 KES 就是走的这条路------在同一数据库实例中,用资源组把 OLTP 和 OLAP 的 CPU/内存/IO 分开,避免分析查询拖慢事务性能。这种方案适合中小规模场景,不需要额外部署分析引擎。而且 OLTP 和 OLAP 之间通过 logical decoding 实时同步,数据一致性有保障,不用像双引擎方案那样担心同步延迟。
双引擎方案:行存 + 列存双引擎(如 TiDB 的 TiKV + TiFlash)。OLTP 写入行存,异步同步到列存,OLAP 查询走列存。隔离更彻底,但运维复杂度更高。
选型判断:如果你的系统日均事务量在百万级以下,偶尔有分析需求,资源隔离方案更经济;如果 OLAP 查询频率高且数据量大,双引擎方案更稳。
八、总结
OLTP 并发控制的核心要点:
- MVCC 让读写不互锁,但长事务会让版本链堆积,是 OLTP 系统的隐形杀手
- 死锁不是 bug,是并发系统的自然现象,关键是预防和快速恢复
- 隔离级别的选择是一致性与并发的权衡,RC 死锁少但可能有不可重复读
- 分布式 OLTP 用一致性换可用性,选型要看业务对一致性的容忍度
- 压测一定要看延迟分布,不能只看 TPS
👋 小耶在手,SQL不愁