news 2026/10/15 15:17:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
私信列表背后的会话设计:从表结构到SQL分组查询实战

做后端这几年,我一直觉得“私信列表”这种功能应该是最简单的那类:把消息按时间查出来,倒序排一下,了不起加个分页。直到真正动手做牛客网后端项目实战的私信模块时,才发现这个想法太天真了。用户进入私信功能后看到的第一屏,其实是微信里那种“会话列表”,每个联系人一行,显示最近一条消息、未读数、小红点,而不是一拉到底的原始消息堆。而“会话列表”这个看似简单的入口,背后藏着一整套表结构设计、分组查询、未读统计、已读回执的逻辑。这篇就以私信列表为切入口,把从需求拆解到SQL落地再到页面渲染的完整链路过一遍,适合正在做社区类、社交类项目,或者单纯想搞明白“会话列表和聊天记录到底差在哪”的同学参考。

1. 私信列表页和消息详情页,其实是两套完全不同的逻辑

很多第一次做私信功能的人,包括我自己,第一反应都是建一张消息表,然后把当前用户相关的消息全部查出来,按时间倒序放页面上。这样做出来的页面,用户看到的是所有历史消息搅在一起,根本没有“和谁聊过天”的聚合概念。而真实产品里的私信入口,一定是先展示会话列表:我和A之间聊过,我和B之间聊过,而不是我发过100条、收过200条。

所以私信功能从需求上就必须拆成两个页面,对应两套后端逻辑。

会话列表页(私信列表),数据范围是“所有和当前用户有过会话的人或系统通知”,每一行代表一个会话,展示的是这个会话里最近一条消息的内容、时间、以及我还没读的条数。消息详情页,数据范围是“某一个会话里的全部往来消息”,进入这个页面之后,该会话的未读数要清零。

这两个页面的核心差异我用表格列一下,看完就明白为什么不能用一个接口解决:

对比项会话列表页消息详情页
数据范围涉及当前用户的全部会话单个会话内的所有消息
每行含义一个会话的最新一条消息一条原始消息
未读处理显示每个会话的未读数进入后批量置为已读
排序方式按会话的最后消息时间倒序按消息时间正序展示
后端接口一般叫 conversations 列表一般叫 detail 详情

对应到项目里,接口也拆成两个:一个是/message/conversations,返回会话列表;另一个是/message/detail?conversationId=xx,返回某个会话的消息明细,同时处理已读更新。

我在这个项目里写第一版时,偷懒只做了一个接口,把消息按会话ID做分组后直接塞给前端。结果前端要么把每条消息都渲染成一行,要么把一个会话的多条消息循环嵌套导致层级混乱,最后不得不返工。所以先想清楚“列表页和详情页是两套逻辑”这件事,比什么都重要。

2. 消息表结构与会话ID规则:conversation_id 为什么要按“小id_大id”拼

私信模块只需要一张消息表,但这张表的设计直接决定后面所有查询的写法。我这里把最终用的字段结构贴出来,基本也是社区类项目里很常见的一种设计:

字段名类型说明
idbigint主键自增
from_idint发送方用户ID
to_idint接收方用户ID
conversation_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_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 表,整个链路就会显得非常顺畅。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:17:10

AI-ISP 落地实战:RAW 域降噪、HDR 与极限场景调参避坑指南

简介&#xff1a;这份PDF是智东西公开课「AI-ISP在人工智能视觉成像领域的应用和发展」的配套课件&#xff0c;由爱芯元智ISP负责人张兴主讲&#xff0c;面向AI视觉成像、图像信号处理与芯片算法方向的工程师、研究者及技术爱好者。内容围绕AI-ISP的架构创新展开&#xff0c;先…

作者头像 李华
网站建设 2026/10/15 15:16:43

SOEM+CiA402开源运动控制:Linux/Windows直驱伺服电机

简介&#xff1a;本资源是一个基于SOEM开源库实现EtherCAT主站通信与CIA402标准电机控制的完整工程实践项目&#xff0c;面向嵌入式/Linux底层开发工程师、工业自动化系统集成人员及高校运动控制方向研究者&#xff0c;解决EtherCAT伺服系统快速原型开发与协议层调试难题。压缩…

作者头像 李华
网站建设 2026/10/15 15:16:41

Kubernetes底层原理:控制平面与工作节点协同机制解析

1. 这不是“背概念”&#xff0c;而是理解容器编排的底层逻辑你点开这个标题&#xff0c;大概率正站在Kubernetes大门外&#xff0c;手里攥着几页零散笔记&#xff0c;脑子里盘旋着一堆名词&#xff1a;Pod、Service、Deployment、etcd、kubelet……它们像一串密码&#xff0c;…

作者头像 李华
网站建设 2026/10/15 15:16:20

生产级AI Agent的七大工程要素与决策点

1. 这不是概念炒作&#xff0c;是工程现场的七把手术刀“AI Agent”这个词现在被讲得太多&#xff0c;太多人还在用“有记忆、能调工具、会规划”这种教科书式定义去套&#xff0c;结果一写代码就卡在循环崩掉、状态错乱、并发一上来就超时。我带团队落地过12个生产级Agent系统…

作者头像 李华
网站建设 2026/10/15 15:16:11

JVM性能监控与故障处理实战:命令行与可视化工具全解析

首先要说&#xff0c;JVM性能监控与故障处理工具这个主题&#xff0c;属于《深入理解JVM.2nd》里“一看就会、一用就忘”的典型章节。命令行工具就那几个字母&#xff0c;jps、jstat、jmap、jstack&#xff0c;但真到了线上服务CPU飙高、Full GC频繁的时候&#xff0c;很多人还…

作者头像 李华