数据库存:技术负责人复盘框架:系统重构如何定位并发冲突
系统重构上线后,接口开始间歇性超时,数据库 CPU 却只有 45%;错误日志里出现“更新失败”,但没有明显的死锁告警;团队先后尝试加索引、提高锁等待时间、增加失败重试,问题仍然在高峰期反复出现。这个场景并不罕见,真正困难的地方也不在于知道什么是行锁,而在于回答:到底是哪两个事务发生了冲突,重构改变了什么,修复之后如何证明冲突真的消失。
我处理这类问题时,通常不会先从数据库命令或锁机制讲起,而是先画出一条证据链:线上现象对应哪些请求,请求进入了哪些事务,事务访问了哪些数据,资源是按什么顺序被持有和等待的,代码变更又改变了哪一个并发假设。只有这条链路闭合,技术负责人才能把“数据库有问题”还原成一个可以验证、可以修复、可以防止再次发生的工程问题。
在复盘会议上,开发者经常用“并发冲突”概括一组完全不同的问题:锁等待、死锁、乐观锁版本不匹配、唯一键冲突、更新覆盖、重复扣减、连接池耗尽,甚至把数据库响应变慢也归进来。
这些问题的共同点是多个执行单元同时访问共享资源,但它们的根因和修复方式并不相同。死锁需要还原循环等待关系;更新覆盖需要检查版本控制;唯一键冲突要确认是否存在重复创建;连接池耗尽则可能根本没有进入数据库执行阶段。
| 表象 | 更可能的冲突类型 | 第一证据 | 优先检查方向 |
|---|---|---|---|
| 请求长时间卡住后超时 | 锁等待、连接池排队、慢 SQL | 请求耗时分段、连接池等待时间、阻塞会话 | 区分等待发生在应用侧还是数据库侧 |
| 数据库主动回滚一个事务 | 死锁 | 数据库死锁日志和等待图 | 统一访问顺序、缩短事务、处理有限重试 |
| 更新成功但业务结果错误 | 更新覆盖、丢失更新 | 读取版本、写入版本、条件更新结果 | 乐观锁、版本号、状态机或条件更新 |
| 插入偶发失败 | 唯一键竞争、重复请求 | 唯一键值、请求幂等键、重试次数 | 幂等设计和冲突后的业务判定 |
| 库存、额度被多次扣减 | 重复执行、事务与消息边界错误 | 业务流水、请求链路、消息投递记录 | 幂等、状态流转和补偿机制 |
第一条原则是:先定义冲突,再选择技术动作。如果团队还没有说清楚问题属于哪一类,就直接提出“加索引”或“加重试”,通常说明排查仍然停留在猜测阶段。

系统重构很少只是把一段代码换成另一段代码。它往往同时改变事务边界、数据访问顺序、批量规模、缓存策略、消息发送时机和失败重试方式。
例如,旧代码先更新订单,再更新库存;新代码为了复用一个领域服务,变成先锁库存,再写订单。如果另一条链路仍然保持旧顺序,两个事务就可能分别持有一部分资源,并等待对方释放另一部分资源。数据库只是把这两个业务路径的顺序矛盾暴露了出来。
所以我在复盘中会反复追问一句话:重构前,团队默认哪些操作会按什么顺序发生;重构后,这个默认是否还成立。这比单独讨论“数据库采用什么隔离级别”更接近根因。
一条完整的根因链至少应包含五个部分:触发条件、直接原因、放大因素、未被提前发现的原因,以及流程改进。
比如,高峰期某个热点账户被大量请求同时更新,这是触发条件;新事务把一次远程风控调用放进了数据库事务,这是直接原因;失败后无退避地重试,是放大因素;压测只使用随机账户,没有模拟热点键,是未发现原因;发布流程没有检查事务内是否存在远程调用,则是流程缺口。
如果复盘最终只留下“调整超时时间”和“开发人员注意代码质量”,下一次问题大概率还会出现。真正有效的复盘,必须让团队知道什么信号出现时应该阻断发布,什么数据出现时应该立即降级,以及什么设计模式不应再次进入核心交易链路。
很多团队能注意到一条 SQL 从 8 毫秒变成 80 毫秒,却没有注意事务从 20 毫秒变成 300 毫秒。对并发冲突而言,后者往往更危险,因为锁的持有时间决定了其他请求进入等待的概率。
旧流程可能是:读取数据、计算结果、快速更新、提交。重构后则可能变成:开启事务、读取数据、调用远程服务、写入审计记录、发送事件、更新主表、最后提交。即使每条 SQL 都没有明显变慢,事务持续时间也已经被非数据库操作拉长。
我在审查重构代码时,会把事务内部操作按三类标记:必须在事务内完成的数据库读写、可以提前完成的计算、绝不能放在事务内的远程调用和不可控 IO。只要第三类操作出现在锁已经持有之后,就会被列为高风险项。
系统迁移通常不是一次性切换。为了降低风险,新旧服务会并行运行一段时间,流量可能按用户、租户、区域或请求比例分配。此时最容易出现的不是传统意义上的死锁,而是同一个业务对象被两套逻辑同时写入。
例如,旧链路根据订单状态直接更新库存,新链路则先写一条库存预占记录,再异步扣减可用库存。如果灰度路由没有做到业务对象级别的一致,某个订单可能先经过新链路,再被旧链路重复处理。数据库日志里看到的只是两次合法更新,业务上却已经发生了重复扣减。
并行迁移期间,必须把“谁拥有写入主责”写清楚。单纯把两套代码都部署上去,再依赖数据库最终一致,通常不足以保护库存、余额、额度这类强约束数据。
平均每秒 500 个请求,并不一定比每秒 100 个请求更危险。真正关键的是请求是否集中访问少数热点键。
假设 100 个请求平均分布在 10000 个账户上,每个账户竞争很低;如果 100 个请求全部更新同一个活动库存、同一个租户配额或同一个热门商品,数据库面对的实际上是一个高密度的串行化队列。
因此,压测不能只设置一个总并发数,还要设置热点比例。至少应分别测试随机分布、10% 请求集中到热点键、50% 请求集中到热点键和单热点键四种场景。

以九数云这类数据分析与经营决策场景为例,用户可能同时刷新仪表板、导入业务数据、执行口径计算和生成报表。这里的冲突不一定表现为传统交易系统中的库存扣减,但仍可能出现在任务状态更新、数据集版本切换、同步批次登记和缓存结果覆盖等位置。
例如,同一个数据集正在导入新批次时,用户发起了刷新报表请求。新链路可能读取到中间状态,旧链路则继续更新任务进度;如果数据集版本、任务状态和结果缓存没有统一的版本控制,就会出现“导入已完成但报表仍显示旧结果”或者“旧任务覆盖新任务状态”的问题。
这类问题提醒我:并发冲突不只属于订单和支付系统。只要系统存在共享状态、异步任务和可变版本,就必须设计并发控制。
一个请求耗时 10 秒,不代表它在数据库里等待了 10 秒。它可能在连接池排队,也可能在应用线程池中等待,或者卡在远程服务、网络重试、垃圾回收和序列化环节。
排查时应把请求总耗时拆成连接获取、事务排队、SQL 执行、锁等待、业务计算和提交后的异步动作。没有分段耗时,就无法判断哪一段真正占用了时间。
我通常要求链路日志至少记录四个时间点:获取连接、开始事务、执行关键 SQL、提交事务。对于高风险接口,再记录每条 SQL 的开始和结束时间。这样才能知道“数据库慢”究竟是 SQL 慢,还是 SQL 在等待锁。
锁等待是资源被占用后暂时无法继续,等待者可能在对方提交后恢复。死锁则是多个事务互相等待,形成无法自行解除的循环。数据库一般会主动选择一个事务回滚,以打破循环。
二者在应用侧都可能表现为超时或更新失败,但处理方式不同。锁等待优先需要减少持锁时间、优化访问路径和控制热点;死锁优先需要还原资源访问顺序,并确保所有事务按照统一顺序获取资源。
如果团队没有拿到数据库侧的等待关系,只凭一条“lock timeout”日志就宣布发生死锁,后续修复很可能偏离方向。
重试对短暂性冲突有价值,但它不是免费的。每一次重试都会重新占用线程、连接和数据库资源。如果大量请求在同一时间无退避地重试,原本的局部冲突可能被放大为全局拥塞。
重试至少要满足三个条件:错误类型确实可重试,业务操作具备幂等性,重试间隔采用带随机性的退避。涉及扣款、扣库存、发送通知的操作,还要确认“数据库事务已提交但客户端未收到响应”时,重试不会导致重复执行。
索引可以缩小扫描范围、减少访问行数,并可能缩短锁持有时间,但索引不是并发问题的万能答案。如果两个事务访问资源的顺序相反,索引并不能消除循环等待;如果所有请求都更新同一行,增加索引也不会让这行同时被多个事务写入。
加索引前,我会先看执行计划、实际扫描行数、返回行数、过滤条件的选择性,以及索引是否改变了锁范围。索引优化的目标不是让某条 SQL 看起来更快,而是让事务更早完成并释放不必要的资源。
调大超时时间有时可以减少短暂失败,但它本质上只是允许请求等待更久。若冲突来自事务设计,等待时间越长,线程池和连接池越容易被占满,最终会把数据库问题扩散成应用不可用。
只有当团队已经确认等待是短暂的、可预测的,并且上游具备足够容量时,才有理由谨慎调整等待参数。参数调整必须绑定观察指标,不能成为替代代码修复的长期方案。
回滚是正确的止血手段,但不是完整复盘。回滚后服务恢复,只能证明新版本与问题时间窗口相关,不能证明具体哪项改动导致了冲突。
如果没有继续做差异分析,下一次重构仍可能重复引入同类问题。技术负责人需要把回滚后的时间窗口当成实验对照,比较新旧版本的事务时长、SQL 顺序、热点访问比例和重试次数。

时间线不是简单罗列“几点发布、几点报警”,而是要把系统行为变化放在同一张图上。建议至少记录发布、配置变更、流量变化、数据库指标、错误率、回滚和恢复这几类事件。
如果数据库锁等待在发布后 3 分钟内上升,而流量和数据分布没有明显变化,重构关联性较强;如果锁等待在发布前已经持续上升,则应优先调查流量、数据增长或数据库资源变化。
时间线还要标记临时措施。例如,团队在 14:20 打开重试开关,错误率可能短暂下降,但数据库连接数和事务数量上升。若不把这个动作放入时间线,复盘时就会误以为系统自然恢复。
将接口耗时拆成几个阶段后,很多“数据库慢”的判断会被重新修正。一个典型请求可以分为:等待线程、获取连接、执行查询、锁等待、业务计算、提交事务和异步投递。
| 耗时阶段 | 典型信号 | 可能根因 | 需要关联的指标 |
|---|---|---|---|
| 等待线程 | 线程池队列变长 | 上游突发流量、重试放大 | 活跃线程、队列长度、拒绝数 |
| 获取连接 | 连接池等待升高 | 事务过长、连接泄漏、池容量不足 | 活跃连接、空闲连接、等待时间 |
| 执行 SQL | 扫描行数或执行时间升高 | 索引失效、数据倾斜、计划变化 | 执行计划、扫描行数、慢查询 |
| 锁等待 | SQL 本身未执行但持续等待 | 长事务、热点行、访问顺序冲突 | 阻塞者、被阻塞者、锁对象 |
| 提交事务 | 提交阶段延迟 | 日志刷盘、资源争用、事务过大 | 提交耗时、IO、日志量 |
如果没有耗时分段,任何“数据库问题”的结论都应该标注为待验证。技术负责人不需要一开始就知道答案,但必须推动团队补齐能区分假设的证据。

判断锁等待时,要回答谁阻塞了谁。至少需要知道事务 A 持有哪些资源、事务 A 等待什么资源、事务 B 是否持有该资源,以及等待持续了多长时间。
判断死锁时,还要确认是否形成闭环。例如,事务 A 持有订单行并等待库存行,事务 B 持有库存行并等待订单行。如果只有 A 等待 B,而 B 最终可以提交,就不能称为死锁。
判断逻辑冲突时,则要关注数据库是否报错。两个请求先后读取同一个版本,后一个请求覆盖前一个请求,数据库可能完全认为这是两次合法更新。此时需要比较读取版本和更新条件,而不是只看数据库错误日志。
我建议把关键接口画成两张事务图,而不是只做代码 diff。事务图至少标注事务开始点、每个 SQL、锁定对象、远程调用、消息发送和提交点。
重构前的图可能是“读取订单,更新订单,提交”;重构后的图可能是“读取订单,锁定账户,调用风控,写审计,更新订单,发送消息,提交”。代码变化看起来只是复用了一个服务,事务行为却已经完全不同。
事务图还有一个好处:它能让产品、测试、运维和研发管理者看到同一件事。并发冲突并不只是数据库专家的局部问题,而是业务流程、系统边界和数据一致性共同作用的结果。
最小复现不追求还原整套生产系统,而是只保留能触发冲突的两条并发路径。比如,只保留订单表和库存表,使用两个事务模拟相反的访问顺序。
-- 事务 A:先锁订单,再锁库存 BEGIN; SELECT id, status FROM orders WHERE id = 1001 FOR UPDATE; UPDATE inventory SET available = available - 1 WHERE sku_id = 2001 AND available > 0; COMMIT; -- 事务 B:先锁库存,再锁订单 BEGIN; SELECT sku_id, available FROM inventory WHERE sku_id = 2001 FOR UPDATE; UPDATE orders SET status = 'PAID' WHERE id = 1001; COMMIT;
这段示例不代表所有数据库的具体锁行为,实际测试必须结合数据库类型、版本、索引和隔离级别验证。但它表达了一个通用问题:如果两条事务路径以相反顺序获取共享资源,死锁风险会显著上升。
最小复现成功后,再逐项加入远程调用、批量处理、重试和真实数据分布。这样可以判断究竟是哪一个因素触发了问题,而不是把所有变量一次性放进压测环境。
下面这个案例是根据常见的数据分析系统重构场景整理的脱敏示例,数据为情景模拟,不对应某一家公司的生产事故。系统提供数据集导入、指标计算和仪表板刷新功能,用户可以在导入业务数据后查看经营报表。
重构目标是把同步计算改成异步任务:用户提交导入请求后,系统写入任务记录,由后台工作者执行清洗、转换和指标计算。为了兼容旧页面,旧的同步查询接口在一段时间内继续保留。
上线前,导入任务平均耗时约 1.8 秒,报表刷新 P95 为 1.2 秒;上线后,导入任务平均耗时下降到 0.7 秒,但高峰期报表刷新 P99 从 3.4 秒上升到 18.6 秒,偶发出现任务状态回退。
表面上看,异步化已经让导入更快;但从并发行为看,系统同时存在三条写入路径:导入服务更新数据集版本,任务服务更新任务状态,兼容接口刷新结果缓存。
团队最初查看数据库 CPU 和磁盘 IO,没有发现明显峰值,于是判断数据库资源充足。但进一步拆分请求耗时后发现,报表刷新请求有相当一部分时间花在获取数据库连接上。
在模拟高峰的 15 分钟窗口内,连接池最大连接数为 80,活跃连接峰值达到 78,连接获取 P99 从 6 毫秒升至 1.4 秒。数据库端真正执行 SQL 的时间只增加了约 20%,事务等待却明显拉长。
| 观察项 | 重构前 | 重构后高峰期 | 判断 |
|---|---|---|---|
| 导入任务平均耗时 | 1.8秒 | 0.7秒 | 异步化降低了单次导入处理时间 |
| 报表刷新 P95 | 1.2秒 | 6.8秒 | 刷新链路出现排队 |
| 报表刷新 P99 | 3.4秒 | 18.6秒 | 极端热点请求被显著放大 |
| 连接获取 P99 | 6ms | 1.4秒 | 连接被长事务占用 |
| 数据库 CPU | 38% | 45% | 资源并非主要瓶颈 |
| 事务持续时间 P99 | 110ms | 2.7秒 | 事务边界扩大是重点嫌疑 |
这个观察改变了排查方向:问题不是“数据库算不过来”,而是少数事务占用连接和锁的时间过长,导致后续请求在应用侧排队。

继续关联数据集版本号后,发现任务服务在导入完成后会生成版本 42 的计算结果,而兼容接口仍可能在稍早的时间读取版本 41,并把旧结果写回缓存。由于缓存键只有数据集 ID,没有携带版本号,后完成的旧请求会覆盖先完成的新结果。
这个问题没有产生数据库死锁,也没有触发唯一键冲突。数据库看到的只是两次普通的缓存记录更新。真正的冲突发生在业务版本语义上:系统没有规定“只有更新版本才能覆盖旧版本”。
修复方式不是加锁,而是让缓存写入带上版本条件。例如,写入时要求新版本号大于当前版本;如果条件不满足,则放弃这次写入,并重新读取最新结果。
UPDATE report_cache SET version_no = :new_version, result_body = :result, updated_at = CURRENT_TIMESTAMP WHERE dataset_id = :dataset_id AND version_no < :new_version;
这里仍然需要结合数据库实际版本验证条件更新的执行计划和并发行为,但设计要点很明确:把业务上的“新结果不能被旧结果覆盖”转化为数据库可以判断的条件。
任务服务原本只更新状态字段,例如把任务从 RUNNING 更新为 SUCCESS。重构后,超时补偿线程和正常完成线程都可能更新同一条任务记录。补偿线程在较早时刻读取到 RUNNING,正常线程已经把任务更新为 SUCCESS,但补偿线程稍后仍然把状态改为 FAILED。
这个故障说明,单纯依赖“最后一次写入生效”并不符合任务状态的业务规则。任务状态是一个有方向的状态机,SUCCESS 不能被普通补偿逻辑改回 FAILED。
可行方案包括状态版本、条件更新和显式状态机。对于关键任务,我更倾向于把允许的状态迁移写入更新条件中,而不是让每个调用方自由更新 status 字段。
UPDATE import_task
SET status = :target_status,
version_no = version_no + 1,
updated_at = CURRENT_TIMESTAMP
WHERE task_id = :task_id
AND version_no = :expected_version
AND status IN ('PENDING', 'RUNNING');更新影响行数为 0 时,调用方不能简单当作失败重试,而应读取当前状态,判断任务是否已经成功、已经被其他线程处理,或者已经进入不可逆状态。
这个案例最终拆出了三类问题:长事务造成连接池和锁等待,版本缺失造成旧缓存覆盖,状态机约束缺失造成任务状态回退。它们同时出现在一次重构中,所以最初被团队统称为“异步化后的并发冲突”。
| 问题 | 根因 | 错误修复 | 正确方向 |
|---|---|---|---|
| 报表刷新长尾 | 事务内包含非必要处理,连接长期占用 | 只提高连接池上限 | 缩小事务、移除事务内远程和计算操作 |
| 旧结果覆盖新结果 | 缓存键缺少版本约束 | 给写入增加普通互斥锁 | 版本化缓存并使用条件更新 |
| 任务状态回退 | 状态更新没有版本和迁移约束 | 增加失败重试次数 | 状态机、版本校验和幂等处理 |
这也是我认为最值得复用的判断:不要为了“并发”这个大词寻找一个大而全的解决方案,要把共享资源和业务不变量逐一拆开。
第一目标是降低影响,而不是立即完成永久修复。可以先暂停非核心批处理、降低高风险接口并发、关闭无必要的同步刷新、停止无退避重试,并保留必要的数据库现场。
如果直接重启数据库或强行终止大量会话,现场证据可能丢失。除非系统已经进入不可恢复状态,否则应先记录阻塞者、被阻塞者、事务开始时间和业务对象,再执行止血动作。
死锁本身不一定意味着数据库设计不可用。很多高并发系统会通过统一访问顺序和有限重试来处理少量偶发死锁。关键是确认死锁频率、涉及业务、是否集中在发布后,以及回滚事务是否可安全重放。
如果死锁每小时只发生一次,且重试成功率高、数据具备幂等保证,可以先通过统一锁顺序和带退避的有限重试降低影响。若死锁每分钟发生数百次,则不能只依赖重试,应直接处理事务边界、热点模型或数据访问顺序。
这类问题的优先级通常高于普通性能抖动,因为它会造成业务数据错误。第一步不是继续调数据库参数,而是确定哪些字段允许被覆盖,哪些字段必须基于版本更新。
对于订单、任务、额度和审批状态等对象,建议明确状态迁移规则。对于可编辑配置和报表结果,建议记录版本号、更新时间或变更序列,并在写入时进行条件校验。
唯一键冲突有时是正常的并发控制结果。例如多个请求同时创建同一个幂等记录,只有一个成功,其他请求读取成功记录即可。问题在于应用是否把这种冲突识别成“已处理”,还是把它当作系统异常反复重试。
我更看重数据库约束和应用语义是否一致。数据库唯一键负责最终兜底,应用层必须知道冲突发生后应该返回已有结果、继续查询状态,还是提示用户重新操作。
连接池耗尽不一定是数据库锁问题。也可能是代码忘记释放连接、事务中调用远程服务、某条慢查询占用连接过久,或者连接池上限远低于实际并发需求。
此时要优先检查连接生命周期和事务范围。不要只把连接池上限从 50 调到 200,因为更大的连接数可能把应用侧排队转移成数据库上下文切换和 IO 争用。

悲观锁适合冲突代价高、必须在当前事务内确认资源占用的场景,例如有限库存、账户余额和关键额度。它的优点是语义直接,缺点是会延长等待,并可能产生死锁和热点排队。
乐观锁适合冲突概率较低、业务可以接受失败后重新读取的场景,例如配置编辑、报表结果、任务状态和后台管理操作。它不会让请求长时间持有锁,但冲突发生时需要设计清晰的失败处理。
| 控制方式 | 优势 | 代价 | 适合场景 |
|---|---|---|---|
| 悲观锁 | 事务内直接保证排他访问 | 等待、死锁、热点串行化 | 库存、余额、强约束额度 |
| 乐观锁 | 减少长时间持锁 | 冲突后需要重读、合并或提示 | 配置、任务状态、低冲突编辑 |
| 条件更新 | 把业务规则放入一次原子写入 | 更新失败后的语义需要定义 | 状态迁移、版本覆盖、额度校验 |
| 串行队列 | 把热点竞争转化为可控顺序 | 增加延迟和系统组件 | 单账户、单商品、单租户热点 |
异步化可以缩短用户请求等待时间,也能把复杂计算从主链路移出,但它不会自动解决一致性问题。异步任务引入后,系统会多出任务状态、重复消费、执行顺序、失败补偿和结果版本等新的并发变量。
如果业务要求用户提交后立即知道最终结果,同步事务可能更简单;如果计算耗时长、结果允许延迟,异步化更合适。但异步化前必须先定义任务幂等键、状态机、重试策略和结果版本。
增大连接池可以提高短时吞吐,但前提是数据库仍能稳定处理新增并发。如果瓶颈是热点行或长事务,扩大连接池只会让更多请求同时进入等待队列。
限制并发看起来会降低吞吐,却可能保护系统的尾延迟和稳定性。对单一热点资源,排队往往比让数百个请求同时争抢数据库更可控。技术负责人要比较的不是平均吞吐,而是 P99 延迟、失败率、恢复时间和数据正确性。

隔离级别不是越高越好。更高隔离级别可以减少某些读写异常,但也可能扩大锁范围、增加冲突概率和资源消耗。更低隔离级别可能提高并发,却要求业务接受更多读取不一致。
选择隔离级别时,先列出业务真正需要保护的不变量。例如,报表查询通常不需要和库存扣减使用相同的隔离要求;账户余额变更则不能因为追求吞吐而放弃原子性。
当冲突集中在一个热点对象时,分库分表未必有效,因为请求最终仍可能集中到同一条记录。真正需要处理的是数据模型和更新模型,例如把一个总库存拆成多个库存桶,或者把单账户高频变更转化为流水累加后异步归并。
这种方案会增加查询复杂度、对账成本和故障恢复难度。只有当热点竞争已经成为长期结构性问题,并且业务能够接受最终一致或延迟聚合时,才值得引入。
复盘文档应把事实和推断分开。事实包括发布时间、错误数量、锁等待时长、事务数量和回滚时间;判断包括“重构扩大了事务边界”“重试放大了连接竞争”等。
每一个判断都应有证据引用。如果证据不足,就标记为“待验证假设”,并写明验证动作。这样可以避免会议变成观点对抗。
| 复盘字段 | 不合格写法 | 合格写法 |
|---|---|---|
| 直接原因 | 数据库锁住了 | 新版本在持有订单行锁后调用远程服务,事务 P99 从 110ms 升至 2.7s |
| 影响范围 | 部分用户受影响 | 高峰期报表刷新 P99 达 18.6s,约 7.4% 请求超过 5s |
| 放大因素 | 流量太大 | 失败请求无退避重试,单请求平均重试 2.3 次 |
| 修复验证 | 上线后正常 | 灰度 30 分钟内死锁为 0,连接获取 P99 降至 12ms,数据版本无回退 |
如果会议无法回答其中三四个问题,不代表团队能力不足,而是说明观测系统还没有提供足够证据。此时比争论方案更重要的工作,是补齐请求级和事务级可观测性。
技术负责人需要区分“操作责任”和“系统责任”。某个开发者可能确实提交了一个过大的事务,但如果代码评审没有事务边界检查、压测没有热点分布、监控没有事务持续时间,问题就不应只归因于个人疏忽。
更有价值的复盘结论是:核心接口必须禁止事务内远程调用;批量更新必须有最大批次门槛;状态更新必须包含版本或合法状态条件;高并发发布必须进行热点比例压测。
复盘结束后,行动项不要只写“加强测试”。行动项应具体到负责人、完成时间、验证方式和阻断条件。

“上线后一切正常”不是成功标准。不同并发问题需要不同的验证指标。锁等待应关注等待时长和阻塞次数;死锁应关注发生频率和重试成功率;更新覆盖应关注版本冲突和数据校验;连接池问题应关注获取连接 P99 和活跃连接占比。
| 问题类型 | 核心指标 | 建议观察方式 |
|---|---|---|
| 锁等待 | 等待 P95、P99、阻塞会话数 | 按接口、表、业务对象分组 |
| 死锁 | 每分钟死锁数、涉及事务类型 | 关联发布版本和业务峰值 |
| 更新覆盖 | 版本冲突率、条件更新失败率 | 校验最终状态和变更流水 |
| 连接池拥塞 | 获取连接 P99、活跃连接峰值 | 与事务持续时间做时间对齐 |
| 重复执行 | 幂等命中率、重复流水数 | 按请求 ID、幂等键和业务流水核对 |
并发压测至少要覆盖四个维度:并发量、热点比例、事务持续时间和失败重试策略。只测试“1000 个随机请求”意义有限,因为生产事故可能发生在 50 个请求同时更新同一个对象的场景。
建议建立一个压力矩阵:随机数据、低热点、中热点、单热点;短事务、带计算事务、带远程调用事务;无重试、固定间隔重试、指数退避重试。每次只改变一个主变量,才能知道结果变化来自哪里。

并发修复不能只看 HTTP 200 比例。更新覆盖、重复扣减和状态回退都可能发生在接口成功返回之后。
针对库存类业务,应核对库存快照、扣减流水和订单状态;针对任务系统,应核对任务状态迁移是否符合状态机;针对分析系统,应核对数据集版本、计算结果版本和缓存版本是否一致。
我通常会让压测脚本在每轮结束后执行对账,而不是只记录响应码。对账可以是全量,也可以是按抽样业务对象核对。没有数据正确性验证,性能指标越好看,风险可能越大。
并发问题常常只在流量峰值或特定数据分布下出现,所以灰度不能只观察五分钟的平均延迟。至少要覆盖一个业务高峰,或者主动回放历史高峰流量和热点分布。
同时要测试故障恢复:杀掉一个任务消费者、让远程服务延迟升高、模拟数据库短暂锁等待、重复投递同一个消息,观察系统是否会出现重试风暴、状态回退或重复写入。

先用一页说明事件,不要让读者在几十页日志中寻找结论。
证据采集的目标是把应用、数据库和发布系统连接起来。建议所有关键记录都带上时间戳,并尽可能使用统一时区。
日志不应无差别记录所有敏感参数。用户身份、金额、联系方式等内容应脱敏或使用哈希摘要,既保证排查能力,也避免为了诊断并发问题扩大数据泄露风险。
| 字段 | 写作要求 |
|---|---|
| 触发条件 | 说明什么流量、数据分布或操作组合触发问题 |
| 直接原因 | 落到事务、SQL、状态、版本或资源行为 |
| 放大因素 | 说明重试、热点、批量、连接池等如何扩大影响 |
| 未发现原因 | 说明测试、监控、评审或灰度为何没有提前发现 |
| 修复动作 | 说明临时措施、永久方案和不采用方案的理由 |
| 验证结果 | 给出指标、时间窗口、压力条件和数据校验结论 |
如果执行计划发生变化、扫描范围明显扩大、过滤条件无法使用索引,或者一条查询在事务内耗时占比最高,应优先处理 SQL 和索引。
但 SQL 优化要以实际执行计划为依据。不要因为表中有几十万行就认为一定需要新索引,也不要因为新增一个索引后单次查询变快,就忽略写入成本、索引维护和锁范围变化。
如果事务内包含远程调用、文件操作、复杂计算、大批量处理或非必要查询,应优先缩短事务。很多系统的并发问题不是数据库能力不足,而是把太多工作放进了数据库资源保护窗口。
可以先在事务外完成可重复计算,把最终需要保护的写入压缩到一个短事务内;对于必须异步的副作用,则通过可靠消息、任务表或补偿机制处理,而不是在持锁期间等待外部服务。
如果死锁日志显示不同事务以相反顺序获取同一组资源,应统一访问顺序。顺序必须写入设计约定,并在代码评审和测试中验证,不能只靠开发者记忆。
批量场景尤其需要注意。一个事务按主键升序更新,另一个事务按输入顺序更新,即使它们调用的是同一条 SQL,也可能因为锁定顺序不同产生冲突。批量数据应在进入事务前排序,或者拆成一致的处理顺序。
如果冲突长期集中在同一个热点对象,且业务请求天然无法避免同时更新,那么继续优化 SQL 的收益会越来越小。此时应考虑分桶、排队、流水累加、异步归并或按对象串行化。
业务模型改造的代价通常较高,可能引入最终一致、对账和补偿,但它能从根上改变资源竞争方式。技术负责人要把改造成本与事故成本、峰值增长预期和数据错误风险放在一起评估。
并发冲突并不一定要被消灭到零。对一些低频、可恢复、具备幂等的死锁,有限重试可能是合理方案;对一些可接受延迟的热点写入,排队也许比强行并发更稳定。
真正需要消灭的是不可解释的冲突、不可恢复的数据错误、无边界的重试,以及无法通过监控提前发现的风险。稳定性不是让所有请求同时成功,而是在资源有限、依赖异常和流量突发时仍然保持可预测。

不要等下一次故障才开始设计观测。今天就可以选一个最重要的写接口,检查是否能够拿到请求 ID、事务耗时、SQL 耗时、连接等待和业务对象 ID。
如果其中任何一项无法关联,先补最小可用的日志和指标。观测不需要一开始就覆盖所有接口,但必须覆盖库存、余额、任务状态、版本切换和高频批量更新等高风险路径。
使用真实业务分布或脱敏后的历史分布,建立随机键、低热点、中热点和单热点四组压测。记录平均延迟之外的 P95、P99、锁等待、连接等待、重试次数和数据对账结果。
如果系统在随机数据下表现良好,但在单热点下严重排队,这不是压测失败,而是识别出了系统的边界。技术负责人要决定这个边界是否符合业务峰值,是否需要排队、分桶或模型改造。
第一张是重构前的事务图,第二张是重构后的事务图。两张图都标出事务范围、共享资源、访问顺序、远程调用、消息发送和提交位置。
如果两张图之间出现以下变化,就应增加并发专项评审:事务变长、访问顺序变化、共享资源增加、写入路径增加、状态字段被多个服务更新、失败重试被引入、异步任务与同步接口同时写入。
复盘的终点不是文档归档,而是让下一次发布自动暴露相同风险。可以在流水线、代码评审和灰度平台中分别设置检查项:代码层检查事务内远程调用,测试层检查热点并发,运行层检查长事务和死锁,业务层检查状态和版本一致性。
当团队能够在发布前回答“共享资源是什么、冲突如何处理、失败如何重试、数据如何校验、异常如何回滚”,并发问题就不再完全依赖某位数据库专家临场救火。
我建议把下面这句话作为技术负责人复盘系统重构时的固定顺序:
先确认现象,再分类冲突;先还原事务,再检查锁关系;先证明重构改变了什么,再决定改 SQL、改事务还是改业务模型;最后用压力、灰度和数据对账证明修复有效。
系统重构中的并发冲突,表面上是多个请求同时访问数据库,深层上却是旧有并发假设被改变后,系统没有及时暴露边界。真正高质量的复盘,不是找到一个参数或一条 SQL,而是把共享资源、事务边界、业务不变量和发布流程重新连接起来。
下一步可以从一个核心接口开始,建立事务图、补齐耗时分段、模拟热点数据,再用复盘模板记录证据和决策。只要团队持续这样做,下一次遇到锁等待、死锁、状态回退或重复写入时,就能更快判断问题属于哪里、修复应该落在哪里,以及哪些方案虽然看似有效却不值得采用。
我负责过一次订单写链路重构,上线后接口 P99 从 180ms 升到 1.6s,但数据库 CPU 只有 42%。团队一开始认为是连接数和慢 SQL 问题,我想知道,技术负责人应该怎样用证据区分锁等待、连接池耗尽和单纯的数据库变慢?
我处理这类问题时,第一步不会看“数据库 CPU 是否打满”,而是把一次请求拆成四段:进入线程池、等待数据库连接、执行 SQL、等待事务提交。很多锁冲突发生时,CPU 反而不高,因为数据库线程主要在等待资源,而不是计算。
我通常先做一张时间对比表,把重构前后的请求耗时拆开,而不是只看接口总耗时: 指标重构前重构后判断 线程池排队3ms8ms不是主要原因 连接池等待5ms210ms可能是事务变长后的连锁反应 SQL 执行时间18ms25ms不足以解释整体延迟 事务提交前等待2ms880ms重点检查锁等待 真正有区分度的证据是“阻塞关系”。
如果能看到事务 A 持有某条记录的锁,事务 B 在等待这条记录,并且 B 的等待时间与接口延迟同步上升,才能把问题指向锁冲突。反过来,如果所有请求都在获取连接前排队,而数据库没有明显阻塞关系,更可能是连接池配置、连接泄漏或长事务造成的资源耗尽。我还会对比重构前后的事务边界。
一次重构中,原本 20ms 内完成的数据库事务被改成了“更新订单,调用库存服务,写操作日志,提交”,远程调用平均耗时 120ms,导致锁持有时间被放大。这个案例里,数据库并没有先变慢,真正变化的是应用把非数据库操作放进了事务。因此,技术负责人不应根据 CPU、磁盘或单条慢 SQL 直接下结论。
更可靠的判断顺序是:先拆解请求耗时,再确认阻塞关系,最后对比重构前后的事务边界和 SQL 顺序。
我在排查并发异常时,发现日志里都出现了“更新失败”或“请求超时”,但数据库有时记录死锁,有时只是普通等待,还有一次没有任何数据库报错,结果却被后一个请求覆盖了。我想建立一套不依赖经验猜测的分类方法。
这三类问题最容易被混在一起,但它们的证据和修复方式完全不同。我的判断原则是:先看数据库是否主动回滚事务,再看是否存在循环等待,最后检查业务数据是否在无报错的情况下发生了丢失更新。
类型典型现象关键证据优先处理方向 锁等待延迟升高,最终超时存在阻塞者,但未形成环路缩短事务、减少锁持有时间 死锁部分事务被回滚数据库输出循环等待关系统一锁顺序,谨慎重试 更新覆盖请求成功但数据错误两个请求读取同一旧版本版本校验或条件更新 唯一键冲突插入失败唯一索引冲突日志幂等键和状态判断 死锁的核心不是“等待很久”,而是形成了环路。
例如事务 A 先更新订单,再更新账户;事务 B 先更新账户,再更新订单。A 等 B 释放账户,B 又等 A 释放订单,这才是死锁。普通锁等待只有单向阻塞,增加索引或缩短事务有时就能改善,但死锁必须优先统一资源访问顺序。更新覆盖更隐蔽,因为数据库可能认为两个 UPDATE 都合法。
比如两个请求都读到库存为 10,一个扣减 2,一个扣减 3,若代码采用“读取后计算,再直接写回”,最终可能写成 7 或 8,而不是正确的 5。此时查看死锁日志没有意义,应该检查 UPDATE 是否带有版本号或原值条件。我会在复盘中要求每条失败都带上异常类型、事务标识、业务对象标识和重试次数。
只记录“更新失败”不够,必须回答它是数据库拒绝了操作,还是应用成功执行了一个逻辑上错误的操作。一个实用的分类口诀是:有循环等待,优先按死锁处理;有单向阻塞,优先查事务和索引;无数据库报错但数据不对,优先查并发控制和幂等。
我见过团队一遇到死锁就加重试,一遇到超时就调大等待时间,一遇到慢查询就加索引。短期指标可能好看一些,但过几天热点请求反而把数据库压得更厉害,我想知道技术负责人应该依据什么选择修复手段?
我不会把“重试”当成默认修复方案,因为重试解决的是偶发失败,不是冲突产生的根因。选择动作前,我会先判断冲突是由扫描范围过大、事务持有时间过长、访问顺序不一致,还是同一业务对象天然成为热点。
观测到的根因更合适的动作不建议直接做的事 执行计划发生全表或大范围扫描修正索引、SQL 条件和数据访问方式只调大锁等待时间 事务中包含远程调用或复杂计算移出事务,缩小提交范围继续扩大事务超时时间 不同代码路径锁顺序不同统一资源访问顺序无限增加重试次数 同一账户、库存或任务被高频争抢排队、分片、异步化或改热点模型单纯增加数据库连接数 请求重复提交幂等键、状态机和去重机制失败后立即无间隔重放 我曾经测试过一个“失败后立即重试两次”的方案:单次请求的失败率从 2.1% 降到 0.7%,看起来有效;
但数据库锁等待总量增加了约 2.4 倍,P99 从 900ms 升到 1.8s。原因是同一批热点请求在资源未释放时再次进入,重试把竞争队列拉得更长。如果确认是偶发死锁,有限次数的重试可以作为保险,但必须同时满足三个条件:事务具备幂等性,重试使用指数退避并加入随机抖动,重试次数受到严格限制。
对于扣款、库存和状态流转,重试前还要确认上一次事务是否已经提交,不能只根据客户端收到的异常判断结果。索引也需要谨慎。索引能缩小扫描范围、降低执行时间,却不能修复锁顺序不一致,更不能解决两个请求持续争抢同一行的问题。
我的决策顺序通常是先修事务边界和访问顺序,再看执行计划,最后才决定是否增加重试作为容错层。技术负责人真正要评估的不是“哪个动作最快”,而是“这个动作会不会把冲突转化成更隐蔽的数据错误或更大的流量放大”。
我遇到过一次回滚后错误率立即下降,大家以为问题已经解决,但在下一次流量峰值时冲突再次出现。后来才发现,团队只验证了接口成功率,没有验证热点数据、事务时长和重试放大,我想知道一套完整的修复验收标准应该怎么设计。
并发问题不能只用“错误率恢复”验收,因为锁等待、更新覆盖和重复写入可能不会同时触发应用错误。我的验收标准分成四层:冲突是否减少、延迟是否恢复、业务数据是否正确、修复是否能在接近生产的并发条件下重复验证。
验收层至少观察的指标通过条件示例 数据库行为死锁次数、锁等待时间、长事务数量峰值期间无异常增长,长事务回到基线 应用性能P95、P99、连接池等待、重试次数P99 不因重试掩盖而继续上升 业务正确性库存、余额、订单状态、重复记录对账结果与预期一致 发布稳定性灰度节点与旧链路对比新链路在相同流量下无明显劣化 我会先构造最小复现,而不是直接在线上等待问题再次发生。
最小复现只保留两个并发事务、一个热点业务对象和触发冲突的关键 SQL,并固定执行顺序。这样才能确认修复的是根因,而不是碰巧改变了请求时序。压测时不能只提高总并发量,还要模拟热点比例。一次测试中,总请求量增加 50% 并不一定能复现问题;
但如果同一个账户或库存键占请求的 30%,即使总流量不高,也可能迅速暴露行级竞争。热点分布通常比总 TPS 更能决定并发冲突是否出现。数据正确性必须单独验收。例如库存扣减场景要检查“初始库存 – 成功扣减总量 = 最终库存”,订单状态则要检查是否出现逆向流转、重复完成或跳过中间状态。
接口返回 200,不代表业务状态正确。最后采用灰度而不是一次性放量,并同时对比新旧链路的事务时长、锁等待和重试次数。如果修复后错误率下降,但事务时间和重试次数持续上升,说明系统可能只是把显性失败变成了隐性拥塞,不能宣布问题已经解决。


读者评论
文章把“并发冲突”拆分为锁等待、死锁、乐观锁冲突、重复执行等类型,这个分类很实用。尤其是强调先补齐请求、事务和锁关系,再决定是否加索引或重试,避免了凭经验排查。
比较认同对事务边界和远程调用的提醒。很多重构确实会把风控、消息或审计操作放进事务,导致持锁时间变长。若能再补充不同数据库获取等待图的具体方法,落地性会更强。
热点数据分布这一点容易被忽略。总并发量相同,单热点键和随机分布的锁等待可能完全不同。文章提出按热点比例做压测,也提醒了灰度期间新旧链路双重写入的风险。