离线消息表结构设计需避开大文本字段和冗余索引:content用mediumtext,仅对receiver_id+status建联合索引,created_at单独建索引,删除非必要索引;采用覆盖索引(含id、content等)避免回表,用分区表+drop partition替代delete清理已读消息,写入时批量insert并禁用autocommit。

离线消息表结构设计要避开大文本字段和冗余索引
聊天系统里离线消息最怕“一查就卡”,根源常在表结构。别把 content 字段设成 TEXT 还加全文索引——它不参与 WHERE 条件时纯属拖累,且 InnoDB 对大字段会外存溢出页,反而增加随机 IO。
推荐方案:content 用 MEDIUMTEXT(上限 16MB,够用),仅对高频查询字段建索引:
-
receiver_id+status(如未读/已读)必须联合索引,拉取未读消息时能跳过全表扫描 -
created_at单独建索引,用于按时间倒序分页(但避免ORDER BY created_at DESC LIMIT 100,20这种深分页) - 删掉所有非必要索引,比如
sender_id单独索引——除非你真有“查某人发的所有离线消息”这种需求
拉取未读消息必须用覆盖索引+LIMIT,不能依赖OFFSET
用户上线时批量拉取未读消息,常见写法是 SELECT * FROM offline_msg WHERE receiver_id = ? AND status = 'unread' ORDER BY created_at DESC LIMIT 50。问题在于:如果没走覆盖索引,InnoDB 要回表查 content,每行都触发一次磁盘 IO。
改成覆盖索引查询,让所有字段从索引中直接拿到:
ALTER TABLE offline_msg ADD INDEX idx_receiver_status_time (receiver_id, status, created_at DESC, id, content);
注意:content 放在索引末尾是允许的(MySQL 8.0+ 支持索引包含列),这样 SELECT id, content, created_at FROM ... 就完全不用回表。同时强制用 LIMIT 控制单次拉取量,别用 OFFSET ——离线消息表一旦积累百万级,LIMIT 10000, 50 会扫完前一万行才取后50条,CPU 和 IO 都扛不住。
删除已读消息别用DELETE,用分区表+DROP PARTITION
离线消息读完就该清理,但 DELETE FROM offline_msg WHERE receiver_id = ? AND status = 'read' 在大数据量下会锁表、打满 undo log,还产生碎片。更糟的是,如果没加 receiver_id 索引,就是全表扫描删。
正确做法是按天分区:
ALTER TABLE offline_msg PARTITION BY RANGE (TO_DAYS(created_at)) (
PARTITION p20260920 VALUES LESS THAN (TO_DAYS('2026-09-21')),
PARTITION p20260921 VALUES LESS THAN (TO_DAYS('2026-09-22')),
PARTITION p20260922 VALUES LESS THAN (TO_DAYS('2026-09-23')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
每天凌晨执行:ALTER TABLE offline_msg DROP PARTITION p20260920; ——秒级完成,不锁表、不写 binlog、不生成 undo。前提是业务能接受“只保留最近 N 天离线消息”,这在聊天场景里通常合理。
写入离线消息要用批量INSERT+禁用autocommit
IM 网关收到多条离线消息时,如果逐条 INSERT,每条都开事务、刷 redo log、写 binlog,吞吐直接腰斩。实测 100 条消息逐条写要 800ms,批量写只要 60ms。
应用层聚合后执行:
INSERT INTO offline_msg (sender_id, receiver_id, content, created_at, status) VALUES (1001, 2001, 'hi', '2026-09-28 11:00:00', 'unread'), (1002, 2001, 'ok', '2026-09-28 11:00:01', 'unread'), ...;
同时连接需设置:SET autocommit = 0;,批量完成后 COMMIT;。别忘了检查 max_allowed_packet 是否足够(默认 4MB,100 条中等长度消息一般够用)。
真正容易被忽略的点是:分区表的 created_at 必须为 NOT NULL,否则 MySQL 会拒绝分区;另外,idx_receiver_status_time 索引里 created_at DESC 在 MySQL 8.0 才支持,低版本得去掉 DESC,靠应用层补排序。











