一张本地消息表装两个业务:差异一点没进表里
本地消息表大家都写过,但让一张表同时装两个业务的,我见得不多。
支付成功之后,系统还有两件事要干:给用户加经验、发一条站内信。
这两件事不能拖在支付回调的主流程里,所以我建了一张本地消息表------主事务里只写一条事件,剩下的交给定时任务去执行,失败了就重试。状态就四个:待分发、执行中、完成、死信。
整套东西跑在数据库里,没有 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 天发布的文章(窗口可配)。更早的漏网得靠手工重建。
以上是我基于自己项目的一些思考,未必通用,仅供参考------有不同的做法,欢迎评论区拍砖。