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 同步回写的核心流程图

下面这张图,是我画的一个同步回写完整流程。你可以看到,从请求进来,到结果返回,每一步都是串行的。

同步回写核心流程图 1. 接收请求 2. 执行决策计算 3. 同步写入存储 4. 等待写入确认 5. 返回决策结果 写入失败 → 回滚 异常 关键说明 整个流程是串行阻塞的,调用方线程在步骤3-4期间挂起等待。 任何一步失败,整个请求都会失败,不会返回部分结果。

4.6 避坑指南:我踩过的三个坑

做同步回写这么多年,有些坑真的是拿血泪换来的。分享三个最常见的:

  1. 超时设置不合理

    我曾经把数据库写入超时设成 5 秒,结果数据库一抖动,所有请求都卡住。调用方等 5 秒才超时,前端直接报 504。后来改成 500ms 超时 + 快速失败,用户体验好多了。

  2. 忽略连接池耗尽

    同步回写占用的数据库连接时间 = 决策时间 + 写入时间。如果决策本身就要 200ms,写入 100ms,那一个请求就占连接 300ms。并发一高,连接池瞬间打满。我的做法是:单独给回写操作分配连接池,和决策查询池隔离。

  3. 事务范围过大

    有人喜欢把「决策计算 + 回写」包在一个大事务里。决策计算如果涉及远程调用,事务时间会很长,容易死锁。我建议:事务只包裹写入操作,决策计算在事务外完成。

一个小技巧:同步回写时,尽量使用批量写入或管道技术。比如一次写入多条决策结果,而不是一条一条写。我在一个风控系统里,把单条写入改成批量 50 条,吞吐量提升了 3 倍。

4.7 小结

同步回写,说白了就是「用时间换安全」。它适合那些数据一致性要求极高、不能容忍延迟的场景。但代价也很明显:响应变慢、吞吐受限、对存储压力大。

选不选同步回写,我的判断标准就一条:如果结果没写进去,下一秒会不会出业务事故?会,就同步;不会,考虑异步。