资讯动态

私信列表背后的会话设计:从表结构到SQL分组查询实战

发布时间:2026/10/15 15:17:24 来源:尧图企业网站定制
做后端这几年我一直觉得“私信列表”这种功能应该是最简单的那类把消息按时间查出来倒序排一下了不起加个分页。直到真正动手做牛客网后端项目实战的私信模块时才发现这个想法太天真了。用户进入私信功能后看到的第一屏其实是微信里那种“会话列表”每个联系人一行显示最近一条消息、未读数、小红点而不是一拉到底的原始消息堆。而“会话列表”这个看似简单的入口背后藏着一整套表结构设计、分组查询、未读统计、已读回执的逻辑。这篇就以私信列表为切入口把从需求拆解到SQL落地再到页面渲染的完整链路过一遍适合正在做社区类、社交类项目或者单纯想搞明白“会话列表和聊天记录到底差在哪”的同学参考。1. 私信列表页和消息详情页其实是两套完全不同的逻辑很多第一次做私信功能的人包括我自己第一反应都是建一张消息表然后把当前用户相关的消息全部查出来按时间倒序放页面上。这样做出来的页面用户看到的是所有历史消息搅在一起根本没有“和谁聊过天”的聚合概念。而真实产品里的私信入口一定是先展示会话列表我和A之间聊过我和B之间聊过而不是我发过100条、收过200条。所以私信功能从需求上就必须拆成两个页面对应两套后端逻辑。会话列表页私信列表数据范围是“所有和当前用户有过会话的人或系统通知”每一行代表一个会话展示的是这个会话里最近一条消息的内容、时间、以及我还没读的条数。消息详情页数据范围是“某一个会话里的全部往来消息”进入这个页面之后该会话的未读数要清零。这两个页面的核心差异我用表格列一下看完就明白为什么不能用一个接口解决对比项会话列表页消息详情页数据范围涉及当前用户的全部会话单个会话内的所有消息每行含义一个会话的最新一条消息一条原始消息未读处理显示每个会话的未读数进入后批量置为已读排序方式按会话的最后消息时间倒序按消息时间正序展示后端接口一般叫 conversations 列表一般叫 detail 详情对应到项目里接口也拆成两个一个是/message/conversations返回会话列表另一个是/message/detail?conversationIdxx返回某个会话的消息明细同时处理已读更新。我在这个项目里写第一版时偷懒只做了一个接口把消息按会话ID做分组后直接塞给前端。结果前端要么把每条消息都渲染成一行要么把一个会话的多条消息循环嵌套导致层级混乱最后不得不返工。所以先想清楚“列表页和详情页是两套逻辑”这件事比什么都重要。2. 消息表结构与会话ID规则conversation_id 为什么要按“小id_大id”拼私信模块只需要一张消息表但这张表的设计直接决定后面所有查询的写法。我这里把最终用的字段结构贴出来基本也是社区类项目里很常见的一种设计字段名类型说明idbigint主键自增from_idint发送方用户IDto_idint接收方用户IDconversation_idvarchar(64)会话ID用于关联两个人之间的所有消息contenttext消息内容statustinyint0-未读1-已读2-删除create_timedatetime发送时间这里最关键的字段就是conversation_id。两个用户之间的所有私信不管谁发给谁都应该归到同一个会话里。最稳妥的拼接规则是取两个用户ID中较小的放前面较大的放后面中间用下划线连接。conversationId fromId toId ? fromId _ toId : toId _ fromId举个例子用户ID为101的用户给102发消息conversation_id 就是101_102102 回复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次循环查询收敛到一次聚合查询会话列表页每一行都要显示未读数这个数字怎么来有两条路。最直觉的做法是拿到会话列表之后循环每个会话执行一次“查未读数”的SQLSELECT COUNT(*) FROM message WHERE to_id #{userId} AND conversation_id #{conversationId} AND status 0假设一页展示10个会话就要执行10次COUNT查询这就是典型的N1问题。会话数量小的时候没感觉等会话多了、页面频繁刷新数据库压力立刻上来了。我最后采用的是反向思路一次查出当前用户在所有会话里的未读分布再用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层的处理逻辑是这样ListMapString, Object unreadList messageMapper.selectUnreadGroupByConversation(userId); MapString, Integer unreadMap new HashMap(); int totalUnread 0; for (MapString, 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没有用户名、头像。如果不做处理前端每渲染一行就得调一次用户信息接口和前面说的N1是一个问题。正确的做法是批量查询遍历会话列表把每个会话中“对方”的ID收集到一个Set里然后一次查询出所有用户信息组装成Map再填充到会话列表的VO中。SetInteger userIds new HashSet(); for (ConversationVO vo : conversationList) { int targetId vo.getFromId() currentUserId ? vo.getToId() : vo.getFromId(); vo.setTargetId(targetId); userIds.add(targetId); } MapInteger, 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 表整个链路就会显得非常顺畅。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑