在 HAOVP 的接口验收中,一条互动链路曾因为跨服务同步回写计数出现锁等待。报告中明确记录的实际问题发生在视频收藏后同步更新用户收藏数;评论创建和删除也有相似的“本地记录 + 远端计数”结构,因此适合用来分析同一类事务时序问题。
这里需要先把两条链路分开:收藏链已经出现过锁等待;评论链存在同类跨服务计数更新,但目前没有同样的故障记录。它们共享一组事务时序问题,却不是同一次故障。
一次评论操作涉及两份状态
创建评论时,至少有两类数据变化:
评论服务
-> 新增评论记录
内容服务
-> 视频评论数加一删除评论时则反向执行:评论状态改变,视频评论数减一。
这两份数据属于不同服务和数据库事务。单个 @Transactional 只能保证当前服务本地事务,不能把一次 HTTP 或 Feign 调用自动纳入同一数据库事务。
为什么事务内同步调用容易拖长锁时间
假设评论服务在本地事务尚未提交时,同步调用内容服务更新计数:
评论事务开始
-> 写入评论,持有本地锁
-> 同步调用内容服务
-> 内容服务开启自己的事务
-> 更新视频计数
-> 等待数据库资源或其他调用
-> 远端返回
-> 评论事务提交远端调用时间会被包含在本地事务持续时间内。网络等待、连接池等待、下游数据库锁和重试都会让本地事务更久才释放资源。
如果另一条链路以不同顺序操作相同资源,就可能形成等待环:
请求 A:先锁评论相关数据,再等待视频相关更新
请求 B:先锁视频相关数据,再等待评论相关更新这只是解释锁等待机制的通用时序,不对应报告中已经确认的具体 SQL 和锁顺序。现有材料没有给出死锁日志、锁记录或并发量,因此不能编造是哪一条 SQL 锁住哪一行。
锁等待不一定等于死锁
两者需要区分:
- 锁等待:一个事务等待另一个事务释放资源,可能随后成功;
- 锁等待超时:等待超过数据库配置时间后失败;
- 死锁:事务之间形成循环等待,数据库通常会主动回滚其中一个事务。
接口表现为“很慢”或超时,只能说明需要继续查事务和依赖,不能直接下结论为死锁。
我会收集:
- 请求开始、下游调用和事务结束的时间;
- 数据库当前事务与锁等待信息;
- 慢查询和异常日志;
- 涉及表的索引与更新条件;
- 是否存在重试;
- 多条链路更新资源的先后顺序。
没有这些证据时,更准确的说法是“出现锁等待或锁等待相关慢失败”。
为什么把回写移出主事务
收藏链的处理方式是把非核心计数回写调整为 afterCommit + async best-effort。
顺序变为:
完成本地核心写入
-> 本地事务提交
-> 启动异步任务
-> 调用远端更新计数
-> 失败时记录日志afterCommit 的意义是:只有本地事实已经提交,才通知其他服务更新派生计数。这样既缩短了本地事务持锁时间,也避免本地事务回滚后远端计数先被更新。
一个简化结构是:
public void runAfterCommit(Runnable task) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
CompletableFuture.runAsync(task);
}
}
);
}这是重写后的结构示例,不包含实际服务、方法和线程池配置。
只有异步,没有 afterCommit 会怎样
评论计数代码在本地写入后启动异步任务。异步任务不等待主请求返回,但它可能在本地事务提交前开始。
一种可能时序是:
本地事务写入评论
-> 异步任务启动
-> 远端评论数加一
-> 本地事务随后回滚最终可能出现评论不存在,但视频评论数已经增加。
另一种时序是异步任务读取依赖数据时,本地事务尚未提交,导致它看不到新状态。异步化减少等待,却不会自动保证执行顺序和一致性。
因此,评论计数如果继续采用异步回写,也应该明确事务提交点、失败补偿和幂等规则。
best-effort 到底是什么意思
best-effort 表示尽力执行:主业务成功后尝试更新派生数据,失败通常只记录日志,不让已经成功的主事务回滚。
它适合某些可重建、允许短时误差的计数,但代价是:
- 异步任务可能执行失败;
- 进程退出时任务可能尚未完成;
- 没有持久化队列时任务无法可靠重放;
- 网络超时后无法确定远端是否已经执行;
- 直接重试可能造成重复加减;
- 计数可能与明细数据暂时或长期不一致。
所以 best-effort 不是“最终一定成功”,也不是消息队列的替代品。
计数更新必须考虑幂等
使用 +1、-1 传递增量很简单,但重试很危险:
远端已经 +1
-> 响应在网络中丢失
-> 调用方认为失败并重试
-> 远端再次 +1可以选择不同策略:
- 为事件分配唯一编号,远端只处理一次;
- 使用评论状态变更事件,由消费者去重;
- 定期按有效评论明细重新计算计数;
- 将计数视为可修复的派生数据,提供对账任务;
- 对关键场景直接查询明细,而不是完全依赖计数字段。
哪种方式合适取决于实时性、数据量和故障容忍度。
为什么不能简单使用分布式事务包住全部操作
把两个服务的数据库更新放进强一致分布式事务,看起来可以一次解决问题,但会增加协议、锁持有、故障恢复和运维复杂度。
对于评论数、收藏数这类派生计数,更常见的取舍是:
明细记录作为事实来源
计数字段作为查询优化
允许短时不一致
通过事件、重试和对账恢复这不代表所有业务都适合最终一致。余额、库存扣减或权限变更等场景需要单独评估,不能直接套用互动计数的策略。
怎样验证调整后的链路
我不会只看接口变快,而会同时检查主记录和派生计数。
正常流程
- 新增评论后,评论记录存在;
- 评论数最终增加一次;
- 删除评论后,状态正确;
- 评论数最终减少一次。
事务失败
- 本地写入回滚时,不应更新远端计数;
- 远端更新失败时,主记录保持正确;
- 失败任务有可定位日志或持久化记录。
重试与重复
- 同一请求重复提交不会重复创建;
- 同一计数事件重复执行不会重复累加;
- 超时后重试能判断第一次是否生效。
对账
SELECT video_id, COUNT(*)
FROM comment
WHERE status = 1
GROUP BY video_id;这是通用示例表名。将有效评论明细汇总与视频计数字段比较,可以发现漂移,但对账脚本还要考虑逻辑删除、审核状态和统计口径。
线程池也是边界
直接使用 CompletableFuture.runAsync 时,默认线程池是否适合业务负载需要单独评估。
需要关注:
- 队列是否有界;
- 线程数量和拒绝策略;
- 上下文和日志标识是否传递;
- 应用停机时如何等待任务;
- 下游故障时任务是否快速堆积;
- 不同类型任务是否相互争抢资源。
异步任务没有受控线程池和监控,也可能把同步等待变成后台资源耗尽。
这次问题能确认到哪里
实际验收能够确认:收藏数同步回写曾遇到锁等待,调整为本地事务提交后异步尽力更新,相关接口链路恢复。
评论服务能够确认:评论创建、删除与视频评论数属于跨服务的两份状态,当前代码使用异步回写;这具有相似的事务时序与一致性风险,但材料没有证明评论计数实际发生了同一场锁等待。
因此,解决方向不是简单地“把所有调用改成异步”,而是先决定哪份数据是事实来源,再明确提交顺序、幂等、失败重试和对账。afterCommit 可以缩短主事务并避免回滚后的提前回写,异步可以减少用户等待,但可靠一致仍需要持久化事件或补偿机制完成。