第 12 篇 · 共100篇|用代码丈量成长 —— 坚持写下去,就是最好的成长。
接口都成功了,为什么还是老数据?
前段时间测试同事提了一个“偶现问题”:
在操作数据后,手机端刷新页面,显示的还是老数据,
只有返回上一页再进来,才会看到最新数据。
当时事情多,就先记了一笔。最近空下来,才正式开始排查这件小事。结果一路追踪下来,发现背后其实是一个挺有代表性的“事务 + 消息”坑。
01. 偶现问题,先确认几件事
遇到“偶现”,我先给自己定了两个前提:
- 业务流程整体是没问题的,否则不可能只是偶现。
- 业务在完成数据存储操作后,的确会发送消息给手机端,让它去刷新数据。
也就是说:
从业务设计上看,链路是闭环的——改数据 → 发消息 → 前端收消息刷新 → 查新数据。
那为什么有时候会看到旧数据呢?
02. 本地怎么都复现不了
第一步还是老套路:先在本地复现。
- 我本地连测试库,自己跑了几条数据,
- 完整走了一遍操作流程,
- 手机端那边配合联调,看起来一切正常。
既然本地不报错、不复现,那就只能开始怀疑:是不是环境差异。
当时我想到两个点:
- 测试同事发现问题的时候,环境里的数据量明显比我本地要大;
- 这个业务本身流程比较复杂,涉及多个表、多个步骤,事务时间也可能更长。
于是我又换成了大批量数据的场景,在测试环境里尽量还原他们的操作节奏、数据量,连续压了一阵子,结果——依然没有问题。
这会儿其实就有点尴尬了:
代码看着没问题,本地和压测也没问题,偶现还一直在。
03. 先怀疑缓存,但很快被自己推翻
测试反馈的一个关键信息是:
“同样的操作路径,有时候查出来是旧数据,有时候是新数据。”
这听起来很像一个典型场景:缓存没更新 / 更新有延迟。
于是我第一反应就是:
是不是某个地方加了缓存,写库之后没及时失效,导致前端偶尔读到旧数据?
结果顺着调用链一查:
这个业务压根没用缓存,也没有 Redis 之类的参与,干干净净就是查库。
那问题就变成了:
在没有缓存的前提下,同一套逻辑、同一条链路,为什么有时候查到新数据,有时候查到旧数据?
04. 把视角转向“事务”
既然缓存排除掉了,我就开始盯着另外一个关键词:事务。
我们都知道,在 Spring 事务里:
- 事务提交前,别的事务去查,很可能还是旧数据;
- 事务提交后,其他请求才能看见最新的数据。
测试那边描述的现象刚好也符合这一点:
我这边操作完,手机端马上刷新,有时候是旧数据,有时候又是新的。
结合业务流程,再捋一遍:
- 接口里先完成数据库操作(在事务中)。
- 接着 发送一条消息 给手机端,提醒它刷新。
我去看了下发送消息的方法:
是一个同步调用,而且是直接写在事务方法里面的。
这就很微妙了——
如果事务还没提交就先发了消息,那手机端立刻去查,确实有可能看到的还是旧数据。
所以当时我基本锁定了一个怀疑对象:
极端情况下:
消息发出去了,但事务还没提交
→ 手机端根据消息马上发起查询
→ 读到的就是“事务前”的旧数据。
05. 人为“慢一点”,问题终于稳定复现
有了猜想,下一步就是想办法人为地放大这个问题。
为了验证这个场景,我在原逻辑的基础上做了一个非常小的改动:
public void sendMessage() {
// 原来的逻辑是这里直接发送消息
send();
// 为了验证问题,我在这里加了一行“人为的拖延”
Thread.sleep(3000);
}
或者更准确地说,我让事务执行过程变“更慢一点” ,以便放大“事务未提交”和“手机端已经在查”的时间差。
然后我在测试环境里,按测试同事当时的操作方式反复点,结果——
问题终于可以稳定复现了:
- 手机端每次都能成功收到消息;
- 但刷新后查到的,确实是旧数据;
- 表现和测试之前描述的一模一样。
到这一步,基本可以确认:
根因就是:事务提交前就把“刷新数据”的消息发出去了。
06. 问题明确了:那就想办法“晚一点发”
问题已经清楚了,解决方向也很自然:
既然 “事务未提交就发消息” 会出问题,
那就改成 “事务提交之后再发消息” 。
在 Spring 体系里,其实已经有现成的工具可以用:
可以在事务方法里注册一个回调,让它在事务真正 commit 之后 再去执行你想做的事情,比如发送消息。
所以我做了两件小事:
- 把发送消息的逻辑做了一层包装;
- 封装了一个工具方法
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. 小结:关于“偶现问题”的几点感受
复盘一下这次排查,其实有几个小经验还挺值得记一下:
- 偶现 ≠ 幻觉
测试说偶现,多数情况下,不是“测试点错了”,而是我们没把场景想全,尤其是并发、事务、延迟这几块。 - 能稳定复现,是转折点
这次也是:本地怎么测都没问题,直到“人为加了一点延迟”,问题才开始稳定出现。
很多线上诡异 Bug,最后都要靠“构造极端条件”才能抓到。 - 事务 + 消息,一定要想清楚顺序
如果消息的意义是“通知别人去读这条新数据”,那它就应该在数据真正对外可见之后再发。
否则,很容易出现这次这样的“消息先到、数据后到”的错位。 - 封装一个通用小工具,会比散落在各处的 if/else 更干净
像这次的runAfterCommit,
以后只要看到“跟事务关联的异步任务”,都可以复用这套逻辑,而不是到处写一堆if (TransactionSynchronizationManager...)。
最后,这个问题本身不算大,但对我来说又是一堂很具体的课:
很多时候不是代码不对,而是时机不对。
在分布式、消息、事务越来越多的系统里,“什么时候做”,真的不比“做什么”简单。
如果你现在的系统里也有“更新完数据就发消息让别人去查”的逻辑,
不妨也回头看一眼——
你的消息,是在事务提交前发的,还是提交后发的?
你的阅读与同行,让路途更有意义
愿我们一路向前,成为更好的自己