一张本地消息表装两个业务:差异一点没进表里

一张本地消息表装两个业务:差异一点没进表里

本地消息表大家都写过,但让一张表同时装两个业务的,我见得不多。

支付成功之后,系统还有两件事要干:给用户加经验、发一条站内信。

这两件事不能拖在支付回调的主流程里,所以我建了一张本地消息表------主事务里只写一条事件,剩下的交给定时任务去执行,失败了就重试。状态就四个:待分发、执行中、完成、死信。

整套东西跑在数据库里,没有 MQ。

后来文章发布也要干一样的事:写完数据库,还得把文章同步到 ES。

我没有再建一张表,而是把它塞进了这一张。

一张表要同时装下这两个业务,只剩一个办法:让它不知道任何一件业务的事。


一、本来有两张表,长得完全不像

文章发布原先用的是一张自己专属的事件表。两张表摆在一起,差得比想象中远:

旧表 article_event 新表 ap_outbox_event
业务字段 article_id、parameter(这次同步要用的参数) 一个都没有
唯一键建在哪 article_id(业务键) event_key(业务无关的字符串)
状态记的是什么 步骤:INIT / DB_SET_FAIL / ES_SYNC_FAIL / DONE 进度:PENDING / PROCESSING / DONE / DEAD
装几种业务 1 种 2 种

旧表的每一列都是围着"这篇文章"长出来的:主键是文章 id,参数列存的是这次同步要用的数据,状态记的是"发布做到哪一步了"。新表的列里,没有一个字眼跟"文章"或者"订单"有关。

能合并的前提,是后者那种长法。


二、表里只有两个槽,业务怎么找回来

重试靠 event_type 区分业务:定时任务扫到一条事件,按 event_type 找到对应的处理逻辑去执行和重试,payload 里放实参(哪篇文章、哪笔订单)。表里业务相关的就这两列------但表只是存着,不认识键的含义,也读不懂 JSON 里装的是什么。

两条链路的失败处理是同一套,差别只有一处:

  • 正常失败都计次。 扫表执行时失败了,就退避着重试(2、4、8、16 分钟)并记一次数,到重试上限进死信、报警等人。支付链路的失败全是这种,上限是 5 次。
  • 文章链路多一种不计次的。 事件触发时,文章可能还停在审核中------这不是故障,等审核结束它自然能执行。要是这种情况也记一次失败,它会把重试配额烧光,真碰上 ES 挂掉时反而没机会重试了。所以这种情况退回待处理、不计数。

不计次就永远到不了上限,必须单独给它一个收手条件------时间:15 分钟内没执行成功,强制判死。 支付没有这种情况,不需要这个上限。

(顺带两句:计次这个字段省不掉,退避就是拿它算的;也不能把所有失败都改成只看时间------下游挂了你愿意多等几轮,审核中的竞态你只想等一小会儿,同一个窗口两个诉求会打架。)

所以这两条链路的差异,小到只剩"要不要多补一层兜底"。 既然这样,表里就不该为它们加任何东西------一旦开始加"业务才知道的列",这张表就从通用容器 变成某个业务的表;第三个业务进来,你要么继续加列,要么干脆开新表,共用就没意义了。

于是差异全部上移到注解声明:

支付联动 文章发布
路由键 PAY_REWARD ARTICLE_PUBLISH
载荷 订单号、用户、课程、金额 文章 id
重试上限 5 次 3 次
时间上限 不限 15 分钟
失败到尽头 死信,等人 死信,等人

最后一行两条完全一样。连"失败了往哪边倒"都不需要在表里区分。

于是表只认两样东西:一个路由键,一段 JSON。


三、那状态怎么统一

这才是最别扭的地方。

旧表的状态是步骤态 :INIT → DB_SET_FAIL → ES_SYNC_FAIL → DONE。每个值对应一个具体的补救动作------扫到这个状态就知道下一步该干嘛。

新表是处理态 :PENDING / PROCESSING / DONE / DEAD。四个值,对所有业务都是一个意思。

这两种合不了。"步骤"是业务自己定义的:文章发布有"置位失败""同步失败"两步,支付联动压根没有这两步。

所以统一的办法不是往枚举里加值,而是把状态降到更抽象的一层:不再记"断在哪一步",只记"这条消息处理到哪了"。

那旧状态里记的"断在哪一步"呢?新表的状态列不存它了,但这个信息不能跟着丢------它被三样东西接走:payload 存重放要用的数据,last_error 存最后一次失败的原因,被注解标注的方法体本身就是"怎么执行、怎么补救"。代价是以后想知道"文章同步失败了几次",得去翻 last_error,不能直接看状态了。

换来的是一张表能装下所有业务------"处理到哪了"是通用的,"断在哪一步"不是。

这四个值也是封死的,后续业务再多,也不加第五个。

原因在状态列回答的那个问题:**这条消息处理到哪了?**对所有业务,这个问题只有四种答案------没处理、正在处理、处理完了、处理不了要人。业务自己的流程里中间步骤再多,落到"这条消息送没送到"上,也就这四格。

所以状态少的直接进来用:支付联动没有中间步骤,正常路径就从待分发走到完成,失败到尽头进死信。状态多的也不用加值:它那些特有的中间步骤,前面说了,由 payload、last_error 和方法体接走,本来就不进状态列。

什么时候才真的会需要一个新的状态值?当你要记的已经不是"消息送到没有",而是"这件事做到哪一步"的时候。那是另一个问题,该用另一张表去记,而不是往这张消息表上加状态------一加,"四个值对所有业务一个意思"就没了。


四、做到哪一步

扩展位有了,但只验证过两种。 加一种新事件 = 在业务方法上打个注解,分发器一行不用改;目前表里就跑着两种。

具体是这样接的:业务方法上打个 @LocalMessage 注解,切面在业务事务里把这次调用的事件类型和实参存进消息表(与业务同事务提交);事务提交后,Dispatcher 按事件类型反射调回这个方法。业务代码只是普通地"调用了一个方法",完全不知道消息表的存在。

这个做法有个必须想清楚的取舍:消息表里只存业务数据(哪篇文章、哪笔订单),不存"该调哪个方法" 。有一类做法会把类名、方法名也写进记录,重放时按这个名字去找方法------问题是代码会重构:哪天方法改了名、类挪了包,老记录里存的还是旧名字,按名字找不到了,那条消息就永远重放不了。消息表偏偏要长期留存(死信要人工看、滞留要重放),所以"哪个事件对应哪个方法"这件事不写进数据,由代码在启动时按注解对上号------数据里不放任何会随代码改名而失效的东西。

重试超限的发布事件会进死信等人,不会被丢弃。 丢弃的理由听起来很顺:"ES 索引反正能重建,丢一条怕什么。"但"能重建"和"会被重建"是两回事:没有任何任务会自动把这篇补回索引,除非有人想起来手工跑一遍。而且丢了之后毫无动静------事件被标成完成,日志里只有一行警告,看起来一切正常,这篇文章在搜索里却是缺的。发布量小、失败更少,留一条死信的成本几乎为零。

收手之后还有两道:

  • 指标接进 Micrometer + Prometheus 告警------死信数一涨就通知;
  • DB↔ES 对账巡检------定期拿最近发布的文章问一次 ES"哪些不在索引里",缺的补推。它兜的不只是丢弃,还有"同步返回成功但文档没落库""索引被误删"这类重试覆盖不到的情况。

最后一条边界:不是"一个业务整块进表",而是"一个业务里该进的那半边才进"。 同一笔支付里,加经验、发站内信这种事,晚几秒、重试几次都无所谓,丢了可惜但不致命;可真正动钱的那一步,要的是当场确定的结果------成功了就是成功了,失败了就得当场知道,等不了"最终一致"。前者进了消息表,后者不进。

所以这张表能共用,是因为**"该不该进这张表"这个判断和业务无关**------它只跟一件事有关:失败的时候,哪个方向的错更难接受。


五、如果换成 MQ

开头说了,这套东西没有 MQ。那如果加一个呢?

要改的只有一件事:谁来触发投递。 现在是每 5 秒一轮的定时任务捞事件执行;换成 MQ,就是写完消息表后投一条,消费端执行完再回来标记状态。

不改的:表结构、状态机、被注解标出来的那几个业务方法。因为原子写入、幂等、重试、失败往哪边倒------MQ 一个都不解决,它解决的是"送"。

换句话说:本地消息表管"消息和业务数据一起落地",MQ 管"已经落地的消息怎么送到下游"。两件事不在一个层面上,谈不上互相替代。


边界

  • 没做压测。 5 秒一轮、每轮 20 条这些参数是按经验设的,没在高并发下验证过。
  • 扫描器跑在应用进程里。 多实例靠一条 CAS 更新保证不重复执行,没有独立部署。
  • 对账只覆盖最近 3 天发布的文章(窗口可配)。更早的漏网得靠手工重建。

以上是我基于自己项目的一些思考,未必通用,仅供参考------有不同的做法,欢迎评论区拍砖。

相关推荐
她的男孩1 小时前
头像换了三次还是旧图:秒传 + 预签名 URL + 私有文件权限,四层缓存叠出一个 Bug
java·后端·架构
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (十五):io_uring 传输 —— RingBuffer 与零拷贝 IO
java·后端
wei_shuo1 小时前
KES 数据保护体系构建:备份策略设计、恢复流程优化与时间点恢复实践
后端
MacroZheng1 小时前
装上这款全能增强插件,DeepSeek Harness瞬间高大上了!
java·人工智能·后端
imDwAaY1 小时前
6篇文章讲清楚Git:Git 实用技巧:暂存现场、挑选提交与定位 Bug (5/6)
git·后端
小卿噢1 小时前
那个时间不存在——时区代码里六个会真的出事的坑
后端
小园子的小菜1 小时前
深度图解 Python 进程、线程与 GIL:彻底吃透并发核心原理
后端
用户7713970207061 小时前
Trae 深度使用指南:从配置到实战,一个 AI 原生 IDE 的完整工作流
后端
创新技术阁1 小时前
FastapiAdmin代码生成详细解释
前端·后端·fastapi