从五种 Handler 复制粘贴说起
财务服务要消费的同步事件不止一种:折旧计提明细的 UPSERT、折旧冲销的 DELETE、资产卡片主数据变更、单据状态流转、期末结转标志。第一版的实现是五种事件五个 Handler,每个都自己写一遍完整的消费流程。
写完回头看,五个类几乎是一个模子刻出来的:
// 五个 Handler 里重复出现五次的段落
public void onMessage(MessageExt message) {
SyncEvent event = parse(message);
try {
doBiz(event); // 唯一不同的地方
logProcessed(event.getMsgId());
} catch (BizException e) {
saveRetry(event, e); // 落重试表
} catch (Exception e) {
log.error("unknown error", e);
throw new RuntimeException(e); // 让 MQ 重试
}
}
真正的业务差异只有 doBiz 那十几行,包裹它的却是五份完全相同的解析、异常分流、重试登记代码。这种重复的代价不是行数——是发散。改一次幂等逻辑要改五个地方,有一次改漏了一个 Handler,那个类型的事件就在没有幂等保护的状态下裸奔了小半个月,直到压测重放才暴露。
问题不是代码写得糙,是流程本身有"骨架+细节"的结构,而代码没有表达这个结构。这正是模板方法模式的用武之地——说来惭愧,之前整理模板方法的学习笔记时,我对它的印象一直停留在"abstract class 的教科书用法",觉得平淡无奇。真到五个 Handler 堆在眼前的时候才体会到,final 锁住的不是代码,是流程的语义。
骨架:把流程语义钉进父类
重构后的抽象长这样(简化示意,细节按真实实现核对):
public abstract class AbstractDepreciationHandler<T> implements SyncHandler {
/** 模板方法:锁死消费骨架,子类不许覆写 */
@Override
public final SyncResult handle(SyncEvent event) {
T payload = parse(event); // 1. 解析:子类各自的反序列化
validate(payload); // 2. 业务校验:子类各自的规则
int affected = doUpsert(payload); // 3. 幂等落库:子类差异点
postProcess(event, payload, affected); // 4. 善后:日志/监控埋点
return SyncResult.ok(event.getMsgId(), affected);
}
protected abstract T parse(SyncEvent event);
protected abstract void validate(T payload);
protected abstract int doUpsert(T payload);
/** 钩子:默认空实现,确有善后需求的子类再覆写 */
protected void postProcess(SyncEvent event, T payload, int affected) {
}
}
五种事件收敛成五个只实现 parse / validate / doUpsert 的子类。解析、异常分流、重试登记、监控埋点全部上收到父类——下一次流程调整只改一处,因为流程只存在一份。
有一个设计决策值得单独说:handle() 为什么是 final?因为骨架一旦允许子类覆写,"五份相同代码"就会以"五份微妙不同代码"的形式卷土重来——覆写正是发散的开始。默认空的 postProcess 钩子是给确有善后需求的子类留的口子,但钩子我们控制在两个以内,再多就说明这个子类的流程本质不同,该拆出去而不是继续打洞。
顺带把和策略模式的辨析写下来,因为推送层当初用的就是策略模式,两兄弟太容易混。辨析的标准很简单:变化发生在"整个算法可替换"时用策略(Email/IM/SMS 每个都是从头到尾不同的推送算法);变化发生在"流程固定、步骤各异"时用模板方法(每种事件的消费流程都是解析→校验→落库,只是每步内容不同)。消费侧选模板方法还有个实际考量:异常分流和重试登记依赖执行顺序,流程语义必须统一,交给孩子类自由发挥反而是风险。
幂等:给业务数据找自然身份
MQ 的 at-least-once 语义决定了重复投递是常态,消费端必须把"同一条消息处理两遍"变成无害操作。我们的方案没有引入独立的幂等表,而是直接给业务表加唯一键:单据号 + 资产编码。
为什么是这个组合?回到业务事实:一行折旧明细的自然身份就是"哪张计提单据、对哪个资产"。同一张单据对同一资产只可能有一条折旧明细——这不是技术设计,是财务口径本身。幂等键选业务自然键而不是自己发明一个去重键,好处是少维护一张表,且幂等凭据和业务数据同生共死:不存在"幂等表记录在、业务数据还没落"或反过来的中间态。
落库用 upsert(MySQL 语法,简化示意):
INSERT INTO depreciation_detail
(bill_no, asset_code, period, dep_amount, state_version, updated_at)
VALUES
(#{billNo}, #{assetCode}, #{period}, #{depAmount}, #{stateVersion}, now())
ON DUPLICATE KEY UPDATE
dep_amount = IF(VALUES(state_version) > state_version,
VALUES(dep_amount), dep_amount),
state_version = IF(VALUES(state_version) > state_version,
VALUES(state_version), state_version),
updated_at = IF(VALUES(state_version) > state_version, now(), updated_at);
这条 SQL 里其实叠着两层防线,分开说清楚:
唯一键防重复。 同一条消息重放一百次,唯一键命中同一行,INSERT 变 UPDATE,最终数据一模一样——这是 Upsert 幂等的本体。
条件更新防旧盖新。 但 upsert 幂等有个边界必须认清:它只对"内容相同的重复"无害。如果乱序让旧状态的事件后到,裸的 upsert 会拿着旧值把新值覆盖掉。IF(VALUES(state_version) > state_version, …) 就是补这个缺口的:版本不比当前高的更新一律空转。唯一键解决"重复",条件更新解决"乱序",两道锁缺一不可——只加前者,链路在乱序面前照样丢数据。
压测时专门验证过这一点:把一天的真实事件流打乱顺序重放两遍,库里的数据与正常顺序单遍消费的结果逐字段一致。幂等做得对不对,不能靠"应该没问题",要靠重放来证。
高可用:生产侧不丢,消费侧必达
"本地消息表与指数退避重试保障高可用"这句话拆开是两端的两个承诺。
生产侧:不丢。 资产侧 outbox 表在上一篇详写过,这里只回收一句——计提事务内写业务表的同时写 outbox,发送失败由 relay 任务兜底补发。"事件一定发得出去"不赌在 try-catch 上。
消费侧:必达。 失败的消息要有去处,且要按正确的节奏重试。完整的三层重试金字塔:
第一层是 MQ 内建重试。消费抛出异常,消息进重试队列,RocketMQ 的重试间隔随次数阶梯递增,默认十六次后落入死信队列。这一层挡的是瞬时抖动——下游数据库主从切换、连接池瞬间打满这类秒级恢复的故障,通常在重试队列里就自愈了。
第二层是消费侧的重试记录表 + 指数退避捞单,挡的是持续故障——财务服务升级、资产侧接口不可用这类分钟到小时级的故障,十六次 MQ 重试远远熬不过去。死信或显式失败的事件登记入表,定时任务按指数退避捞单:
// 指数退避:2^n 增长,封顶 + 抖动(简化示意)
long backoff = baseMillis * (1L << Math.min(retryCount, capExp));
backoff = Math.min(backoff, maxBackoffMillis); // 封顶 24h
backoff += ThreadLocalRandom.current().nextLong(jitterMillis);
record.setNextRetryAt(Instant.now().plusMillis(backoff));
为什么必须是指数退避而不是固定间隔?假设下游故障恢复的瞬间,一万条失败消息都按固定十分钟间隔重试——它们会在同一个对齐的节拍上砸回去,恢复中的下游被第二波打趴下,故障时间和影响面双双放大。指数退避把重试时间点在时间轴上指数级地拉开,再叠一层随机抖动(jitter)把同批消息的去重节拍彻底打散。重试策略的本质不是"多试几次",是控制失败恢复时刻的流量形状。
第三层是人:重试次数超上限的记录触发告警工单,死信队列挂监控。到这里,每一条失败的消息都有明确去向——要么正在退避等待,要么已经躺在告警里等人。高可用的反面不是故障,是失败得无声无息。
效果与还没做的
定性地说改造前后的差别:
新增一类同步事件,从"复制一个 Handler 改业务段"变成"实现三个方法",骨架代码零接触,幂等和重试天然继承;重复消费在压测重放下零痕迹;每条失败消息在重试表里有状态、有下一次重试时刻,排障时不用翻日志考古。
还没做的也如实记着:postProcess 之外暂时没开更多钩子,但已经能预感到"某种事件想跳过校验步骤"的需求正在靠近,到时候是继续开钩子还是拆出新的骨架分支,需要重新权衡;重试表的清理策略还没做(和上次 outbox 膨胀的坑同款隐患,已经挂在待办上);模板方法父类目前靠 code review 保证子类不绕过骨架,没有编译期或运行期的强制手段。
几点体会
设计模式的价值在学习笔记里是抽象的,在真实代码里是具体的。 笔记阶段觉得模板方法不过如此,直到五个 Handler 的重复代码摊在面前才明白:它解决的不是"代码优雅",是"流程语义只能存在一份"这种一致性工程问题。学到的东西要等一个真实场景来兑现。
幂等键的设计是业务问题,不是技术问题。 "单据号 + 资产编码"之所以成立,是因为财务口径里折旧明细就是这么定义唯一性的。技术上的 upsert 只是把这个业务事实翻译成了唯一索引。反过来,哪天业务口径变了(比如单据粒度调整),唯一键必须跟着动——幂等键跟着业务语义走,而不是跟着消息结构走。
每层防线只解决一种失败模式。 唯一键防重复、条件更新防乱序、MQ 重试防抖动、指数退避防持续故障、告警防无声失败——没有哪一层是多余的,也没有哪一层能包打天下。做数据同步做久了,会越来越习惯"先把失败模式列全,再给每一种配一条专属防线"的思路。
最后留一个自我提醒:模板方法把流程锁进父类的同时,也把修改权锁了进去——哪天出现一个流程真不一样的子类,最先崩掉的会是当初那个 final。到时候要敢于推翻自己,把骨架改成组合式的。模式是为场景服务的,反过来不成立。
网硕互联帮助中心



评论前必须登录!
注册