文章

评论计数为什么会遇到锁等待

语之溪

·

Java

·

⚠️ 本文最后更新于2026年05月09日,已经过了107天没有更新,若内容或图片失效,请留言反馈

在 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 可以缩短主事务并避免回滚后的提前回写,异步可以减少用户等待,但可靠一致仍需要持久化事件或补偿机制完成。

现在已有 7 次阅读,0 条评论,0 人点赞
评论:共0条
发表
搜索 消息 足迹 排行
你还不曾留言过..
你还不曾留下足迹..
博主 不再显示
博主
未知作品 歌曲封面
立即安装