培训系统里“发通知”这件事,做的时候很简单,过俩月就后悔的人我见过不少。今天专门聊聊怎么把通知和消息相关的表设计得稳一点、跑得久一点、改起来也不至于推倒重来。这套设计我在模拟项目X里面完整落地过,也从上线后的各种奇奇怪怪问题里学到了一些教训,分享一下。
1. 先别急着画表,把需求分清楚再说
很多人在培训系统里做消息通知,上来就直接建一张message表:id、title、content、user_id,完事。这种表在Demo里跑得挺欢,等接入真实业务场景,马上就卡壳。原因很简单:培训系统里的“消息”根本不是同一类东西。
我习惯把培训系统的通知拆成三个维度看:业务类型、触达方式、状态流转。
业务类型决定了消息从哪来。培训系统里最常见的几种:课程开课提醒、作业被批改后的结果通知、考试安排、公告发布、学时即将过期预警、管理员强制下发的系统通知。这些消息的来源不同,有的是一对多的广播,有的是一个学员对多个学员的提醒,有的是一对一的反馈。
触达方式决定了消息的渠道。站内信要存,邮件要发,有些还要推短信或直接对接企微、钉钉这类群机器人。渠道不同,发送失败的兜底策略也不同。站内信可以慢慢发,短信和邮件就有频率限制和模板限制。
状态流转则是最容易踩坑的地方。消息不是“发出去就完了”,它还涉及“已读”“未读”“撤回”“已失效”这些状态。如果消息表设计得不够抽象,这些状态会在多个地方重复出现,改一个需求要动三张表。
所以设计第一步,是把“通知”这个模糊说法拆成模板、消息实例、接收者、发送日志这四个层面。这个分层不是凭空想出来的,是脑图式的需求梳理之后必然会得出的结论。你可以先画一张简单的表格,把产品的消息需求列出来,看哪些字段是共性的,哪些是个性化的。共性字段进主表,个性化字段进扩展表或JSON字段。
有一个小建议:在动手建表之前,把每个消息场景写成一句话,格式是“当A发生时,系统需要通知B,通知方式为C,要求D时间内已读”。比如“当课程X即将开课时,系统需要通知所有已选课学员,通知方式为站内信和邮件,要求开课前1天已读”。把这类句子写够十条,表结构基本就自己浮出来了。
2. 核心表结构怎么拆,每张表干什么用
我最终落地的是四张核心表加两张辅助表:message_template、message、message_receiver、message_send_log,外加user_device和user_notification_config。拆开说每一张表的职责和关键字段。
2.1 消息模板表:把重复的文案收拢起来
模板表解决的是“内容从哪里来”的问题。培训系统里的消息文案绝大部分是有固定格式的,比如“{课程名}将于{开课时间}开课,请准时参加”。这类文案如果散落在业务代码里,后期维护就是灾难。运营想改一个字,都得找开发重新发版。
CREATE TABLE `message_template` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `template_code` varchar(64) NOT NULL COMMENT '模板编码,业务方调用时使用', `title_template` varchar(255) NOT NULL COMMENT '标题模板,支持占位符', `content_template` text NOT NULL COMMENT '内容模板,支持占位符', `channel` varchar(32) NOT NULL DEFAULT 'station' COMMENT '默认渠道:station/email/sms/webhook', `biz_type` varchar(64) NOT NULL COMMENT '业务类型,如course_remind/homework_result', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_template_code_channel` (`template_code`, `channel`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息模板表';注意这里的唯一索引,我刻意用了template_code + channel组合。原因是同一业务消息发站内信和发邮件,文案可能不一样。站内信可以活泼一点,邮件要正式一些,短信更是有字数限制。拆成不同渠道独立的模板,运营才能灵活调整,不用互相迁就。
占位符的格式我统一用{xxx},解析的时候用正则替换。如果你用数据库存储模板,那就不要在SQL里做字符串替换,查询出来后在代码里做。SQL的字符串处理能力有限,遇到嵌套占位符容易出幺蛾子。
这里要提醒一句:模板的content_template字段千万别用varchar(255)存。通知内容虽然一般不会特别长,但作业反馈这种场景,老师可能会写一大段评语。数据库字段类型给够余量,省得后期改表。我见过好几个人在这里踩坑,上线没俩月就得跑ALTER TABLE改字段类型。
2.2 消息主表:只存“这条消息是什么”
消息主表记录的是消息本身的公共属性,不分接收者。类比一下,它就是“一封信的信封和信纸”,而“这封信发给谁、看没看”是另一回事。
CREATE TABLE `message` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `biz_type` varchar(64) NOT NULL COMMENT '业务类型', `biz_id` bigint(20) DEFAULT NULL COMMENT '业务ID,如课程ID/作业ID', `template_id` bigint(20) DEFAULT NULL COMMENT '使用的模板ID', `title` varchar(255) NOT NULL COMMENT '渲染后的标题', `content` mediumtext NOT NULL COMMENT '渲染后的内容', `message_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1系统通知 2业务通知 3私信', `priority` tinyint(4) NOT NULL DEFAULT '1' COMMENT '优先级 1低 2中 3高', `sender_id` bigint(20) DEFAULT NULL COMMENT '发送者ID,系统通知为空', `expire_at` datetime DEFAULT NULL COMMENT '过期时间', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_biz_type_biz_id` (`biz_type`, `biz_id`), KEY `idx_created_at` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息主表';我把渲染后的标题和内容直接冗余到主表里,存的是最终文本,不再存占位符。这有两个好处:一是查询列表页的时候直接拿出来就能显示,不用再解析模板;二是如果后来模板文案改了,历史消息不会被影响,保留的是发送那一刻的快照。这个“快照”思路很重要,很多消息系统做不好就是因为在“内容”和“模板”之间反复横跳。
biz_type和biz_id这组字段是给业务追溯用的。比如一条“您的作业《数据分析报告》已被批改”的站内信,点击之后要跳到作业详情页,就需要biz_id查出是哪个作业。如果表里没有这个关联,跳转就得靠自己在content里嵌链接,不仅丑还难维护。
2.3 接收者表:一个消息发N个人的关键表
这是最常见被忽略的表。很多人会问:“直接在消息表里加一个user_id不就行了吗?”不行,因为培训系统里一条消息几乎总是发给很多人。一门课程开课提醒,可能要同时发给几百个学员。如果消息主表复制几百条,内容要重复存储好多遍,而且查“这个用户的所有消息”和查“这条消息所有接收者”都会变得很别扭。
接收者表拆出来的核心作用,就是把“消息”和“接收人”解耦。
CREATE TABLE `message_receiver` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `message_id` bigint(20) NOT NULL COMMENT '消息ID', `receiver_id` bigint(20) NOT NULL COMMENT '接收者用户ID', `receiver_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1学员 2讲师 3管理员', `is_read` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否已读:0未读 1已读', `read_at` datetime DEFAULT NULL COMMENT '已读时间', `is_deleted` tinyint(4) NOT NULL DEFAULT '0' COMMENT '接收者侧删除标记', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_message_receiver` (`message_id`, `receiver_id`), KEY `idx_receiver_isread` (`receiver_id`, `is_read`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息接收者表';接收者表最核心的几个字段就是message_id、receiver_id、is_read。这三个字段撑起了整个个人中心的消息列表、未读数、全部已读、单条已读这些功能。
这里有个关键设计:is_deleted不是物理删除,而是接收者自己的删除标记。培训系统里经常有“用户自己删消息”的需求,但管理员还想留底查记录。如果你直接DELETE掉记录,消息主表里的内容还在,但关联没了,查历史就断了。加一个删除标记,列表查询过滤掉,后台管理却还能看到全量数据,两边不误。
索引方面,idx_receiver_isread这个组合索引值得展开说。个人中心最常见的查询是“我未读的消息有哪些”,条件就是receiver_id = ? AND is_read = 0,再按created_at倒序。组合索引(receiver_id, is_read, created_at)能覆盖这个查询,避免回表或全表扫描。如果你有“用户查所有消息(含已读)”的需求,(receiver_id, created_at)就能覆盖,但这里组合索引已经包含了receiver_id前缀,所以也能用得上。
2.4 发送日志表:排查问题的关键
发送日志表是很多人偷懒不做的,但我强烈建议加。它的作用是记录“我到底通过什么渠道发过什么内容、结果怎么样”。
CREATE TABLE `message_send_log` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `message_id` bigint(20) NOT NULL COMMENT '消息ID', `receiver_id` bigint(20) NOT NULL COMMENT '接收者ID', `channel` varchar(32) NOT NULL COMMENT '发送渠道', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待发送 1成功 2失败 3已重试', `provider_response` varchar(512) DEFAULT NULL COMMENT '渠道方返回信息', `retry_count` tinyint(4) NOT NULL DEFAULT '0' COMMENT '重试次数', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_message_id` (`message_id`), KEY `idx_receiver_channel_status` (`receiver_id`, `channel`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息发送日志表';为什么要有它?因为消息发送是个典型的不可靠操作。站内信写库可能失败,邮件可能被对方拒收,短信可能因为模板不合法发送失败。没有日志表,用户说“我没收到短信”,你根本无从查起。
日志表里有个细节:provider_response字段。这个我把渠道方返回的原始信息整串存进去。比如邮件发送失败,可能是邮件地址语法不正确,也可能退信,渠道方会返回错误码。整串存进去,排查的时候直接看字段,不用重新调一遍渠道方的接口。
发送日志表还承担着“异步发送状态跟踪”的功能。站内信可以同步写库,邮件短信一般要异步发。异步发的好处是主流程不卡顿,但也意味着发送结果要有个落点。日志表就是这个落点,消息中心定时扫描status=0的记录,批量交给发送队列去处理。
2.5 用户维度的辅助表
如果只是把消息发出去,收到就完了,那还不太够。用户可能会想“这个课程的开课提醒不要老发邮件”“系统通知我全都不想看”。这时就需要两张辅助表。
user_notification_config存用户对某一类消息的偏好设置:
CREATE TABLE `user_notification_config` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `biz_type` varchar(64) NOT NULL, `channel` varchar(32) NOT NULL, `enabled` tinyint(4) NOT NULL DEFAULT '1', `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_biz_channel` (`user_id`, `biz_type`, `channel`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户通知偏好配置表';user_device存用户的设备信息,也就是推送需要的token:
CREATE TABLE `user_device` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `device_type` varchar(16) NOT NULL COMMENT 'ios/android/web', `device_token` varchar(255) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '1', `last_active_at` datetime DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_device_token` (`user_id`, `device_token`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户设备表';这两张表在大型一点的项目里基本跑不掉。设备表最容易出的问题就是token过期失效。用户换了手机或删掉App,token就变了,死token还在表里躺着,推送的时候会一直失败。所以last_active_at字段很重要,每次App启动时更新一下这个时间,后续定期清理太久没活跃的设备token。
3. 核心流程怎么走通,从发消息到已读回执
表结构定了,落地的时候跑一遍核心流程,看有没有缺漏。我以一个典型场景为例:管理员给即将开课的班级群发开课提醒,学员在个人中心收到站内信,同时系统发一封邮件。
整个流程分六步。
第一步:调用方构造业务消息。业务系统传过来的是biz_type、biz_id以及模板占位符里的参数。比如biz_type="course_remind",biz_id=1024,参数是{ "courseName": "Python数据分析训练营", "startTime": "2026-03-01 09:00" }。
第二步:根据biz_type和占位符渲染内容。从message_template表查出对应的启用模板,把占位符替换成真实内容。渲染结果写入message主表,生成一条message_id。
第三步:确定接收者列表并批量写入message_receiver表。这个接收者列表可能来自课程选课表,也可能来自组织机构,调用方直接传一个用户ID数组过来就行。批量插入时要特别注意用INSERT ... ON DUPLICATE KEY UPDATE或者先查重再插,避免同一条消息重复发送给同一个人。我在培训系统里遇到过这样的bug:课程管理员修改开课时间,触发了一次新通知,但业务方粗心没去重,学员同一天收到四条一模一样的提醒。这个问题在写入前检查唯一键就能防住。
第四步:按用户偏好裁剪发送渠道。这里就是要查user_notification_config,如果某学员把“课程提醒”的邮件渠道关了,那邮件就不发,站内信还是要发。裁剪之后的渠道列表进入发送日志。
第五步:站内信即时写库,邮件短信异步投递。站内信写入message_receiver后,学员刷新页面就能看到。邮件和短信放进消息队列,由消费进程逐条发送,发送结果更新到message_send_log的状态字段。
第六步:用户点开消息,标记已读。前端拿到message_id和receiver_id,调用已读接口更新is_read=1和read_at。
这个流程走下来,你就能发现一个比较微妙的地方:站内信和其他渠道的状态不是同步的。站内信的“已读”只代表用户在系统里点开了,不意味着他看到邮件了。这个逻辑要想清楚:产品上说的“某某看到通知没”只能反问一句你指的是哪个渠道的已读。
3.1 未读数怎么算
个人中心左上角那个红点就是个很常见的需求。未读数指的是用户所有未读站内信的总和。
有些人图省事直接在查询的时候SELECT COUNT(*) FROM message_receiver WHERE receiver_id = ? AND is_read = 0,这在小数据量的场景没问题。但只要消息量上来,这个查询会越来越慢,而且每次刷新列表都要count一遍,压力不小。
我的做法是加一张用户消息计数表,或者在user表里冗余一个unread_count字段。发送一条站内信时对接收到的每个学员unread_count + 1,点开已读时-1。用事务保证计数和消息状态的原子性。
CREATE TABLE `user_message_stat` ( `user_id` bigint(20) NOT NULL, `unread_count` int(11) NOT NULL DEFAULT '0', `last_message_at` datetime DEFAULT NULL COMMENT '最近一条消息时间', PRIMARY KEY (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户消息计数表';用计数表还有个附带好处:列表页排序可以直接按last_message_at来,不需要join到消息接收者表再排序。这个字段每次插入新消息时顺手更新就行。
计数表的问题是遇到批量操作容易不一致。比如“全部标已读”,如果逐条更新message_receiver并逐个扣减计数,性能会很差。更好的做法是先UPDATE message_receiver SET is_read=1, read_at=NOW() WHERE receiver_id=? AND is_read=0,然后把影响行数拿回来,用这个行数一次性更新计数表。
3.2 过期消息和清理策略
消息表不能无限膨胀。站内信可能保留三个月或六个月就差不多了,审计要求高的场景另说。message主表里的expire_at字段,可以在消息过期后从用户侧隐藏。
清理策略我推荐用任务定时跑,处理两个动作:
- 把已过期的消息从用户查询里过滤掉,查询SQL里加
AND (expire_at IS NULL OR expire_at > NOW())。 - 物理删除超过保留期的
message_receiver记录。message主表可以保留,因为它是业务追踪的凭证。
删除时有个要注意的点:message_receiver表通常数据量很大,直接DELETE FROM message_receiver WHERE created_at < ?在千万级数据下会锁很久,影响线上写入。稳妥的做法是分批删除,比如每次删5000条,加个LIMIT,循环执行直到没有受影响的行。这种“小步慢跑”的清理方式在真实生产环境里比一把梭要靠谱得多。
4. 踩过的坑:常见问题与排查技巧
说几个我在实操中真实遇到过的典型问题。这些问题不亲身趟一遍很难提前想到,写出来希望对你有实际帮助。
4.1 消息重复发送
消息重复发送是最让人头疼的问题之一。根源通常不在表结构,而在消息发送的触发时机。培训系统里很多通知是由定时任务触发的,定时任务天然有重复执行风险,没做好幂等的话,消息就发重了。
排查方法是看message_send_log里同一个message_id对应的某渠道是否有多次记录。如果是,再往前查是哪一步重复触发。
解决思路分两层:一是靠message_receiver的唯一键uk_message_receiver挡掉站内信重复;二是在发送日志表里加一个“业务去重键”,也就是biz_type + biz_id + channel + receiver_id的唯一索引。同一个业务事件对同一个人只能发一次,重复触发就直接跳过。这个去重键比单纯依靠发送日志的message_id更防御性地覆盖了“同一条消息重复发送”的场景。
4.2 已读状态是读状态还是时间点
有人问:“我是不是只需要一个is_read,不需要read_at?”
这两个字段解决的问题不一样。is_read用来过滤未读列表和计算未读数。read_at用来做“最后阅读时间”之类的展示,以及判断用户是否在特定时间点前看过这条消息。比如培训里要统计某条考试通知在截止日期前的已读率,那就必须依赖read_at,只靠is_read没法算。
成本上多一个datetime字段几乎可以忽略,所以建议都保留。更新时间在同一条UPDATE里一起做就行了。
4.3 时间问题:已读时间到底是哪个时区
培训系统如果有跨地区的学员,时间字段的时区问题就出现了。统一的约定是数据库里存UTC时间,展示时由前端转成本地时间。read_at、created_at这些字段如果存MySQL的CURRENT_TIMESTAMP,默认用的是MySQL服务器的时区。为了避免混乱,我一般会在连接串里显式指定serverTimezone=UTC,并且应用层统一在写入前用new Date()生成时间。
配置了统一时区后,expire_at这种“过期时间”也要按UTC存,否则学员A在早上9点看到的过期时间和学员B在晚上8点看到的会不一致。产品的过期提醒需要的是绝对时间点,不是相对时长,这一点在设计接口时就要跟前端对齐。
4.4 消息内容里的用户信息变更
这是个隐蔽问题。假设一条作业批改通知里,模板是“你好,{studentName},你的作业已被批改”。发送时存储的是渲染后的完整内容。后来学员改了昵称,这条历史消息里依然显示旧昵称。这算是好事还是坏事?
从审计角度讲是好事,保留了发送瞬间的现场;从产品体验角度讲可能有点怪,明明改了昵称,消息里还叫你旧名字。
我的建议是:消息主表存渲染后的快照,模板表里的字符串可以顺带存一份原始参数JSON。这样既保留了历史现场,也给了产品侧在需要时动态调整展示的余地。message主表加一个params_json text字段能一次性解决这个问题。
4.5 通知中心撑不住高频批量写入
培训系统有个特点:消息爆发是突发式的。比如一场全平台考试,开考前一小时要发给两万名学员提醒。如果全部用单条INSERT,数据库会被瞬间打爆。
我采用的方案是批量插入,一次INSERT带多行VALUES。实测下来在MySQL 8.x里,一次插入500条比逐条插入快大概一个数量级。批量大小不是越大越好,太大会导致SQL文本过长或锁占用过久。500到1000条是一个比较稳的经验区间。
批量插入后如果某个学员记录因为唯一键冲突导致整个批次失败,那就会殃及全池。处理方式是使用INSERT IGNORE或ON DUPLICATE KEY UPDATE,保证部分冲突不至于中断整批。
4.6 软删除和已读之间的关联
如果用户删除了消息,列表里不显示,但未读数里还包含它,用户就会很困惑。所以用户删除消息时,未读数也要同步扣减。
这里有一个顺序问题:先更新is_deleted=1,再扣减计数,不能反过来。如果先扣减再标记,中间进程重启,数据就会对不上。
4.7 同一用户多设备已读的同步
学员可能在网页端已读了一条消息,但App端还显示未读。这里需要考虑的是已读是否全局同步。
两种方案:
- 方案A:按用户维度记已读,后端以
receiver_id为准,任何端标记已读后全端同步。推荐这个,因为实现简单且用户感知一致。 - 方案B:按用户+设备维度记已读,同一个用户在Web已读,App仍显示未读。不推荐,用户会觉得系统有毛病。
我的选择是方案A。is_read存的是用户对消息的已读状态,不区分设备。前端轮询或下拉刷新时拉取最新状态即可。这个产品决策最好在设计阶段就定清楚,否则后期改造成本不小。
5. 关于扩展方向的一点个人心得
前面讲的这套设计,支撑一个常规培训系统绰绰有余。但要继续往前想的话,有几个方向值得留出余地。
第一个是富媒体消息。现在的站内信如果是纯文本,学员的阅读率会比较低。培训系统完全可以给消息附一张“证书已发放”的图片或者一条语音点评。要支持这个,message主表可以加media_type和media_url字段,或者抽一张message_attachment子表。我倾向于子表,因为一条消息可能带多个附件,用逗号拼URL这种偷懒做法对前端解析不友好。
第二个是消息归档与检索。量大之后,运营人员会想检索“上个月所有发给某学员的催学通知”。这就要给消息主表加全文检索或者接入搜索服务。字段层面保证title、content、params_json都有存储,后面接Elasticsearch或别的什么引擎都不至于缺数据。
第三个是触达效果分析。发出去的通知到底有多少人看了?这个数据对培训运营特别有用。有了read_at和message_receiver就能统计某条消息的已读率、平均响应时间。再加一个first_read_time和last_read_time,就能分析学员使用习惯。这些字段现在不加,等需求来的时候再加个字段也不难,但前提是你没有把表结构设计得乱七八糟。
我个人踩过最深的一个坑,是一开始把消息表和接收者表混在一起,结果每个学员收到课程通知就会在表里增加一条完整记录,不仅数据量飞速膨胀,而且想批量改通知内容时发现每条都各干各的。后来拆了接收者表,同一时刻几百个学生的记录才终于共用一条消息内容,这个改动让我彻底理解了为什么关系型数据库设计要把“实体”和“关系”分开。
如果你现在正在重新设计培训系统的消息模块,建议照着“模板—主消息—接收者—日志”这四层结构去搭,基本不会出大错。真正到了复杂业务再说扩展的事也不迟。