4. 同步回写模式:实时响应的代价与选择
同步回写,这个名字听起来挺技术范儿的。说白了,就是决策引擎算出一个结果后,立刻、马上、当场就把这个结果写回数据库或者下游系统。调用方得在那儿等着,直到回写完成,才能拿到最终响应。
我刚开始接触决策引擎那会儿,觉得同步回写是天经地义的事——程序不都是这么跑的吗?后来踩的坑多了,才明白这里面门道很深。今天咱们就把同步回写掰开揉碎了聊。
4.1 什么是同步回写
同步回写,指的是决策流程执行完毕后,在当前线程内立即将结果数据持久化到目标存储,并等待写入确认后,才向调用方返回响应。
它的核心特征就三个:
- 阻塞等待:调用方线程挂起,直到回写完成
- 强一致性:写入成功后,调用方立即能读到最新数据
- 事务绑定:回写失败通常会导致整个请求失败或回滚
一句话总结:同步回写 = 决策计算 + 立即写入 + 等待确认 + 返回结果。这四个步骤串成一个完整的事务单元。
4.2 适用场景:什么时候非用不可?
说实话,不是所有场景都适合同步回写。我个人习惯是,先问自己一个问题:如果这个结果没写进去,下一秒用户再查,会不会出大事?
如果答案是「会」,那同步回写基本就是唯一选择。我列几个典型的场景:
| 场景 | 为什么必须同步 | 我踩过的坑 |
|---|---|---|
| 金融交易风控 | 交易结果必须实时落库,防止重复支付或盗刷 | 曾经异步回写导致同一笔订单被扣了两次款 |
| 用户注册/登录 | 注册成功后,用户立即登录,数据必须已存在 | 异步写入延迟,用户登录时提示「账号不存在」 |
| 实时授信决策 | 额度审批结果需要立即生效,供后续交易校验 | 同步写入失败,用户额度没更新,交易被拒 |
| 规则热更新 | 新规则部署后,下一条请求必须使用新规则 | 异步同步导致新旧规则混用,决策结果不一致 |
你想想看,这些场景都有一个共同点:数据的时效性直接决定了业务正确性。晚一秒都不行。
4.3 实现方式:三种主流做法
同步回写的实现,说白了就是「怎么把结果写进去,同时不让调用方等太久」。我做过三种方案,各有各的脾气。
4.3.1 直接数据库写入
最朴素的做法。决策引擎算完结果,直接 JDBC 或 ORM 写入数据库。
// 伪代码:直接数据库写入
public DecisionResult execute(DecisionRequest request) {
// 1. 执行决策
DecisionResult result = decisionEngine.evaluate(request);
// 2. 同步写入数据库
try {
decisionDao.insertResult(result);
// 写入成功,返回结果
return result;
} catch (Exception e) {
// 写入失败,整个请求失败
throw new DecisionException("回写失败", e);
}
}
优点:实现简单,事务好控制。
缺点:数据库成为瓶颈。我在一个高并发项目里用过,QPS 一上去,数据库连接池直接被打满,响应时间从 50ms 飙到 2s。
我曾经在一个日活千万的信贷系统里用这种方案,上线第一天就出了事故。数据库连接池耗尽,整个决策服务雪崩。后来不得不改成连接池隔离 + 读写分离才稳住。
4.3.2 缓存 + 数据库双写
先写缓存(比如 Redis),再异步写数据库。但注意,这里说的「同步回写」是指对调用方而言,缓存写入成功就算成功。
// 伪代码:缓存优先双写
public DecisionResult execute(DecisionRequest request) {
DecisionResult result = decisionEngine.evaluate(request);
// 1. 同步写入缓存(必须成功)
cacheClient.set(result.getKey(), result, 300); // 5分钟过期
// 2. 异步写入数据库(可失败重试)
asyncExecutor.submit(() -> {
try {
decisionDao.insertResult(result);
} catch (Exception e) {
log.error("数据库回写失败,等待补偿", e);
// 放入重试队列
}
});
// 3. 立即返回
return result;
}
优点:响应快,缓存扛住读压力。
缺点:缓存和数据库可能不一致。如果缓存挂了,数据就丢了。
我的建议:这种方案适合「结果可以短暂丢失,但不能长时间不一致」的场景。比如用户画像标签,丢几秒问题不大,但别丢一天。
4.3.3 消息队列同步确认
把结果发给消息队列,但调用方要等到队列确认消息已持久化,才算成功。
// 伪代码:MQ 同步确认
public DecisionResult execute(DecisionRequest request) {
DecisionResult result = decisionEngine.evaluate(request);
// 发送消息并等待 Broker 确认
SendResult sendResult = mqProducer.send(
new Message("DECISION_RESULT", result.toBytes()),
new SendCallback() {
@Override
public void onSuccess(SendResult result) {
// Broker 已持久化,可以返回
}
@Override
public void onException(Throwable e) {
// 发送失败,重试或回滚
}
}
);
// 等待确认(同步转异步)
sendResult.get(500, TimeUnit.MILLISECONDS);
return result;
}
优点:解耦,削峰填谷,适合大数据量。
缺点:架构复杂,需要处理消息重复和顺序问题。
4.4 优缺点分析:没有银弹
同步回写不是万能的。我做了个对比表,方便你选型时参考:
| 维度 | 同步回写 | 异步回写 |
|---|---|---|
| 数据一致性 | 强一致,写入即可见 | 最终一致,有延迟窗口 |
| 响应时间 | 受写入耗时影响,可能变慢 | 写入不阻塞,响应快 |
| 系统吞吐量 | 受限于存储写入能力 | 可缓冲,吞吐量高 |
| 故障处理 | 写入失败 = 请求失败 | 可重试、补偿,容错性好 |
| 实现复杂度 | 简单直接 | 需要消息队列、补偿机制 |
| 适用场景 | 强一致性要求高、写少读多 | 高并发、写多读少、可容忍延迟 |
说白了,同步回写是用响应时间和吞吐量换数据一致性。如果你的业务能接受几秒甚至几分钟的延迟,那异步回写更香。但像金融交易这种,延迟一秒可能就出事故,那就别犹豫,上同步。
4.5 同步回写的核心流程图
下面这张图,是我画的一个同步回写完整流程。你可以看到,从请求进来,到结果返回,每一步都是串行的。
4.6 避坑指南:我踩过的三个坑
做同步回写这么多年,有些坑真的是拿血泪换来的。分享三个最常见的:
-
超时设置不合理
我曾经把数据库写入超时设成 5 秒,结果数据库一抖动,所有请求都卡住。调用方等 5 秒才超时,前端直接报 504。后来改成 500ms 超时 + 快速失败,用户体验好多了。
-
忽略连接池耗尽
同步回写占用的数据库连接时间 = 决策时间 + 写入时间。如果决策本身就要 200ms,写入 100ms,那一个请求就占连接 300ms。并发一高,连接池瞬间打满。我的做法是:单独给回写操作分配连接池,和决策查询池隔离。
-
事务范围过大
有人喜欢把「决策计算 + 回写」包在一个大事务里。决策计算如果涉及远程调用,事务时间会很长,容易死锁。我建议:事务只包裹写入操作,决策计算在事务外完成。
一个小技巧:同步回写时,尽量使用批量写入或管道技术。比如一次写入多条决策结果,而不是一条一条写。我在一个风控系统里,把单条写入改成批量 50 条,吞吐量提升了 3 倍。
4.7 小结
同步回写,说白了就是「用时间换安全」。它适合那些数据一致性要求极高、不能容忍延迟的场景。但代价也很明显:响应变慢、吞吐受限、对存储压力大。
选不选同步回写,我的判断标准就一条:如果结果没写进去,下一秒会不会出业务事故?会,就同步;不会,考虑异步。