接口明明成功了,手机端还是老数据?我被事务和消息“联手”套路了一次

49 阅读7分钟

第 12 篇 · 共100篇|用代码丈量成长 —— 坚持写下去,就是最好的成长。

接口都成功了,为什么还是老数据?

前段时间测试同事提了一个“偶现问题”:

在操作数据后,手机端刷新页面,显示的还是老数据,
只有返回上一页再进来,才会看到最新数据。

当时事情多,就先记了一笔。最近空下来,才正式开始排查这件小事。结果一路追踪下来,发现背后其实是一个挺有代表性的“事务 + 消息”坑。


01. 偶现问题,先确认几件事

遇到“偶现”,我先给自己定了两个前提:

  1. 业务流程整体是没问题的,否则不可能只是偶现
  2. 业务在完成数据存储操作后,的确会发送消息给手机端,让它去刷新数据。

也就是说:
从业务设计上看,链路是闭环的——改数据 → 发消息 → 前端收消息刷新 → 查新数据。

那为什么有时候会看到旧数据呢?


02. 本地怎么都复现不了

第一步还是老套路:先在本地复现

  • 我本地连测试库,自己跑了几条数据,
  • 完整走了一遍操作流程,
  • 手机端那边配合联调,看起来一切正常。

既然本地不报错、不复现,那就只能开始怀疑:是不是环境差异

当时我想到两个点:

  1. 测试同事发现问题的时候,环境里的数据量明显比我本地要大
  2. 这个业务本身流程比较复杂,涉及多个表、多个步骤,事务时间也可能更长

于是我又换成了大批量数据的场景,在测试环境里尽量还原他们的操作节奏、数据量,连续压了一阵子,结果——依然没有问题。

这会儿其实就有点尴尬了:
代码看着没问题,本地和压测也没问题,偶现还一直在。


03. 先怀疑缓存,但很快被自己推翻

测试反馈的一个关键信息是:

“同样的操作路径,有时候查出来是旧数据,有时候是新数据。”

这听起来很像一个典型场景:缓存没更新 / 更新有延迟

于是我第一反应就是:
是不是某个地方加了缓存,写库之后没及时失效,导致前端偶尔读到旧数据?

结果顺着调用链一查:
这个业务压根没用缓存,也没有 Redis 之类的参与,干干净净就是查库。

那问题就变成了:

在没有缓存的前提下,同一套逻辑、同一条链路,为什么有时候查到新数据,有时候查到旧数据?


04. 把视角转向“事务”

既然缓存排除掉了,我就开始盯着另外一个关键词:事务

我们都知道,在 Spring 事务里:

  • 事务提交前,别的事务去查,很可能还是旧数据;
  • 事务提交后,其他请求才能看见最新的数据。

测试那边描述的现象刚好也符合这一点:

我这边操作完,手机端马上刷新,有时候是旧数据,有时候又是新的。

结合业务流程,再捋一遍:

  1. 接口里先完成数据库操作(在事务中)。
  2. 接着 发送一条消息 给手机端,提醒它刷新。

我去看了下发送消息的方法:

是一个同步调用,而且是直接写在事务方法里面的。

这就很微妙了——
如果事务还没提交就先发了消息,那手机端立刻去查,确实有可能看到的还是旧数据

所以当时我基本锁定了一个怀疑对象:

极端情况下:
消息发出去了,但事务还没提交
→ 手机端根据消息马上发起查询
→ 读到的就是“事务前”的旧数据。


05. 人为“慢一点”,问题终于稳定复现

有了猜想,下一步就是想办法人为地放大这个问题

为了验证这个场景,我在原逻辑的基础上做了一个非常小的改动:

public void sendMessage() {
    // 原来的逻辑是这里直接发送消息
    send();

    // 为了验证问题,我在这里加了一行“人为的拖延”
    Thread.sleep(3000);
}

或者更准确地说,我让事务执行过程变“更慢一点” ,以便放大“事务未提交”和“手机端已经在查”的时间差。

然后我在测试环境里,按测试同事当时的操作方式反复点,结果——
问题终于可以稳定复现了:

  • 手机端每次都能成功收到消息;
  • 但刷新后查到的,确实是旧数据;
  • 表现和测试之前描述的一模一样。

到这一步,基本可以确认:

根因就是:事务提交前就把“刷新数据”的消息发出去了。


06. 问题明确了:那就想办法“晚一点发”

问题已经清楚了,解决方向也很自然:

既然 “事务未提交就发消息” 会出问题,
那就改成 “事务提交之后再发消息”

在 Spring 体系里,其实已经有现成的工具可以用:

可以在事务方法里注册一个回调,让它在事务真正 commit 之后 再去执行你想做的事情,比如发送消息。

所以我做了两件小事:

  1. 把发送消息的逻辑做了一层包装
  2. 封装了一个工具方法 TransactionUtil.runAfterCommit,用来在事务提交之后再执行任务。

07. 最终方案:利用事务同步,在 afterCommit 里发消息

先来看发送消息的入口:

public void sendMessage() {
    TransactionUtil.runAfterCommit(() -> {
        // 事务真正提交之后,再发送消息
        send();
    });
}

关键逻辑都在 TransactionUtil 里:

@Slf4j
public class TransactionUtil {

    /**
     * 在事务提交后执行任务;如果没有事务则立即执行
     *
     * @param task 事务提交后要执行的任务
     */
    public static void runAfterCommit(Runnable task) {
        if (TransactionSynchronizationManager.isSynchronizationActive()
                && TransactionSynchronizationManager.isActualTransactionActive()) {

            // 当前有事务:注册一个“事务同步回调”
            TransactionSynchronizationManager.registerSynchronization(
                    new TransactionSynchronization() {
                        @Override
                        public void afterCommit() {
                            safeRun(task, "事务提交后执行任务失败");
                        }
                    }
            );

        } else {
            // 没有事务:那就直接执行
            safeRun(task, "无事务环境下执行任务失败");
        }
    }

    /**
     * 安全执行任务,捕获异常并记录日志
     *
     * @param task        要执行的任务
     * @param errorPrefix 错误前缀,用于日志定位
     */
    private static void safeRun(Runnable task, String errorPrefix) {
        try {
            task.run();
        } catch (Exception e) {
            log.error("异常:{},错误信息:{}", errorPrefix, e.getMessage(), e);
        }
    }
}

这样一来:

  • 如果当前线程里有真实事务在跑
    → 消息会在 事务 commit 成功之后 再发出去;
  • 如果当前本来就没有事务
    → 那就直接执行任务,不改变原本行为。

这个实现的好处是:

  • 改动范围非常小
    只是在原来的 send() 外面包了一层 runAfterCommit
  • 对上层业务几乎透明
    业务只负责调用 sendMessage(),至于“什么时候发”,交给事务来决定。

08. 小结:关于“偶现问题”的几点感受

复盘一下这次排查,其实有几个小经验还挺值得记一下:

  1. 偶现 ≠ 幻觉
    测试说偶现,多数情况下,不是“测试点错了”,而是我们没把场景想全,尤其是并发、事务、延迟这几块。
  2. 能稳定复现,是转折点
    这次也是:本地怎么测都没问题,直到“人为加了一点延迟”,问题才开始稳定出现。
    很多线上诡异 Bug,最后都要靠“构造极端条件”才能抓到。
  3. 事务 + 消息,一定要想清楚顺序
    如果消息的意义是“通知别人去读这条新数据”,那它就应该在数据真正对外可见之后再发。
    否则,很容易出现这次这样的“消息先到、数据后到”的错位。
  4. 封装一个通用小工具,会比散落在各处的 if/else 更干净
    像这次的 runAfterCommit
    以后只要看到“跟事务关联的异步任务”,都可以复用这套逻辑,而不是到处写一堆 if (TransactionSynchronizationManager...)

最后,这个问题本身不算大,但对我来说又是一堂很具体的课:

很多时候不是代码不对,而是时机不对
在分布式、消息、事务越来越多的系统里,“什么时候做”,真的不比“做什么”简单。

如果你现在的系统里也有“更新完数据就发消息让别人去查”的逻辑,
不妨也回头看一眼——
你的消息,是在事务提交前发的,还是提交后发的?

你的阅读与同行,让路途更有意义

愿我们一路向前,成为更好的自己