SQL 高并发事务优化实践

1次阅读

根本原因是锁粒度与事务时长不匹配:无索引导致表锁、慢查询延长持锁时间、事务混入非db操作;应优化执行计划、缩小锁范围、缩短事务、避免隐式锁升级。

SQL 高并发事务优化实践

为什么 select ... for UPDATE 在高并发下会卡死

根本原因不是锁本身,而是锁的粒度和事务持续时间不匹配。InnoDB 默认走行锁,但若查询条件没走索引,会退化成表锁;更常见的是事务里混了慢查询、网络 IO 或应用层逻辑,让锁持有时间远超预期。

  • 检查执行计划:务必确认 EXPLAIN 输出中 typerefrange,不是 ALLindex
  • 锁范围要最小化:避免 SELECT * FROM t WHERE status = 'pending' FOR UPDATE 这种无主键/唯一约束的扫描
  • 事务越短越好:把非数据库操作(如 http 调用、json 解析)全移出事务块,只留真正需要原子性的语句
  • 注意隐式锁升级:mysql 8.0+ 中,当单个事务锁住超过 1000 行,默认可能触发锁升级警告(虽不强制,但会显著拖慢)

如何安全地做“先查后更新”而不丢数据

这是高并发扣减库存、抢优惠券最典型的幻读/超卖场景。UPDATE ... WHERE 原子判断比“查→判→更”三步走可靠得多,但前提是 where 条件能精确命中且包含业务约束。

  • 别依赖应用层判断余额:if (balance > amount) { UPDATE ... } 必然超卖
  • 用带校验的单条 UPDATE:例如 UPDATE accounts SET balance = balance - 100 WHERE id = 123 AND balance >= 100,然后检查 ROW_COUNT() 是否为 1
  • 如果业务逻辑复杂(比如要同时更新多个表),改用 INSERT ... ON DUPLICATE KEY UPDATEREPLACE INTO 配合唯一索引兜底
  • 注意:MySQL 的 READ COMMITTED 隔离级别下,SELECT ... FOR UPDATE 不会阻塞快照读,但 UPDATE 仍会加锁——别误以为换隔离级别就能绕过锁

innodb_lock_wait_timeout 设太小反而更糟

默认 50 秒看似很长,但线上服务常设成 3~5 秒来“快速失败”。问题在于:它只控制等待锁的时间,不控制锁本身持有时间。设太小会导致大量事务被杀,重试风暴反而推高整体锁冲突率。

  • 先看真实锁等待分布:SELECT * FROM performance_schema.data_lock_waits(MySQL 8.0+)或解析 SHOW ENGINE INNODB STATUS 中的 TRANSACTIONS
  • 调优方向是缩短锁持有时间,不是压缩等待时间。比如把一个 2 秒的事务拆成两个 300ms 事务,比把 timeout 从 50 改成 1 秒有效得多
  • 应用层需区分错误类型:捕获 Lock wait timeout exceeded 后,不能无脑重试,得结合业务判断是否可降级(如“库存暂不可用”而非“下单失败”)

连接池 + 事务边界不一致引发的隐形死锁

很多框架(spring Boot、laravel)默认开启事务代理,但开发者手动在 DAO 层又开了一次事务,或者在异步线程里复用主线程连接——此时连接池里的连接状态和事务实际状态脱节,FOR UPDATE 锁可能一直挂着不释放。

  • 确认事务是否真提交:SELECT trx_id, trx_state, trx_started FROM information_schema.INNODB_TRX,长期 trx_state = RUNNING 且无对应应用请求,大概率是连接没正确归还池
  • 禁用自动事务传播:Spring 中明确标注 @Transactional(propagation = Propagation.REQUIRED),避免 NESTEDREQUIRES_NEW 在高频路径滥用
  • 连接池配置必须匹配事务预期:HikariCP 的 connection-timeout 应大于最大事务耗时,leak-detection-threshold 开启后能抓到未关闭的事务

真正的瓶颈往往不在 SQL 写法,而在事务生命周期和连接状态的错配。只要有一个连接在事务中睡着了,整个资源池就可能被它拖垮。

text=ZqhQzanResources