给一家连锁客户做后台系统,做过一段时间的通知。起初这件事没当成一个模块:订单付款了,代码里写一行发给店长;库存低于阈值,再写一行发给采购;有人请假,写一行发给人事。半年下来,通知散在四十多个业务方法里,收件人是硬编码的用户编号。
真正的麻烦出现在改需求的时候。客户说门店调价的通知也要发给区域督导,我改了一个方法;第二天说请假审批要抄送店长,又改一个。改到第三轮,谁也说不清"付款成功"这件事到底会发给几个人。后来我把通知抽出来做成订阅中心:业务只发事件,谁收、走什么渠道、什么时段不发,全部由一张规则表决定。
结论是:通知系统的核心不是发消息的通道,是"谁该收到"这张规则表。规则进了数据,改需求就是改一行配置;规则写在代码里,每次改需求都是一次发版。
一、通知乱发的三种表现
1.1 收件人硬编码在业务里
常见的写法是 send(shopManagerId, "订单已付款")。这行代码写下去很省事,问题在它把"谁该知道"这件事锁在了业务逻辑里。店长换人,得改代码;一个店有两个店长,也得改代码。
更隐蔽的问题是这类代码会繁殖。同一个事件在两个地方各发一次,收件人列表还不一样,用户就会收到两条内容相近的通知,一条带单号一条不带。
1.2 全量广播,靠人自己过滤
另一种极端是发给所有人,让收到的人自己判断要不要管。仓库的通知、门店的通知、财务的通知全都涌进一个群,结果是谁都不看。通知的价值和收到的条数成反比,一天三十条的时候还会看,一天三百条的时候直接静音。
1.3 漏发没人发现
漏发很难被发现,因为它不报错。某个门店没有配置店长,事件发出去匹配不到人,日志里只有一行"无收件人",没人看。等到月底客户问"为什么这批调价我们店没收到",才回头查。
所以订阅中心必须有一个东西:匹配结果落库。每次事件匹配到几个人、走了什么渠道、失败了几条,都要留痕,这样"没收到"才有得查。
二、把"谁该收到"变成数据
2.1 三个概念先分清
事件、订阅、渠道,三件事要分开。事件是"发生了什么",比如订单付款、库存预警、审批通过,它只有类型和业务数据,不带收件人。订阅是"谁关心什么",是一个人或一个角色对某类事件的关注声明。渠道是"怎么送",站内、短信、企业微信、邮件,同一条订阅可以配多个渠道。
分开的好处是三者可以独立变化。新增一个渠道不用碰订阅,新增一类事件不用碰渠道,调整收件人只改订阅。混在一起的话,每加一个需求都要动三处。
2.2 规则表怎么设计
规则表一行代表一条订阅规则:哪个事件、发给谁(全体、按门店、按角色、按具体人)、走哪些渠道、什么条件下才发、什么时段不发、优先级多少、什么时候生效。
"发给谁"用两个字段表达:范围类型和范围值。范围类型是枚举,范围值是字符串,按角色就存角色编码,按门店就存门店编号。这样一张表能覆盖四种收件方式,不用为每种方式建一张表。
条件用 JSON 存,比如"金额大于某值才发"、"仅限调价类目"。条件放在应用层解析,数据库只负责存,这样新增条件类型不用改表结构。
2.3 建表和匹配查询
下面这段在 MySQL 8 上跑。两张表:事件表存发生过什么,规则表存谁关心什么。第三个查询是给定一条事件,找出该收的人。
sql
CREATE TABLE notify_event (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_key VARCHAR(64) NOT NULL COMMENT '事件类型,如 order.paid',
biz_id BIGINT UNSIGNED NOT NULL,
payload JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_event (event_key, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE notify_rule (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_key VARCHAR(64) NOT NULL,
scope_type ENUM('all','store','role','user') NOT NULL DEFAULT 'all',
scope_value VARCHAR(64) NOT NULL DEFAULT '',
channels SET('inapp','sms','wecom','email') NOT NULL,
conditions JSON NULL COMMENT '应用层解析,如 {"amount_gte":100}',
quiet_from TIME NULL,
quiet_to TIME NULL,
priority TINYINT NOT NULL DEFAULT 100,
enabled TINYINT NOT NULL DEFAULT 1,
valid_from DATETIME NULL,
valid_to DATETIME NULL,
KEY idx_match (event_key, enabled, priority)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SELECT r.id AS rule_id,
r.priority,
r.channels,
u.id AS user_id,
u.role_code,
u.store_id
FROM notify_rule r
JOIN sys_user u
ON (r.scope_type = 'all'
AND u.status = 1)
OR (r.scope_type = 'user'
AND CAST(u.id AS CHAR) = r.scope_value)
OR (r.scope_type = 'role'
AND u.role_code = r.scope_value)
OR (r.scope_type = 'store'
AND CAST(u.store_id AS CHAR) = r.scope_value)
WHERE r.event_key = 'order.paid'
AND r.enabled = 1
AND (r.valid_from IS NULL OR r.valid_from <= NOW())
AND (r.valid_to IS NULL OR r.valid_to >= NOW())
ORDER BY r.priority, r.id, u.id;
三、匹配和投递怎么接
3.1 一条事件进来做什么
事件进来先落事件表,拿到事件编号,然后跑匹配查询得到"规则 + 人"的组合。同一个事件可能命中多条规则、同一个人可能出现多次,所以第二步要合并:按人去重,把他的渠道并起来,取优先级高的规则的条件。
合并之后进投递队列。队列里每条记录包含事件编号、人、渠道、规则编号。投递成功回写状态,失败进重试。这条链路里每一步都可以重放,因为事件表和队列都有记录。
3.2 静默时段和去重
静默时段写在规则上,起止时间是本地时间,判断在应用层做。落在静默时段的投递不进重试,直接压到静默结束后头一个时间窗口,标记为"延迟投递"。这样既不会半夜吵人,也不会把重要的通知吞掉。
去重按"人 + 事件类型 + 业务对象 + 时间窗"做。比如同一张单的付款通知,十分钟内对同一个人只发一条;库存预警同一商品一天只发一条。去重键存进缓存,带过期时间,不落库,因为它的生命周期很短。
3.3 投递失败怎么补
失败分两类:通道失败和地址失效。通道失败(超时、限流)重试三次,间隔递增;地址失效(手机号停机、企业微信未关注)不重试,把这条订阅标成"渠道不可达",同时给这个人发一条站内提醒,让他自己补。
补发要有上限。我们定的是同一条通知重试不超过三次,超过就落一张失败表,每天早上汇总成一封内部邮件发给运维。失败表比日志好用的地方在于它能排序:哪个渠道失败多,一眼看得出来。
3.4 站内通知和外部通知要分开做
站内通知是必达的,因为它存在系统里,用户登录就能看到;短信、企业微信是提醒,可能失败。这两类不要用同一套状态机。做法是站内先写,写成功之后再发外部渠道,外部失败不影响站内。
这个顺序带来的好处是:用户永远不会"什么都没收到"。哪怕短信全挂了,他进系统还能看到那条消息,只是晚了几小时。
四、上线之后调过的三处
4.1 规则优先级要有明确语义
一开始 priority 只用来排序,没定义冲突处理,结果同一个人命中两条规则,收到了两条内容不同的通知。后来把语义定清楚:优先级只决定同一事件同一人的去重胜出者,编号小的胜出,其余静默。
语义定清楚之后,配置的人就知道该怎么填:越具体的规则优先级越高,具体到人的高于按角色的,按角色的高于按门店的,全体的垫底。这个顺序符合直觉,配错的情况少了很多。
4.2 免打扰要能自己设
规则上的静默时段是管理员的视角,用户自己也想关。后来加了一层个人偏好:每个人可以对自己收到的每类事件选"实时、汇总、关闭"。个人偏好优先于规则上的静默时段,但不优先于强制通知(比如账号异常)。
个人偏好存在单独一张表,匹配时在应用层做一层过滤。这一层加上之后,投诉"半夜被吵醒"的工单从每月十几张降到一两张。
4.3 规则要有有效期
临时性的订阅很常见:某次促销期间,运营要盯每小时的单量。如果给她配一条永久规则,促销结束就忘了关,半年后还在收。所以规则表留了生效起止时间,配置时默认填一个结束时间。
默认结束时间是七天。这个默认值救过好几次,因为大部分人配规则时不会主动想"什么时候该停"。七天到了规则自动失效,需要就续,不需要就放着。
4.4 规则变更要留版本
规则改了之后出问题,得能回答"昨天这条通知为什么发给了这个人"。做法是每次改规则写一条变更记录:谁改的、改前改后、生效时间。查询时把命中的规则编号带进投递记录,事后可以顺着编号查回当时的配置。
没有这层记录,排查只能靠猜。有了之后,客户问"为什么我没收到",两分钟能给出答案:他所在的角色昨天下午被从规则里移出去了。
五、这套东西的边界
5.1 它不是工作流引擎
订阅中心只管"通知谁",不管"接下来该谁处理"。审批流转、任务分派这些有状态的东西不该塞进来,否则规则表会膨胀成一张流程表,谁也看不懂。
边界划清之后,两套系统各管一段:流程引擎在状态变化时发事件,订阅中心负责把事件变成消息。流程改版不影响通知,通知改规则不影响流程。
5.2 规则数量要有人管
规则表容易失控的地方是数量。业务方每提一个需求就加一条,两年下来几百条,互相覆盖,没人敢删。我们的做法是每季度做一次清理:三个月没命中过的规则标灰,半年没命中的归档。
清理这件事要自动化,靠人记不住。规则表里加一个命中次数字段,每次匹配加一,清理时按次数排序,一目了然。
常见问题
问:匹配查询用 OR 连接四个条件,规则多了会不会很慢?
答:会,所以要控制规则总量并在索引上做文章。规则表按事件类型和启用状态建组合索引,查询时先缩小到这个事件的所有启用规则,通常几十条,再做范围展开。如果单个事件的规则超过几百条,说明规则设计有问题,应该把共性抽成角色,而不是继续堆规则。
问:同一个人命中多条规则,渠道怎么合并?
答:按人去重,渠道取并集,条件取交集不成立时按优先级取舍。具体做法是先按优先级排序,同一个人只保留优先级靠前的那条规则的条件判断结果,渠道则把所有命中规则的渠道合并去重。这样既不会重复发,也不会漏掉某个渠道。
问:事件表会一直涨,怎么清理?
答:按时间分区,保留三十天,超期的分区直接删。事件表的作用是排错和重放,三十天足够覆盖绝大多数回溯需求。如果确实需要长期留痕,把事件内容压缩后转到冷表,主表只留编号和类型,查询性能不受影响。
问:规则配错了,发出去的通知能撤回吗?
答:站内通知可以撤回,把消息状态改成已撤回,前端不再展示;外部渠道发出去的撤不回,只能补一条更正说明。所以规则变更要先在测试环境跑一遍,用一条样例事件看匹配结果,确认收件人列表对了再启用。这一步多做一分钟,能省掉一次群里的道歉。