TiDB乐观事务冲突排查与重试策略实战

📅 2026/6/28 7:23:05
TiDB乐观事务冲突排查与重试策略实战
问题背景在 TiDB 的乐观事务模型下高并发写入同一行或同一唯一索引时事务提交阶段的prewrite很容易因版本冲突而失败。业务日志里出现write conflict或try again later后如果重试策略不当要么导致大量无意义反复提交压垮集群要么直接丢请求引发数据不一致。最近我们处理了一个库存扣减服务单行热点更新时冲突率高达 40%TPS 从 5000 降到 800。以下是从定位到解决的完整实战记录。1. 乐观事务提交流程与冲突产生位置TiDB 的两阶段提交2PC简化如下Prewrite将事务涉及的 key 加锁写入Lock记录并检查当前 key 的版本是否与读取时一致。如果被其他事务锁住或版本过期则直接返回write conflict。Commit提交 Primary Key 的锁成功后异步清理 Secondary Key 的锁。冲突几乎全部发生在prewrite阶段TiKV 会返回WriteConflict错误。TiDB 内部自动重试几次但重试次数有限默认 10 次且依赖Backoff策略业务层不感知时通常只能看到最终失败。2. 定位冲突热点三把利刃2.1 Statement Summary 表直接查询information_schema.cluster_statements_summary按WRITE_CONFLICT_COUNT降序排列SELECT digest_text, exec_count, sum_err_count, sum_warn_count, STMT_TYPE, WRITE_CONFLICT_COUNT FROM information_schema.cluster_statements_summary WHERE digest_text NOT LIKE %information_schema% ORDER BY WRITE_CONFLICT_COUNT DESC LIMIT 10;当某条 SQL 的WRITE_CONFLICT_COUNT远高于其他语句时基本可以锁定热点 SQL。2.2 Slow Query 日志TiDB 的慢查询日志中Wait_Start后出现KV_WaitTime或LockKeysWait的语句往往是冲突高发者。也可以通过 SQL 过滤SELECT query_time, process_time, wait_time, backoff_time, lock_keys_stats, digest_text FROM information_schema.slow_query WHERE backoff_time 1 ORDER BY backoff_time DESC LIMIT 5;backoff_time越大说明冲突等待越严重。2.3 Grafana Metrics在 TiDB 面板tidb-summary-KV Backoff中关注Backoff Count/Backoff Duration若write conflict占比超过 30%必须干预。Retry Count每秒重试次数飙升代表事务冲突严重。3. 业务侧重试策略幂等 退避 错误分类3.1 错误分类与重试决策错误类型TiDB 错误码是否重试WriteConflict9007是幂等下LockConflict9001是DupKey1062否唯一键冲突需改造Timeout2013是需考虑上下文3.2 重试函数实现Go 示例import ( context errors math/rand time github.com/go-sql-driver/mysql ) type RetryableFunc func(ctx context.Context) error const ( maxRetries 5 baseDelay 50 * time.Millisecond maxDelay 2 * time.Second ) // isRetryableError 判断是否可重试 func isRetryableError(err error) bool { var mysqlErr *mysql.MySQLError if errors.As(err, mysqlErr) { switch mysqlErr.Number { case 9007, 9001, 2013: return true case 1062: return false // 唯一键冲突通常不可重试 } } return false } // RetryWithBackoff 带指数退避和抖动 func RetryWithBackoff(ctx context.Context, fn RetryableFunc) error { var err error for i : 0; i maxRetries; i { if err fn(ctx); err nil { return nil } if !isRetryableError(err) { return err } // 计算退避baseDelay * 2^i 随机0~50ms抖动 delay : baseDelay * (1 i) // 指数: 50ms, 100ms, 200ms, 400ms, 800ms jitter : time.Duration(rand.Int63n(int64(50 * time.Millisecond))) if delay maxDelay { delay maxDelay } time.Sleep(delay jitter) } return fmt.Errorf(max retries exceeded: %w, err) }3.3 幂等性设计重试时如果业务逻辑不是幂等的例如扣减库存时又加了一次会导致数据不一致。常见做法在业务表增加request_id唯一索引每个请求带唯一 ID。重试时带上同一个request_id数据库内用INSERT IGNORE或ON DUPLICATE KEY保证只执行一次。BEGIN; -- 幂等检查 INSERT IGNORE INTO order_idempotent(request_id, status) VALUES(xxx, processing); -- 实际业务例如扣库存 UPDATE stock SET count count - 1 WHERE sku_id 123 AND count 0; COMMIT;若INSERT IGNORE失败已存在则直接返回成功避免重复扣库存。4. 热点行与唯一索引冲突的改造方案4.1 热点行分桶单行更新比如热门商品库存是典型热点。将一行拆成 N 个桶每次随机选择一个桶更新-- 原表 CREATE TABLE stock ( sku_id bigint PRIMARY KEY, count bigint ); -- 分桶表 CREATE TABLE stock_bucket ( sku_id bigint, bucket_id int, count bigint, PRIMARY KEY (sku_id, bucket_id) ); -- 扣减时随机一个桶 BEGIN; UPDATE stock_bucket SET count count - 1 WHERE sku_id 123 AND bucket_id FLOOR(RAND() * 10) AND count 0; COMMIT;剩余总库存通过 SUM 实时计算或缓存维护。分桶后冲突率从 40% 降到 2% 以下。4.2 唯一索引冲突唯一索引冲突通常发生在用户抢单、幂等表等场景。常见错误做法是先SELECT再INSERT导致 TOCTOUTime of check to time of use问题。正确做法使用INSERT IGNOREROW_COUNT()判断是否成功。或使用INSERT ... ON DUPLICATE KEY UPDATE原子化合并。-- 错误写法先查后插高并发下两个事务都查不出记录然后插入 IF NOT EXISTS (SELECT 1 FROM user_order WHERE order_id xxx) THEN INSERT INTO user_order(order_id, user_id) VALUES(xxx, 1); END IF; -- 正确写法直接插入利用唯一约束冲突 INSERT IGNORE INTO user_order(order_id, user_id) VALUES(xxx, 1); IF ROW_COUNT() 1 THEN -- 插入成功 ELSE -- 唯一键冲突说明已存在 END IF;5. 生产环境参数调优与压测验证5.1 关键参数调整参数说明调优建议tidb_enable_async_commit异步提交减少 commit 阶段的阻塞高冲突场景建议开启默认关闭tidb_constraint_check_in_place在 prewrite 阶段立即检查唯一约束避免提交时才报 dup key建议开启减少二次重试tidb_txn_modeoptimistic/pessimistic热点行极高冲突如秒杀可切换悲观锁但会降低吞吐tikv.enable-1pc一阶段提交对单行单region事务有效开启后可减少一次 RTT降低冲突窗口示例配置全局或 session 级SET GLOBAL tidb_enable_async_commit 1; SET GLOBAL tidb_constraint_check_in_place 1; SET GLOBAL tidb_txn_mode optimistic; -- 默认乐观5.2 压测验证方法使用go-tpc或自编压测脚本重点模拟热点行写入# 使用 tpcc 压测关注写冲突 go-tpc tpcc -H 127.0.0.1 -P 4000 -D tpcc --warehouses 10 run -t 300 # 或使用 sysbench 的 update_index 脚本集中更新少数行 sysbench --db-drivermysql --mysql-host127.0.0.1 \ --mysql-dbtest --table_size100000 --threads100 \ --report-interval5 --time120 \ /usr/share/sysbench/oltp_write_only.lua run调优前后对比数据以分桶改造为例项目改造前改造后分10桶TPS12004800冲突率38%1.2%P95 延迟850ms120ms总结定位通过statement_summary和慢查询找到具体冲突 SQL不要凭感觉。重试业务层必须做指数退避重试配合幂等设计避免雪崩。改造热点行分桶是性价比最高的方案唯一索引冲突用原子 DML 而非查插分离。参数启用异步提交和约束就地检查秒杀等高冲突场景可切换悲观锁但要做好连接池保护。生产环境不要迷信乐观锁的“无锁”优势当冲突率超过 20% 时悲观锁或分桶改造往往是更稳妥的选择。