做后端这几年,我一直觉得“私信列表”这种功能应该是最简单的那类:把消息按时间查出来,倒序排一下,了不起加个分页。直到真正动手做牛客网后端项目实战的私信模块时,才发现这个想法太天真了。用户进入私信功能后看到的第一屏,其实是微信里那种“会话列表”,每个联系人一行,显示最近一条消息、未读数、小红点,而不是一拉到底的原始消息堆。而“会话列表”这个看似简单的入口,背后藏着一整套表结构设计、分组查询、未读统计、已读回执的逻辑。这篇就以私信列表为切入口,把从需求拆解到SQL落地再到页面渲染的完整链路过一遍,适合正在做社区类、社交类项目,或者单纯想搞明白“会话列表和聊天记录到底差在哪”的同学参考。
1. 私信列表页和消息详情页,其实是两套完全不同的逻辑
很多第一次做私信功能的人,包括我自己,第一反应都是建一张消息表,然后把当前用户相关的消息全部查出来,按时间倒序放页面上。这样做出来的页面,用户看到的是所有历史消息搅在一起,根本没有“和谁聊过天”的聚合概念。而真实产品里的私信入口,一定是先展示会话列表:我和A之间聊过,我和B之间聊过,而不是我发过100条、收过200条。
所以私信功能从需求上就必须拆成两个页面,对应两套后端逻辑。
会话列表页(私信列表),数据范围是“所有和当前用户有过会话的人或系统通知”,每一行代表一个会话,展示的是这个会话里最近一条消息的内容、时间、以及我还没读的条数。消息详情页,数据范围是“某一个会话里的全部往来消息”,进入这个页面之后,该会话的未读数要清零。
这两个页面的核心差异我用表格列一下,看完就明白为什么不能用一个接口解决:
| 对比项 | 会话列表页 | 消息详情页 |
|---|---|---|
| 数据范围 | 涉及当前用户的全部会话 | 单个会话内的所有消息 |
| 每行含义 | 一个会话的最新一条消息 | 一条原始消息 |
| 未读处理 | 显示每个会话的未读数 | 进入后批量置为已读 |
| 排序方式 | 按会话的最后消息时间倒序 | 按消息时间正序展示 |
| 后端接口 | 一般叫 conversations 列表 | 一般叫 detail 详情 |
对应到项目里,接口也拆成两个:一个是/message/conversations,返回会话列表;另一个是/message/detail?conversationId=xx,返回某个会话的消息明细,同时处理已读更新。
我在这个项目里写第一版时,偷懒只做了一个接口,把消息按会话ID做分组后直接塞给前端。结果前端要么把每条消息都渲染成一行,要么把一个会话的多条消息循环嵌套导致层级混乱,最后不得不返工。所以先想清楚“列表页和详情页是两套逻辑”这件事,比什么都重要。
2. 消息表结构与会话ID规则:conversation_id 为什么要按“小id_大id”拼
私信模块只需要一张消息表,但这张表的设计直接决定后面所有查询的写法。我这里把最终用的字段结构贴出来,基本也是社区类项目里很常见的一种设计:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键自增 |
| from_id | int | 发送方用户ID |
| to_id | int | 接收方用户ID |
| conversation_id | varchar(64) | 会话ID,用于关联两个人之间的所有消息 |
| content | text | 消息内容 |
| status | tinyint | 0-未读,1-已读,2-删除 |
| create_time | datetime | 发送时间 |
这里最关键的字段就是conversation_id。两个用户之间的所有私信,不管谁发给谁,都应该归到同一个会话里。最稳妥的拼接规则是:取两个用户ID中较小的放前面,较大的放后面,中间用下划线连接。
conversationId = fromId < toId ? fromId + "_" + toId : toId + "_" + fromId举个例子:用户ID为101的用户给102发消息,conversation_id 就是101_102;102 回复101时,因为 102 > 101,拼出来仍然是101_102。这样无论消息从哪个方向发出,查询某个会话的消息只需要WHERE conversation_id = '101_102',完全不用判断发送方向。
这个规则还带来一个好处:会话列表页要识别“对方是谁”时,可以直接把conversation_id按_拆成两个ID,哪个不等于当前登录用户,哪个就是对方ID。我在做详情页时用的是这种方式,省掉了先查一条消息再反推用户的开销。
系统通知也复用同一张表,我们约定了一个固定的系统账号,比如 from_id = 1。系统给某用户发通知时,conversation_id 同样按规则拼接。这样私信列表页天然就多出一行“系统通知”,不需要额外建通知表,通知和私信统一用一个入口,产品上也很常见。
再提一个容易忽略的点:索引。我在早期版本里只给conversation_id建了索引,后来压数据测试时发现未读数统计和会话列表查询都变慢了。实际需要关注两个高频查询场景:
- 查询某个会话的全部消息:
WHERE conversation_id = ? ORDER BY create_time,所以建idx_conversation_time (conversation_id, create_time)更划算。 - 统计未读数:
WHERE to_id = ? AND status = 0 GROUP BY conversation_id,所以建idx_to_status (to_id, status)。
联合索引的顺序很重要,写反了(比如status, to_id)会导致这个统计查询没法快速走索引。这些细节不会影响功能正确性,但在数据量上来之后会直接决定接口的响应时间。
3. 会话列表查询:每组取最新一条消息的SQL,到底怎么写才对
会话列表的核心需求,翻译成数据库操作就是:把涉及当前用户的所有conversation_id找出来,然后对每个会话取最近一条消息,最后按这些最近消息的时间倒序排列,再做分页。
我第一次写这个查询时,想当然地用了GROUP BY conversation_id加上MAX(create_time):
SELECT conversation_id, from_id, to_id, content, MAX(create_time) FROM message WHERE from_id = 101 OR to_id = 101 GROUP BY conversation_id这条SQL在MySQL的默认模式下能跑出结果,但content字段拿到的并不一定是最近那条消息的内容。GROUP BY之后,除了聚合函数计算的列,其他非聚合列的取值是“不确定的”,它可能取到该组内任意一行的内容。也就是说,列表里显示的“最后一条消息预览”很可能和真实时间对不上,这是最典型的坑。
正确的思路是“分区排序后取第一条”,MySQL 8.0以上可以直接用窗口函数:
SELECT conversation_id, from_id, to_id, content, create_time FROM ( SELECT m.*, ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY create_time DESC, id DESC) AS rn FROM message m WHERE (m.from_id = #{userId} OR m.to_id = #{userId}) AND m.status != 2 ) t WHERE t.rn = 1 ORDER BY t.create_time DESC, t.id DESC LIMIT #{offset}, #{limit}说说这条SQL的几个关键细节。
第一,PARTITION BY conversation_id把每个会话分成一组,组内按create_time DESC, id DESC排序,ROW_NUMBER()给每组打上序号1、2、3,外层只取rn = 1的那一行,就是每个会话的最近一条消息。
第二,排序时除了create_time还加了id DESC兜底。生产环境中,同一秒内连续发两条消息是完全可能的,如果只按时间排,两条消息的顺序不稳定,列表预览就可能偶尔串行。id 是自增主键,同秒时 id 更大的一定是后发的,这个兜底是防止边界问题用的。
第三,也是最容易踩的坑:WHERE里的OR条件。数据库里AND的优先级高于OR,如果不加括号,条件会变成“from_id = 101 或者 (to_id = 101 and status != 2)”,结果是当前用户发出但已经被删除的消息也会被查出来。我实际调试时遇到过这个现象,列表里出现了一些早就删掉的会话,加上括号之后数据立刻正常。
如果项目还在用MySQL 5.7,没有窗口函数,可以用“取每组最大id再自连接”的方式替代:
SELECT m.* FROM message m JOIN ( SELECT conversation_id, MAX(id) AS max_id FROM message WHERE (from_id = #{userId} OR to_id = #{userId}) AND status != 2 GROUP BY conversation_id ) tmp ON m.id = tmp.max_id ORDER BY m.create_time DESC LIMIT #{offset}, #{limit}这个方案利用MAX(id)找到每组最近一条消息的主键,再回表查整行。id 自增的场景下基本可靠,但如果业务上允许异步补录历史数据、或者消息时间由客户端自定义,id 顺序和真实时间顺序不一定一致,还是窗口函数方案更严谨。
最后是分页的位置。LIMIT必须放在外层子查询的结果集上,放在内层的话,会先截断每个会话的候选消息,比如每组只留10条,等到外层再取rn = 1时,可能拿到的是“每组第10条中最近的一条”,整体会话数量完全不对。
4. 未读消息数统计:从N次循环查询收敛到一次聚合查询
会话列表页每一行都要显示未读数,这个数字怎么来,有两条路。
最直觉的做法是:拿到会话列表之后,循环每个会话,执行一次“查未读数”的SQL:
SELECT COUNT(*) FROM message WHERE to_id = #{userId} AND conversation_id = #{conversationId} AND status = 0假设一页展示10个会话,就要执行10次COUNT查询,这就是典型的N+1问题。会话数量小的时候没感觉,等会话多了、页面频繁刷新,数据库压力立刻上来了。
我最后采用的是反向思路:一次查出当前用户在所有会话里的未读分布,再用Map装配到会话列表里。
SELECT conversation_id, COUNT(*) AS cnt FROM message WHERE to_id = #{userId} AND status = 0 GROUP BY conversation_id注意这里的条件只有to_id和status,因为未读数只可能是“别人发给我的、我还没读的”消息,自己发出的消息不存在未读一说。这个SQL配合前面说的idx_to_status (to_id, status)联合索引,走索引聚合非常快。
Service层的处理逻辑是这样:
List<Map<String, Object>> unreadList = messageMapper.selectUnreadGroupByConversation(userId); Map<String, Integer> unreadMap = new HashMap<>(); int totalUnread = 0; for (Map<String, Object> item : unreadList) { String conversationId = (String) item.get("conversationId"); Integer cnt = ((Number) item.get("cnt")).intValue(); unreadMap.put(conversationId, cnt); totalUnread += cnt; }然后遍历会话列表时,对每个会话的conversationId从unreadMap里取数,取不到就是0。这样无论当前页有多少个会话,都只需要两次查询:一次取会话列表,一次取未读数分布。
我最初也想过把未读数放到列表SQL里用子查询一起查出来,后来放弃了。原因是会话列表本身已经要处理分页、最新一条、时间排序,SQL复杂度已经不小,再把未读聚合塞进去,可读性和排错成本都会增加。分开查、Java内存里组装,是更清晰的做法。
关于未读数这里还有一点进阶空间。如果项目量级做大,未读数被频繁读写,数据库COUNT的性价比就不够高了。可以考虑用Redis维护一个未读数字段:每次发消息时对接收方的未读数做自增,进入详情页时清零。不过这个方案引入了缓存一致性、持久化等新的问题,中小项目直接用数据库聚合完全够用,不要过早优化。
5. 详情页的已读更新与页面渲染细节
从会话列表点进某个会话后,后端要同时处理两件事:一是查询这个会话的消息记录,二是把这个会话中当前用户未读的消息批量置为已读。
更新已读的SQL很直接:
UPDATE message SET status = 1 WHERE to_id = #{userId} AND conversation_id = #{conversationId} AND status = 0这里有个必须注意的地方:条件里一定要带上to_id = 当前用户。我第一次写的时候只写了conversation_id和status,结果把当前用户自己发出的消息也一起置成已读了。虽然发送方消息的已读状态一般没有实际用途,但这个操作在逻辑上是错的,而且会污染数据。
已读更新和消息查询的执行顺序也有讲究:先执行UPDATE,再执行SELECT。这样本次请求返回的列表已经是已读状态,页面刷新后未读数能立即归零,不会出现“明明进了详情页,回列表还是显示小红点”的尴尬。这两个操作我放在同一个事务方法里,加上@Transactional保证一致性。
接下来是页面数据装配。会话列表查出来之后,只有from_id和to_id,没有用户名、头像。如果不做处理,前端每渲染一行就得调一次用户信息接口,和前面说的N+1是一个问题。
正确的做法是批量查询:遍历会话列表,把每个会话中“对方”的ID收集到一个Set里,然后一次查询出所有用户信息,组装成Map,再填充到会话列表的VO中。
Set<Integer> userIds = new HashSet<>(); for (ConversationVO vo : conversationList) { int targetId = vo.getFromId() == currentUserId ? vo.getToId() : vo.getFromId(); vo.setTargetId(targetId); userIds.add(targetId); } Map<Integer, User> userMap = userService.listByIds(userIds) .stream().collect(Collectors.toMap(User::getId, u -> u));对方是谁的判断方式,我在前面提过,可以直接解析conversation_id,也可以用消息里的from_id / to_id判断。我实际用的是前者,连查询都省了。
会话列表VO里需要包含的展示字段大概是这些:
- conversationId:会话ID,点击跳转时传给详情页
- targetId、username、avatarUrl:对方的用户信息
- lastMessage:最后一条消息内容,前端截断显示
- lastTime:最后一条消息时间,按今天/昨天/更早格式化
- unreadCount:未读数,大于0时显示红点
消息内容预览还有一个细节:用户发的私信内容可能带换行,富文本内容可能很长。在后端拼VO时,我会把换行替换成空格再截断前60个字符,避免列表页出现奇怪的排版。时间格式化建议在后端统一处理,前端拿到字符串直接显示就行,这样列表页和详情页的时间格式能保持一致。
分页参数也值得单独说一下。会话列表的分页是“按会话分页”而不是“按消息条数分页”,所以页码和每页条数在URL上传参即可。详情页的分页则按消息条数走,字段设计成page和limit,跳转详情时把conversationId一并带过去。
整套私信列表做下来,我最大的体会是:这类功能技术难度不高,难在把“会话”这个中间抽象想明白,然后围绕它组织SQL和数据装配。把会话列表、未读数、详情页三个需求拆开之后,每一块的逻辑都非常直白,代码也不复杂。最后分享一个小技巧:如果你项目里的MySQL还停留在5.7,窗口函数那版SQL换成MAX(id) JOIN子查询也能跑,逻辑等价,就是嵌套层数多,写的时候务必把括号里的OR条件圈清楚。测试时也记得造几条“同一秒发送”的数据,窗口函数里的id DESC兜底就是为这种边界情况准备的。私信列表做完,下一步自然是私信详情和发送逻辑,回头看这张 message 表,整个链路就会显得非常顺畅。