thinkphp6未读消息计数需db+redis双写保障一致性:消息表必含to_user_id、type(tinyint)、status(not null default 0)、created_at,并建联合索引(to_user_id, status, created_at);新消息事务写db后incr redis,列表页幂等更新db再set redis归零,轮询优先读redis并fallback兜底。

ThinkPHP6 实现未读消息计数,核心不是“查一次数据库就返回个数字”,而是要兼顾性能、一致性与多端同步。关键在于避免每次轮询都扫表,同时防止 Redis 和 DB 状态错位。
数据库结构必须规范
消息表不能只存 content 和 user_id。必须包含以下字段并设好约束:
- to_user_id:NOT NULL,接收者 ID,是查询未读的主维度
- from_user_id:可为 NULL 或 0(系统消息用 0)
- type:TINYINT,如 1=系统通知、2=私信、3=评论回复,不用字符串,索引更高效
- status:TINYINT NOT NULL DEFAULT 0,0=未读、1=已读、2=已删除;严禁用 NULL 或布尔类型
- created_at:DATETIME DEFAULT CURRENT_TIMESTAMP,否则新消息可能排在最底下
- 联合索引 (to_user_id, status, created_at):这是查未读列表和统计 COUNT 的命脉,缺了它,1000 条消息就卡顿
未读数缓存必须双写+兜底
直接 SELECT COUNT(*) WHERE to_user_id = ? AND status = 0 在高并发下不可行。正确做法是 Redis + DB 双写,并严格控制时序:
- 新消息入库时,在事务中先成功插入 DB,再执行 INCR redis_key:unread:{user_id}
- 用户打开消息列表页时,先执行 UPDATE message SET status = 1 WHERE to_user_id = ? AND status = 0(幂等操作),再查 Redis 当前值,最后 SET redis_key:unread:{user_id} 0
- 轮询接口(如 /message/unread)优先读 Redis;若为空或超时,fallback 到 DB 查询,并把结果 SET 回 Redis
- 禁用 DECR 或 DEL 操作未读 key——值必须由 INCR/SET 显式控制,避免出现负数
接口设计与调用逻辑要防抖
前端频繁点击“全部已读”或快速切换页面,容易触发重复请求。后端需做基础防护:
- 标记已读接口必须支持幂等:WHERE status = 0 且 LIMIT 1 或加版本号/时间戳校验
- 前端避免无节制轮询,5 秒间隔已是较激进策略;可结合 SSE 或长连接在有新消息时主动推
- TP6 中推荐用中间件统一拦截未读数请求,自动注入缓存逻辑,避免每个控制器重复写
多终端登录时状态要同步
用户在手机、PC、小程序三端同时在线,一处标记已读,其他端小红点必须实时消失。这需要:
- DB 更新后,通过 Redis Pub/Sub 或消息队列广播“{user_id} 未读数已清零”事件
- 各端 WebSocket 或 SSE 连接监听该事件,收到后本地刷新未读数缓存
- 若使用第三方 IM SDK(如腾讯云 TIM),直接调用其 清除会话未读数 接口,再同步更新业务侧状态











