
本文介绍一种基于关系型数据库的轻量级通知系统设计,通过分离“通知模板”与“用户状态”两张表,避免消息重复存储,支持批量发送、已读标记及高并发写入。
本文介绍一种基于关系型数据库的轻量级通知系统设计,通过分离“通知模板”与“用户状态”两张表,避免消息重复存储,支持批量发送、已读标记及高并发写入。
在构建多用户 Web 应用时,管理员需向全体(或部分)用户推送统一通知(如系统公告、活动提醒),同时允许每位用户独立标记为“已读”。若为每位用户单独插入一条完整通知记录,不仅造成大量冗余数据(N 用户 × 1 消息 = N 条重复内容),还会显著增加存储开销、索引体积和查询延迟。正确的解法是采用范式化设计:将“消息内容”与“用户交互状态”解耦,分别存于两个逻辑清晰、职责分明的数据表中。
✅ 推荐数据库结构设计
-- 表1:notifications —— 存储唯一通知模板(不重复) CREATE TABLE `notifications` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(255) NOT NULL, `content` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_active` TINYINT(1) DEFAULT 1 -- 支持软下线旧通知 ); -- 表2:user_notifications —— 记录用户与通知的关联状态 CREATE TABLE `user_notifications` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `notification_id` INT NOT NULL, `user_id` INT NOT NULL, `is_read` TINYINT(1) DEFAULT 0, `read_at` DATETIME NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, -- 复合唯一索引:防止同一用户重复关联同一条通知 UNIQUE KEY `ux_user_notif` (`user_id`, `notification_id`), INDEX `ix_notif_user` (`notification_id`, `user_id`), FOREIGN KEY (`notification_id`) REFERENCES `notifications`(`id`) ON DELETE CASCADE, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE );
? 关键设计说明:
- notifications 表仅保存一次消息原文,无论后续推送给多少用户;
- user_notifications 表按需为每个用户生成一行轻量记录(仅含 ID、状态、时间戳),空间占用极小;
- UNIQUE KEY (user_id, notification_id) 是核心约束,确保不会因重复执行推送而产生脏数据;
- 使用 ON DELETE CASCADE 保障主通知删除时自动清理所有关联状态,保持数据一致性。
? 批量推送通知的正确 SQL 实现(以 MySQL 为例)
假设需向全部活跃用户(users 表中 status = 'active')推送一条新通知:
-- 步骤1:插入通知模板(返回生成的 notification_id)
INSERT INTO `notifications` (`title`, `content`)
VALUES ('系统升级公告', '平台将于本周六凌晨2:00-4:00进行维护...');
SET @new_notif_id = LAST_INSERT_ID();
-- 步骤2:批量关联所有目标用户(原子性操作,推荐在事务中执行)
START TRANSACTION;
INSERT INTO `user_notifications` (`notification_id`, `user_id`)
SELECT @new_notif_id, u.id
FROM `users` u
WHERE u.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM `user_notifications` un
WHERE un.user_id = u.id AND un.notification_id = @new_notif_id
);
COMMIT;
✅ 该方案优势:
- 零冗余:消息正文只存一份;
- 可扩展:支持百万级用户,user_notifications 表可水平分片或按 user_id 哈希分区;
- 高性能读:用户查看未读数 → SELECT COUNT(*) FROM user_notifications WHERE user_id = ? AND is_read = 0;
- 事务安全:START TRANSACTION ... COMMIT 确保推送过程原子完成,避免部分成功导致状态不一致;
- 无锁担忧:现代 InnoDB 在合理索引下对 INSERT ... SELECT 并发写入表现优异,无需手动加表锁。
⚠️ 注意事项与最佳实践
- 避免全表扫描:务必为 user_notifications(user_id, is_read) 和 (notification_id, user_id) 建立覆盖索引;
-
标记已读应使用 UPDATE 而非 INSERT:用户点击“标记为已读”时执行:
UPDATE user_notifications SET is_read = 1, read_at = NOW() WHERE user_id = ? AND notification_id = ? AND is_read = 0;
- 定期归档:对 is_read = 1 且 read_at
- 前端优化建议:结合 Redis 缓存用户未读数(INCRBY user:123:unread 1),减轻数据库压力。
此设计已在多个中大型 Laravel/ThinkPHP 项目中稳定运行,单日千万级通知分发下数据库负载平稳。核心思想始终如一:内容与状态分离,写时范式化,读时高效聚合。










