站内信功能必须包含status和created_at字段并设默认值,否则已读/未读不准、新消息排序错乱、分页异常、红点统计崩溃;最小可用字段集为id、sender_id、receiver_id、subject、body、status、created_at,并需添加外键、索引及type字段区分消息类型。

不加 status 和 created_at DEFAULT CURRENT_TIMESTAMP,站内信功能上线即残废——已读/未读不准、新消息排在最底下、分页错乱、红点统计崩溃,全是必然结果。
messages 表必须包含的字段和约束
只建 id、sender_id、receiver_id、subject、body 这五个字段,等于没建。真实可用的最小集合是:
-
id:主键,AUTO_INCREMENT -
sender_id和receiver_id:都设为INT NOT NULL;若要支持系统公告(sender_id = 0)或群发(receiver_id IS NULL),则后者不能加NOT NULL -
status:必须TINYINT NOT NULL DEFAULT 0,0=未读,1=已读,2=删除(如需逻辑删除);不设DEFAULT 0,新插入消息status为NULL,WHERE status = 0就查不到 -
created_at:必须TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;只写DATETIME不设默认值,MySQL 5.7+ 会报错,5.6 会填0000-00-00 00:00:00,排序时新消息永远沉底 - 外键建议加上:
FOREIGN KEY (sender_id) REFERENCES users(id),但前提是引擎为 InnoDB;MyISAM 不支持外键,强行加会报错 - 索引至少加两个:
INDEX idx_receiver_status (receiver_id, status)(收件箱查询)、INDEX idx_sender_created (sender_id, created_at)(发件箱排序)
为什么不能用 OR receiver_id IS NULL 混合查私信和公告
常见错误写法:WHERE receiver_id = ? OR receiver_id IS NULL。表面看能一次拉出私信+系统公告,实际埋了三颗雷:
- 无法区分消息类型,前端渲染全靠
receiver_id === null判定,一旦未来加“用户组公告”,逻辑立刻崩 - 权限控制无从下手——比如管理员才能看某类公告,但
OR条件让 WHERE 失去过滤能力,只能靠 PHP 层硬筛,DB 层压力白扛 - 索引失效:
receiver_id = ?走索引,receiver_id IS NULL在多数 MySQL 版本中不走同一索引分支,执行计划常退化为全表扫描 - 正确做法是加
type字段:TINYINT NOT NULL DEFAULT 1 COMMENT '1=私信,2=系统公告,3=用户组',查私信用WHERE receiver_id = ? AND type = 1,查公告用WHERE type = 2
标记已读时最容易被忽略的并发与缓存问题
只执行 UPDATE messages SET status = 1 WHERE id = ? AND receiver_id = ? 是危险的起点。问题不在 SQL 本身,而在它引发的连锁反应:
立即学习“PHP免费学习笔记(深入)”;
- 首页小红点如果每次靠
SELECT COUNT(*) FROM messages WHERE receiver_id = ? AND status = 0实时算,用户量过万就明显卡顿 - 多个标签页同时打开同一条消息,可能触发两次
UPDATE,但缓存(如 Redis 的user:123:unread)若没用DECR+EXPIRE原子操作,会扣成负数 - “全部标为已读”不能写成
UPDATE ... SET status = 1 WHERE receiver_id = ?,得加AND status = 0,否则重复点击会把已读消息反复更新,浪费 I/O 且干扰 binlog - 更稳妥的做法:更新 DB 后,用 Redis pipeline 同步执行
DECR user:123:unread和EXPIRE user:123:unread 86400,避免缓存与 DB 状态长期不一致
发信时 sender_id 绝对不能信任客户端传入
哪怕你前端用隐藏域写了 <input type="hidden" name="sender_id" value="<?php echo $_SESSION['id']; ?>">,后端也必须无视它:
- HTTP 请求可任意篡改,攻击者把
sender_id改成 1(管理员 ID),就能伪造系统通知 - 正确做法:控制器里直接取会话值,
$data['sender_id'] = $_SESSION['user_id'] ?? null;,然后校验非空且为数字;删掉所有接收$_POST['sender_id']的逻辑 - 若需支持“代发”(如客服代用户提交反馈),必须走独立权限校验流程,而非开放
sender_id参数 - 插入前必须检查接收者存在:
SELECT id FROM users WHERE id = ?,不存在则拒收,避免无效消息堆积污染收件箱
真正难的不是建表或写 SQL,而是每一步都要预判“当 1000 个用户同时点开消息时,哪条语句最先慢下来”——status 缺默认值、created_at 没自动时间戳、缓存没原子操作,这些都不是上线后能“慢慢优化”的点,是压根跑不起来的硬伤。



















