☰
培训系统通知消息表设计:主表+明细表实战方案
2026/10/17 12:41:57 网站建设 项目流程

1. 培训系统里的通知和消息,到底有哪几类

前阵子给某企业做内部培训系统,被问到最多的一个设计问题不是课程表也不是考试表,而是“通知和消息的表怎么设计”。表面上看这模块很简单:发一条消息,用户列表里看到未读数,点进去已读。但真正动手做的时候,你会发现它比想象中复杂得多——消息类型五花八门、接收人范围千奇百怪、已读和撤回状态要互相隔离、列表查询还牵扯未读数统计,稍不注意就设计出一张“改不动也查不动”的表。

所以这篇就把我在实际项目里沉淀出来的一套通知消息表设计方案完整拆开讲:从消息分类、主表+明细表结构、字段的设计理由,到列表查询、未读统计、批量发送、定时提醒、撤回清理这些高频场景的落地写法,最后附上我踩过的坑和优化经验。适合正在做培训系统、企业内训平台、在线学习系统这类项目的后端开发参考,也适合刚接触消息模块、想搞懂“为什么这么建表”的读者。

先说一个核心判断:设计通知消息表之前,一定要先把消息分类想清楚,因为不同消息类型的接收人、时效、状态要求完全不一样,直接决定你要不要做“明细表”、要不要做“定时字段”、要不要做“撤回逻辑”。

1.1 六种最常见的培训消息类型

我梳理过手上几个培训项目的业务需求,消息类型基本逃不出下面这六类。

消息类型典型场景接收人时效性是否需要已读
系统公告平台升级、培训制度调整全部学员/讲师长期有效通常不需要
课程通知新课上架、课时调整、班级变动选课学员、指定班级短期需要
作业提醒作业布置、截止前催交课程学员短期需要
考试通知考试安排、成绩发布、补考提醒报名学员短期必须确认
报名/审批结果报名成功、审批驳回、证书发放个人即时需要
站内消息讲师答疑、学员私信沟通个人之间即时需要

这六类消息放在同一个“消息中心”里展示,但它们的接收人范围、生命周期、状态流转差别很大。系统公告是广播,所有人都要看到,看完不需要确认;考试通知不仅是通知,还带“已读确认”的业务含义;站内消息是一对一的实时沟通,和课程业务几乎没关系。

如果你不先做这个分类,直接开一张“消息表”,什么都往里塞,后面基本上会陷入无休止的加字段状态。

1.2 从业务形态抽象出两个核心模型

把所有类型归拢一下,从数据结构视角看只有两种形态:

广播型消息:一条消息对应全体用户或一大批用户,大家看到的是同一份内容。系统公告、课程通知都算这类。它的核心特点是内容单一、接收人集合大、是否已读往往不是硬性要求。

定向型消息:一条消息只发给某个具体用户,内容甚至可以为不同人定制。报名审批结果、站内私信都属于这类。它的核心特点是接收人就一个或极少数,但状态特别重要——已读、未读、删除、撤回都要精确到人。

培训系统的特殊之处在于:它经常同时存在这两种形态,而且广播型消息里又衍生出“需要确认已读”的类型(考试须知、合规培训通知)。所以我最终没有走“广播消息单独一张表、定向消息单独一张表”的分离方案,而是用“一张主表存消息本身+一张明细表存每个接收人的接收状态”统一覆盖,靠类型字段和范围字段区分行为。这个方案是接下来要详细展开的重点。

2. 表结构核心方案:一张主表加一张明细表

主题思想用一句话概括:消息内容放主表,谁收到了、谁读没读、谁删了,全部放明细表。两张表通过 notice_id 关联,形成一对多关系。

2.1 先看最终表结构

以 MySQL 8.0 为例,两张表的核心设计如下。

-- 消息主表:一条记录代表一条通知或消息 CREATE TABLE `notice_message` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `msg_type` TINYINT UNSIGNED NOT NULL COMMENT '消息类型 1系统公告 2课程通知 3作业提醒 4考试通知 5审批结果 6站内消息', `title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题', `content` TEXT COMMENT '正文内容', `biz_type` TINYINT UNSIGNED DEFAULT NULL COMMENT '关联业务类型 1课程 2作业 3考试 4审批', `biz_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联业务ID,前端点击跳转用', `priority` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '优先级 0普通 1紧急', `scope_type` TINYINT UNSIGNED NOT NULL COMMENT '接收范围 1全体 2指定用户 3指定角色 4指定部门', `publisher_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '发布人用户ID,系统消息为0', `status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '主表状态 0草稿 1已发布 2已撤回 3已删除', `send_time` DATETIME DEFAULT NULL COMMENT '实际发送时间', `scheduled_send_time` DATETIME DEFAULT NULL COMMENT '定时发送时间,空为立即发送', `expire_time` DATETIME DEFAULT NULL COMMENT '过期时间,过期后前端自动置灰', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_send_time` (`status`, `send_time`), KEY `idx_scheduled_send_time` (`scheduled_send_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知消息主表'; -- 消息接收人明细表:一个用户一条记录 CREATE TABLE `notice_receiver` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `notice_id` BIGINT UNSIGNED NOT NULL COMMENT '消息ID,关联主表', `receiver_id` BIGINT UNSIGNED NOT NULL COMMENT '接收人用户ID', `receiver_type` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '接收人来源 0直接指定 1角色展开 2部门展开', `status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '接收状态 0未读 1已读 2用户删除 3系统撤回', `read_time` DATETIME DEFAULT NULL COMMENT '阅读时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_notice_receiver` (`notice_id`, `receiver_id`), KEY `idx_receiver_status` (`receiver_id`, `status`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息接收人明细表';

2.2 为什么主表和明细表必须分开

这是整个设计里最关键的一个决定。有些同事第一次设计时想省事,在消息主表里加一个 receivers 字段,把接收人的 ID 列表用逗号或 JSON 拼成一个字符串存进去。表面看一条消息一行数据,发布也简单,但用起来全是坑。

先说已读状态这个核心问题。一次考试通知发给 500 个学员,其中 380 个已读、120 个未读,这些状态存哪里?如果你把接收人放在主表的一个字符串字段里,已读状态就只能再开另一个字符串字段存“已读的ID集合”,每有人已读就要把整个字符串读出来、改一遍、再写回去,并发情况下直接互相覆盖。更别说“某个用户点进来要看自己有没有读”“撤回时要把 500 个人里的 120 个未读标为已撤回”这类需求,根本没法用 SQL 高效表达。

拆成明细表之后,所有问题都变成了一对一的行操作:

  • 一条考试通知发布时,往明细表插入 500 行,每行对应一个学员。
  • 学员A已读时,只需更新 receiver_id = A 的那一行。
  • 未读统计就是SELECT COUNT(*) FROM notice_receiver WHERE notice_id = ? AND status = 0,走索引即可。
  • 给某个用户展示消息列表时,用 receiver_id 查明细表关联主表,天然支持分页。
  • 系统撤回时可以把所有未读明细更新为已撤回,用户自己删除时只改自己的那一行,两者互不干扰。

一句话总结:明细表解决的是“同一条消息在不同用户视角下的个性化状态”问题,这是消息系统的本质需求,绕不开。

2.3 三个千万不能踩的建模误区

除了“字符串存接收人”,我再列举三个常见坑。

第一个误区:把消息按接收人复制多行,直接在明细表里存冗余的标题和内容。这样查询确实简单,但撤回一条消息时要把几千行主表字段全部改一遍,而且内容占空间极大,索引失效不说,存储成本也白白翻倍。主表存内容,明细表只存状态,这个边界一定要守住。

第二个误区:把已读状态放主表,用整数位运算去记录“第N个人已读”。如果用户总量是固定的几十人,勉强能用,但培训系统的学员数随时在变,位图一旦扩容,旧数据的含义全乱。这种方案只适合极小规模且用户集合固定不变的场景,实际项目里没见过几个人能维护好。

第三个误区:接收范围设计成穷举用户列表,却忽略角色和部门。培训系统里“课程通知发给所有选课学员”“公告发给某部门所有员工”这类需求特别多。如果发布时不能从角色、部门直接推导出接收人集合,每次都要业务方手工勾选几百人,数据正确性完全无法保证。所以我在主表里专门放了 scope_type,发布时按范围类型自动展开,这在后面发布流程里会细讲。

3. 字段设计逐项拆解:类型、范围、状态、业务跳转

表结构看起来简单,但每个字段的取舍背后都有业务逻辑。这一节把几个容易理解错的字段单独拿出来讲。

3.1 消息类型、范围和优先级的枚举设计

msg_type 不能只图省事用字符串“notice”和“message”区分,因为培训系统里消息的展示样式和业务处理完全不同。我建议用 TINYINT 存枚举,1系统公告、2课程通知、3作业提醒、4考试通知、5审批结果、6站内消息。前端拿到这个数字自己去匹配图标、跳转链接和配色,后端则根据它决定发送逻辑:考试通知必须支持已读确认,作业提醒要支持截止时间展示,站内消息不需要走批量发送。

scope_type 的取值建议:1全体用户、2指定用户、3指定角色、4指定部门。它存在的意义是发布消息时不需要由调用方整理一份巨大的用户ID列表,而是告诉我“发给谁”的规则,由系统内部去展开明细。

priority 字段很多人觉得可有可无,实际它有两个用途:一是在消息列表里控制排序,紧急消息置顶;二是对接推送渠道时决定是否要走短信、App Push 等强提醒。培训场景里“考试明天开始”“作业今晚截止”这类消息设成紧急,体验会好很多。

3.2 状态机与软删除设计

主表 status 和明细表 status 是两个不同维度的状态,一定要区分开。

主表状态针对消息本身:0草稿、1已发布、2已撤回、3已删除。草稿支持编辑,发布后不可再改内容,只能撤回或删除。明细表状态针对“这条消息在这个用户视角下的状态”:0未读、1已读、2用户删除、3系统撤回。

为什么用户删除和系统撤回要分开?举个例子:系统发了一条课程取消通知,学员A看了一眼然后删掉了,学员B一直没读。这时如果系统要撤回这条消息,A的视角里消息已经删除,不应该再出现一条“已撤回”的消息;B的视角里应该从“未读”变成“已撤回”,前端展示“消息已被撤回”。如果两个状态混用一个字段,系统撤回时会误伤已经删除的用户记录,用户会再次看到自己删掉的消息复活,体验非常糟糕。

所有状态变更都走软更新,不物理删除。原因一是消息中心需要留痕,二是用户删除后如果系统误撤回,还有机会恢复。只有在过期时间过长、数据量确实影响性能时才做物理清理,后面专门讲。

3.3 业务关联字段:biz_type 和 biz_id 为什么必须有

这是被很多人忽略但实际价值最高的两个字段。培训系统里的消息几乎都跟业务绑定:课程通知要跳到课程详情页,作业提醒要跳到提交作业页,考试通知要跳到考试入口,审批结果要跳到审批详情页。如果表里只存标题和正文,前端收到消息之后一脸懵,不知道点了该去哪儿。

有了 biz_type 和 biz_id,前后端就能定一个简单约定:前端根据 biz_type 决定跳转路由,用 biz_id 拼出具体地址,后端不需要为每个消息类型单独写一套查询接口。比如课程通知的 biz_type = 1,biz_id = 3021,前端直接跳转/course/3021;考试通知 biz_type = 4,biz_id = 88,跳转/exam/88。发送方发送前把这两字段填好,整个消息中心就具备了统一交互能力。

4. 高频业务场景的SQL实现:列表、未读、全部已读

表设计得再好,最终也要落到应用层能写 SQL。我把消息中心最高频的四个场景的完整做法列出来,全是可直接抄的。

4.1 消息列表分页查询

用户进入消息中心,最核心的查询是“我收到的所有未删除、未被撤回的消息,按时间倒序分页”。

SELECT m.id, m.msg_type, m.title, m.content, m.biz_type, m.biz_id, r.status AS read_status, r.read_time FROM notice_receiver r INNER JOIN notice_message m ON m.id = r.notice_id WHERE r.receiver_id = 1001 AND r.status IN (0, 1) AND m.status = 1 ORDER BY m.id DESC LIMIT 20 OFFSET 0;

关键在于联合索引的利用。where 条件里首先用receiver_id = ?过滤当前用户的消息,走idx_receiver_status的前缀。status IN (0,1)用来排除用户已删除和系统撤回的明细。主表再叠加m.status = 1过滤掉整体被撤回的消息,双保险。

这里有个性能细节:排序我用的是m.id DESC,等价于按发布时间倒序(id 自增,时间越晚 id 越大),比用m.send_time DESC更稳定,因为 send_time 可能出现同一秒多条消息,排序不稳定,而 id 天然唯一。如果用户消息量非常大,可以先按r.id DESC分页拿到 notice_id 列表,再回表关联主表,减少回表次数。

4.2 未读数统计与红点展示

未读数通常是首页角标的来源,查询非常轻量。

SELECT COUNT(*) FROM notice_receiver WHERE receiver_id = 1001 AND status = 0;

这个查询走idx_receiver_status索引,即便明细表有几百万行,只要索引命中,单次统计都在毫秒级。注意不要把未读数的“累计值”存到用户表里,因为每次已读都要同步更新用户表,跨表事务很难处理一致性问题。如果对性能要求更高,可以在应用层引入缓存:Redis 里存每个用户的未读数,已读操作时更新缓存,后台定时任务把缓存定期刷回数据库。但培训系统的用户量一般不会到千万级别,纯靠上面的 SQL 完全够用,没必要一开始就上缓存。

4.3 已读、全部已读和用户删除的实现

单条已读:

UPDATE notice_receiver SET status = 1, read_time = NOW() WHERE notice_id = 10086 AND receiver_id = 1001 AND status = 0;

注意更新条件里带上status = 0,可以保证幂等,避免重复已读时把 read_time 覆盖成更晚的时间。实际影响行数可以作为判断“是否真的第一次读”的依据,这在做学分统计、考试须知确认时很有用。

全部已读:

UPDATE notice_receiver SET status = 1, read_time = NOW() WHERE receiver_id = 1001 AND status = 0;

很多系统会额外加一张“最后已读游标表”来优化全部已读,也就是记录每个用户最后已读到的消息ID,查询时拿游标过滤。但明细表方案下,全已读就是一条 UPDATE,没必要引入游标这种额外复杂度。只有当你把“全体广播消息”改成不展开明细表、直接实时计算接收人的方案时,才需要考虑游标,具体取舍在下一节说。

用户删除单条消息:

UPDATE notice_receiver SET status = 2 WHERE notice_id = 10086 AND receiver_id = 1001;

只改明细,不动主表,这样系统撤回时才能区分“用户自己删了”和“系统撤回”,这个前面已经强调过。

“没有明细表”的替代方案也要提一句:如果你只做全体广播公告,且用户规模特别大、不要求精确已读统计,可以把明细表去掉,在主表加一个last_read_message_id字段存每个用户最后已读的消息ID。列表查询时拿这个 ID 做游标,未读数就用“最新消息ID减去已读游标”来估算。但这个方案做不了定向消息、做不了撤回、做不了按用户删除,所以只适合非常窄的场景。培训系统千万别上来就选这条“极简路线”。

5. 消息发布与流转:从发送到撤回的完整链路

表结构只是静态设计,消息从创建到进入用户列表,中间还有一段完整的流转链路。这一章把流程理顺。

5.1 发布流程:范围展开、批量插入、事务边界

发布一条消息的推荐流程如下:

第一步,插入主表,状态为 0 草稿,拿到自增主键 id。此时不着急展开明细,因为后面可能还要编辑。

第二步,确定接收人集合。根据 scope_type 走分支:

  • 全体用户:查用户表SELECT id FROM sys_user WHERE status = 1。
  • 指定用户:直接用调用方传入的用户ID列表。
  • 指定角色:查角色关联用户表SELECT user_id FROM sys_user_role WHERE role_id = ?。
  • 指定部门:查部门关联用户表SELECT user_id FROM sys_user_dept WHERE dept_id = ?。

第三步,组装批量明细数据,每个用户一行,receiver_type 记录用户来源,分批插入 notice_receiver 表。

第四步,更新主表状态为 1 已发布,填补 send_time。

存放内容用 TEXT 类型,但要特别注意 batch insert 的大小。我实际测试过:一次性往明细表插 5000 行,单条 INSERT 语句可能超过 MySQL 默认的 max_allowed_packet(通常是 4MB 或 64MB)而直接报错。更稳妥的做法是每批 500 到 1000 行,分批提交。如果你用的是 MyBatis,可以用 ExecutorType.BATCH 配合手动 flush;用 JDBC 就直接循环执行 addBatch。

关于事务边界,我的建议是:插入主表和展开明细可以放一个事务里,保证“不会有发出去的消息没有接收人”。但如果接收人数量特别大,事务时间会拉长,可以考虑先插入主表状态为草稿,再异步展开明细,最后再更新主表状态为已发布。异步链路需要引入消息队列或本地任务表,复杂度明显高一些。培训系统一般几千到几万用户,同步事务就能搞定,不建议过度设计。

5.2 定时通知的实现

培训系统的消息经常要定时发,比如课程开始前 15 分钟提醒、考试前 1 天提醒。实现思路很简单:主表里设置 scheduled_send_time,发布时状态直接设为 1(表示已确认发送),但 send_time 留空。

后台起一个定时任务,每分钟扫描一次:

SELECT id FROM notice_message WHERE status = 1 AND scheduled_send_time IS NOT NULL AND scheduled_send_time <= NOW() AND send_time IS NULL;

为了避免任务重跑导致重复发送,更新时要做原子保护:

UPDATE notice_message SET send_time = NOW() WHERE id = ? AND send_time IS NULL;

如果影响行数为 1,说明本次由当前任务抢到了发送权,继续发通知;影响行数为 0,说明已经被其他实例处理过,直接跳过。这就是幂等控制,不加这个保护,定时任务一旦因为数据库抖动重试,用户就会收到两条一模一样的提醒。

5.3 撤回、失效与历史数据清理

撤回消息分两步。第一步更新主表:UPDATE notice_message SET status = 2 WHERE id = ?。第二步更新明细表:把未读和已读的记录标记为已撤回,已删除的保持不动。

UPDATE notice_receiver SET status = 3 WHERE notice_id = ? AND status IN (0, 1);

消息在用户端的展示规则是:明细 status = 0 显示为未读,status = 1 显示为已读,status = 2 不显示,status = 3 显示“消息已撤回”。主表 status = 2 时,即使明细还是 0,也要过滤掉,避免撤回后仍出现在未读列表里。

长期运营后,过期消息需要清理。主表有 expire_time,每天凌晨跑一次清理任务:物理删除过期的 notice_receiver 明细行,主表置为已删除状态。也可以做成逻辑过期——查询时加过滤条件,让过期消息自动置灰但不消失。两者方案看业务需要,我建议至少保留 3-6 个月的逻辑展示,避免用户看到“消息数量在减少”的疑惑。

6. 我在实际项目中踩过的坑和优化建议

最后分享几个真实踩过的坑,都是文档里不会写但迟早会遇到的。

6.1 没建明细表,只靠 JSON 字段,结果查询全崩

我第一次给内部工具做消息模块时,也偷懒过:主表加两个 TEXT 字段,一个存接收人 ID 的 JSON 数组,一个存已读状态。开发阶段数据量小,一切正常。等正式上线两周后,运营发了一条全员公告,用户一多,每次“未读人数统计”都要扫描所有 JSON 字段,用不了就回表解析。后来补了一张接收人明细表,把存量数据重新展开迁移,才彻底解决问题。那次的教训是:消息模块从第一版就按一对多设计,不要贪图“一条消息一行记录”的简洁。

6.2 批量插入和索引设计上的教训

批量插入的坑前面提过,5000 行一条 SQL 直接失败,改成 500 行一批就稳了。但还有一个隐藏问题:明细表如果同时给 notice_id 和 receiver_id 都建了单列索引,写入时两个索引都要更新,写放大很严重。实际业务查询只有两类:按 notice_id 查某条消息的所有接收人,按 receiver_id 查某个用户的所有消息。所以建两个索引就够了,一个是联合唯一索引 (notice_id, receiver_id),一个是查询索引 (receiver_id, status, id)。我最初加过三个索引,性能反而更差,后来才明白索引不是越多越好。

6.3 待办事项和通知消息不要混在一张表

培训系统里“待办”和“通知”很容易被混为一谈。比如审批结果通知和待办审批是两回事:通知只要让用户知道结果;待办要求用户去进行处理操作,它有独立的状态流转,比如“待处理、已处理、已过期”。如果把待办塞进消息表,就用明细表 status 字段硬承载了业务状态,等需要按处理状态统计工作时长的时候,数据会非常别扭。我的建议是待办独立建表,消息表只负责通知和已读确认。如果一定要在消息中心统一展示待办,就做一个视图层合并,底层两张表各管各的。

6.4 定时提醒任务一定要做幂等

有一次晚上改了定时任务的触发频率,从一小时一次改成五分钟一次,结果第二天学员收到了四遍“作业即将截止”的重复提醒。原因就是任务重跑时没有做任何幂等控制,每次扫描都把符合条件的数据重新发了一遍。后来又出现过一次数据库抖动导致任务重试,但因为加了send_time IS NULL的条件,没有再重复发送。这个教训让我把“所有会反复执行的定时任务都必须做成幂等”当成了一条硬性规范,消息表里的 send_time 字段当天然锁用,效果很好。

最后再讲一个实用技巧:消息列表的分页查询,如果发现回表查询太慢,可以先把分页缩小到明细表上,只拿 id 和 notice_id,再去关联主表取标题和正文,最后用 IN 查询补详情。这样第一页 20 条消息最多回表 20 次,数据库压力完全可控。消息表设计这件事,说到底就是把“内容”和“接收状态”拆得足够干净,再用索引把两类查询都喂饱,后面所有业务功能都会变得很顺手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询