先说一句大实话私信列表这种功能光看标题觉得“不就查个表吗”真正动手做的时候你会发现它同时牵扯到会话聚合、未读计数、分页、性能、还有一堆交互上的细节。它是那种典型的看着简单、做起来才知道坑不少的业务模块。这篇就把我做牛客网后端项目私信列表这块的完整思路、表结构设计、接口实现、以及我实际踩过的坑全部分享出来希望能给正在做类似社区或社交类后端的同学一些参考。1. 业务场景与核心需求拆解1.1 私信列表到底是做什么的私信列表从产品视角看其实就是用户消息中心的“会话列表页”。它要回答的问题很朴素用户登录后打开消息页面第一眼要看到的是“我和谁有过私信往来”每个会话显示对方的头像、昵称、最后一条消息的摘要、还有未读条数。点进某一个会话才能看到全部往来消息。但这个看似简单的页面扔给后端马上就变成一堆要处理的细节一个用户可能和很多人私信过要按“会话”而不是按“单条消息”来聚合展示每个会话必须算出未读消息数量方便前端在头像或列表项右上角显示红点会话列表要有排序规则通常是按最后一条消息的时间倒序最新的排最前已读状态要能更新用户点进某个会话后这组消息要标记为已读会话列表要分页消息历史也要分页而且不能把两个分页逻辑搞混私信是双向的A发给BB回给A这属于同一个会话不能在列表里出现两行“我和TA”的记录还有敏感词过滤发私信不能口无遮拦聊天内容和列表摘要里的文字都要过一遍。我在第一次做私信功能之前也以为很简单结果真正着手梳理才发现需求方可能只说了“做个私信列表”背后其实藏着以上一大堆默认的期望。所以动手第一步不是写代码而是把话术理顺、把需求拆成可落地的功能点。1.2 会话列表页的典型交互流程为了讲清楚后续的接口和表结构设计必须先明确用户和系统的交互路径。我以常见的消息中心为例画个完整的流程概念用户A打开私信列表页后端返回A参与的所有会话每个会话带对方用户信息、最后一条消息内容、未读数、最后消息时间。用户A点击某个会话进入消息详情页后端返回这个会话下的全部消息记录分页同时把这组消息的未读状态更新为已读。用户A在详情页回复消息后端新增一条消息同时把会话的“最后一条消息”和“最后消息时间”进行更新。用户A退出详情页回到列表页列表要自动刷新刚才那个会话的最新内容应该排到最前面、未读数归零。用户A如果收到新消息未读数增加列表顺序也要动态变化。从这个流程能推导出后端至少需要三块接口会话列表接口、会话详情接口含已读操作、发送私信接口。有些系统还把“删除会话”也作为单独的接口这里先不展开先把核心链路做稳。1.3 我为什么先梳理清需求才动代码很多同学写后端容易一上来就建表这是我的老毛病后来吃过亏才改。私信这个东西表结构一旦定错后面改起来极其痛苦。比如你一开始把消息和会话混在一张表里没有 conversation_id 的概念那会话列表就没法高效查询再比如你没想到要冗余“最后一条消息”那列表页每次都要子查询或者 JOIN 才能显示摘要性能迟早出问题。所以我的习惯是先把“页面长什么样”“交互怎么走”搞清楚再倒推表结构和接口宁可多花半小时画个流程图也不要让后期重构来还债。这个步骤看起来不产代码但对实战项目来说它的价值比写代码还高。2. 数据库表设计与建模思路2.1 会话表与消息表的分工私信场景最稳妥、也是我用下来的方案是拆两张表一张会话表一张消息表。会话表管“会话维度的元数据”消息表管“消息流水”。会话表的核心字段我建议这样设计字段名类型说明idint主键自增from_idint会话发起方用户IDto_idint会话接收方用户IDconversation_idvarchar(64)会话唯一标识全局唯一unread_countint未读消息数冗余字段last_messagevarchar(255)最后一条消息内容冗余字段last_message_timedatetime最后一条消息时间冗余字段statustinyint会话状态0正常1删除create_timedatetime创建时间update_timedatetime更新时间有人可能会问from_id 和 to_id 已经能确定两个人为什么还要单独搞一个 conversation_id我是这样理解的如果以 (from_id, to_id) 二元组作为唯一标识查询“某用户参与的所有会话”时必须写成WHERE from_id ? OR to_id ?SQL 写起来别扭索引利用也不直接。有了一个独立的 conversation_id可以做到用户维度直接锁定会话查询条件变成WHERE conversation_id IN (...)或直接等值匹配。还有一坑是会话的方向问题。A发给B和B发给A是同一个会话不能因为方向不同产生两个会话。所以我的做法是生成会话ID时约定把两个用户ID按大小排序拼接小的在前大的在后。比如 A 的 ID 是 5B 的 ID 是 8conversation_id 就生成 5_8反过来 B 发给 A 时先比较仍然是 5_8。这样从根上保证同一对用户永远只对应一个会话。2.2 消息表的字段设计与索引选择消息表是这个业务的核心流水表字段设计直接影响消息历史和未读统计的实现。我常用的结构如下字段名类型说明idint主键自增from_idint发送方用户IDto_idint接收方用户IDconversation_idvarchar(64)所属会话IDcontentvarchar(512)消息内容statustinyint消息状态0未读1已读2删除create_timedatetime发送时间这里 status 既是“是否已读”的标记也是“是否被删除”的标记。我见过有项目把已读和删除拆成两个字段也不是不行但从我的经验看私信场景下“删除”一般做成逻辑删除就够了一个字段全部搞定SQL 写起来也简单。索引方面要说重点。conversation_id 一定要加索引因为消息历史查询是高频操作而且通常是按时间排序的分页查询所以建议建联合索引(conversation_id, create_time)。from_id 和 to_id 要不要单独索引如果存在“查询某用户发给某用户的所有消息”这类需求那就加但前面我们已经用 conversation_id 把两个人锁死在同一个会话里了实际上这个查询频率很低个人建议不必多加控制索引数量也能减轻写入压力。未读计数一定要放在会话表做冗余不要每次实时 COUNT 消息表。原因是私信列表页每次刷新都要展示未读数如果每行都去消息表做 COUNT列表一长SQL 就爆炸。会话表冗余 unread_count 字段发送消息时 1已读时清零查询时直接 SELECT性能好得多。代价是更新时要多写一个字段但私信场景的并发量级完全扛得住。2.3 冗余字段的取舍与一致性方案上一节说到的 last_message、last_message_time、unread_count 都是冗余字段。冗余这件事新手最容易纠结到底要不要冗余我的态度是私信列表这种高读场景冗余是必然的否则列表页做不到高效。但冗余会带来一致性问题比如发送消息时要同时更新消息表、会话表的 last_message、last_message_time以及未读计数已读时要更新消息表多条记录的 status同时把会话表的 unread_count 清零。这些操作必须在同一个事务里做要么全成功要么全失败。我在实际开发中用过两种方案方案一在 Service 层手动开启事务先插入消息表再更新会话表使用 Spring 的Transactional注解保证原子性。这个方案简单直观也是我推荐新手优先使用的。方案二借助数据库存储过程或者异步消息队列把消息写入和会话更新解耦。这个方案性能更好但复杂度明显上升对私信场景来说属于过度设计。我实际开发中选的是方案一原因很简单私信功能的写入频率没有高到需要引入消息队列的程度事务方案足够而且出了问题容易排查、回滚直观。还有个小细节消息表里也冗余了 from_id 和 to_id而不是只靠 conversation_id。这是我的习惯万一以后要做“某用户发送的全部消息统计”或者“拉黑后清理消息”等扩展功能直接走这两个字段就能查不用去 JOIN 用户表再解析会话ID。多余不有点多余但代价极小收益明确我选择保留。3. 后端接口设计与核心实现3.1 接口列表与参数约定我最终确定的接口有三个都是 RESTful 风格接口方法路径说明会话列表GET/conversation/list分页返回当前用户的所有会话会话详情GET/conversation/detail查询某个会话下的消息历史并标记已读发送私信POST/message/send发送一条私信先说明登录态的问题所有接口都要校验用户登录状态当前用户 ID 我从登录态里获取而不是让前端传 fromId否则用户可以伪造别人身份发消息这是绝对的安全漏洞。会话列表接口的请求参数我用的是当前用户ID 页码 每页条数返回结构大致如下{ code: 0, data: { list: [ { conversationId: 5_8, user: { id: 8, username: 某用户昵称, avatarUrl: ... }, lastMessage: 在吗, lastMessageTime: 2025-04-01 12:30:00, unreadCount: 2 } ], total: 23, pageNum: 1, pageSize: 10 } }会话详情接口的返回结构也不复杂消息记录按时间升序排列方便前端直接从上到下渲染聊天气泡{ code: 0, data: { conversationId: 5_8, messageList: [ { id: 101, fromUserId: 5, toUserId: 8, content: 在吗, createTime: 2025-04-01 12:29:00 }, { id: 102, fromUserId: 8, toUserId: 5, content: 在的什么事, createTime: 2025-04-01 12:30:00 } ], total: 2 } }3.2 会话列表查询的 SQL 与逻辑细节会话列表本质上是“当前用户 ID 出现在 from_id 或 to_id 的所有会话记录按最后消息时间倒序”。我的 Mapper 里核心 SQL 如下select idselectConversationsByUserId resultTypeConversation SELECT * FROM conversation WHERE status 0 AND (from_id #{userId} OR to_id #{userId}) ORDER BY last_message_time DESC LIMIT #{offset}, #{pageSize} /select这个 SQL 本身不难但有一个性能细节值得注意如果表数据量很大单靠OR条件索引利用可能不理想。这时候有两个优化方向方向一在 from_id 和 to_id 上分别建索引让 MySQL 分别走索引后合并但这种优化效果受数据分布影响需要 EXPLAIN 验证。方向二在业务层把用户已参与的 conversation_id 先查出来再走主键或唯一键查询。比如先查一张“会话成员关联表”再做二次查询。考虑到私信列表场景一般不会出现百万级会话我更倾向直接OR 适当索引简单可靠等真正有性能瓶颈时再优化关联表方案。我实际项目的数据量在几十万左右这条 SQL 响应时间稳定在 20ms 以内完全够用。接下来就是组装返回结构。我的 Service 层做法是拿到会话列表后把其中出现的“对方用户ID”收集成一个列表批量查用户信息再组装成 VO 返回。批量查的要点是避免 N1 查询千万不要在循环里一条一条查用户表。这个坑我相信很多人都踩过一次列表页加载明明只有 10 个会话结果发出去 10 条用户查询 10 条会话查询接口慢得跟蜗牛一样。那怎么判断“对方用户”是 from_id 还是 to_id简单凡是“不是当前用户的那个ID”就是对方。比如当前用户是 5会话记录是 from_id5、to_id8那对方就是 8如果当前用户是 8同一行记录里 from_id 是 5、to_id 是 8那对方就是 5。3.3 生成会话 ID 的规则与实现这一步值得单独拿出来讲因为它是防止“同一对用户出现多个会话”的关键。我用的拼接规则是public static String generateConversationId(int uid1, int uid2) { if (uid1 uid2) { return uid1 _ uid2; } else { return uid2 _ uid1; } }先比大小、再拼接保证无论哪一方发起生成的 ID 都是一样的。发送私信时流程就是先根据当前用户和接收方用户的 ID 生成 conversation_id查一下会话表有没有这条记录没有就插入一条新会话unread_count 0last_message 为空然后再插入消息。这里有一个查询和插入的并发问题如果 A 和 B 同时互发第一条私信两个请求可能都发现会话不存在然后都去插入结果产生两条会话记录。我的处理方式有两个方向方向一给 conversation_id 字段加唯一索引插入时用INSERT IGNORE或者捕获重复键异常保证同一时间只有一个会话记录插入成功。方向二在插入会话前用SELECT ... FOR UPDATE锁住判断逻辑但这种写法在并发不高时有点过度设计。实际开发中我用的是方向一。再提醒一下给 conversation_id 添加唯一索引是个好习惯否则哪怕代码写得再小心也架不住并发插入的极端场景。3.4 发送私信时未读数更新与顺序问题发送私信的完整逻辑如下生成或获取会话 ID插入消息表status 设为 0未读更新会话表last_message 消息内容last_message_time 当前时间如果接收方不是发送方自己unread_count unread_count 1。这里最后一个条件很容易被忽略如果 A 给 B 发消息B 的未读数 1但如果 A 在给自己发消息某些系统允许“发给自己做笔记”未读数不应该 1因为自己看自己的消息不存在“未读”概念。虽然私信场景一般不允许给自己发但判断一下总没错。另外发送消息和更新会话要在同一个事务里。我用的代码大致如下Transactional public void sendMessage(int fromUserId, int toUserId, String content) { String conversationId generateConversationId(fromUserId, toUserId); Conversation conversation conversationMapper.selectByConversationId(conversationId); if (conversation null) { conversation new Conversation(); conversation.setConversationId(conversationId); conversation.setFromId(fromUserId); conversation.setToId(toUserId); conversation.setUnreadCount(0); conversationMapper.insert(conversation); } Message message new Message(); message.setFromId(fromUserId); message.setToId(toUserId); message.setConversationId(conversationId); message.setContent(content); message.setStatus(0); messageMapper.insert(message); conversationMapper.updateLastMessage(conversationId, content, new Date()); }更新未读数的逻辑我放在 updateLastMessage 的 SQL 里判断接收方update idupdateLastMessage UPDATE conversation SET last_message #{lastMessage}, last_message_time #{lastMessageTime}, unread_count unread_count 1 WHERE conversation_id #{conversationId} AND to_id #{toUserId} /update让接收方以外的会话不增加未读数这个条件我特意加在 SQL 里是因为不同场景下更新语义不同。如果接收方就是当前登录用户那未读数不加否则加 1。如果你用的是“更新时无条件 1”的方案那么发私信给自己时未读计数会越攒越多用户看到自己的未读永远清不掉这是很蠢的 bug。3.5 会话详情与已读标记的原子操作用户点开一个会话后端要做两件事返回消息列表同时把该会话的所有未读消息置为已读。这两个操作同样要在事务里完成否则可能消息还没标记已读用户又收到新消息了结果旧消息仍然是未读状态。获取消息历史的 SQLselect idselectMessagesByConversationId resultTypeMessage SELECT * FROM message WHERE conversation_id #{conversationId} AND status ! 2 ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select注意这里我按 create_time 倒序查询因为要支持“加载更多”这种分页模式先加载最新一页往下翻的时候加载更早的。前端拿到的列表再自行反转渲染成时间正序。标记已读的 SQL 就是批量更新update idupdateStatusByConversationId UPDATE message SET status 1 WHERE conversation_id #{conversationId} AND to_id #{userId} AND status 0 /update更新完消息表后别忘了把会话表的 unread_count 清零update idclearUnreadCount UPDATE conversation SET unread_count 0 WHERE conversation_id #{conversationId} AND to_id #{userId} /update清零时同样要带to_id #{userId}条件防止 A 打开会话时把 B 的未读数也清掉了。这个细节我在做第二版的时候才补上第一版踩过坑当时测试同事反馈“我打开会话后对方的未读红点神秘消失了”排查了半天才发现是少加了 to_id 条件。3.6 敏感词过滤私信列表不能“张口就来”私信内容是用户生成内容必须做合规处理。我在消息写入前会走一遍敏感词过滤器。考虑到现实情况我把规则分成两种强校验某些严重违规的内容直接拒绝发送替换处理一般敏感词替换成*号再入库列表页展示的也是替换后的内容。具体实现上可以用前缀树Trie或者直接使用开源工具包。我自己的项目里前期用的是一个简单的关键词数组遍历 String.replace后来关键词多了才改成前缀树。如果你刚开始做不用一上来就上重工具先把过滤流程打通再逐步升级。敏感词过滤的代码大概长这样public String filter(String content) { for (String keyword : sensitiveWords) { if (content.contains(keyword)) { content content.replace(keyword, ***); } } return content; }这个实现很初级真实场景要用 AC 自动机才高效。但核心思想是一样的入库之前过滤列表摘要展示过滤后的文本。注意过滤后的文本要存到消息表和会话表两个地方保持一致否则列表页的 last_message 带着敏感词直接展示等于过滤白做了。4. 前端对接与联调时的关键问题4.1 前端页面需要哪些字段与状态后端做完接口要和前端联调。前端页面通常需要关注这些点会话列表每一项对方头像、对方昵称、最后一条消息文本、未读数、最后消息时间。头像和昵称来自用户服务后端在 VO 里已经组装好前端不用再调一次用户接口。未读数显示规则未读数超过 99 显示 99未读为 0 不显示红点。消息列表的反转后端按时间倒序返回前端渲染时要反转成时间正序否则聊天界面的顺序是反的。已读标记的触发时机前端进入详情页时应该立即调用详情接口后端在这个接口里顺带做了已读标记不需要前端额外传“我已读”这类动作。下拉刷新和分页加载的判断依据列表是否还有更多以后端返回的 total 和当前页数做判断。从联调经验看最容易出问题的地方是“会话详情接口查询完消息历史后马上标记已读”的时序。如果前端进详情页时先拉消息、再等几秒才收到新消息而新消息也在这个会话里那后端标记已读时会不会把新消息也误标成已读答案是不会因为标记已读的 UPDATE 条件是status 0而新插入的消息在事务隔离下不会出现在同一个 UPDATE 范围里。但如果你在同一个事务里先查后更可重复读隔离级别下UPDATE 默认读取的是最新已提交数据所以新消息不受影响。这里牵扯到 InnoDB 的当前读和快照读面试或者技术评审时会是一个不错的话题。4.2 联调中的并发场景模拟联调时我强烈建议用两个账号互相发消息模拟以下场景A 连续给 B 发三条消息B 的会话列表未读数应该显示 3B 点开会话未读数清零但 A 那边不受影响A 看到的会话未读仍然按 A 收到的消息计算B 不点会话直接刷新列表页未读数保持 3A 和 B 同时给对方发消息会话列表不出现两行重复记录A 删除会话如果有删除功能B 仍然能看到会话且未读计数不受影响。这些场景是我在测试环境里真实跑过的它们能覆盖大部分私信功能的边界问题。尤其是第 4 条没有 conversation_id 唯一约束的情况下极容易出现重复会话我在早期的版本里就栽过跟头。所以写代码时一定要建立“唯一索引 幂等生成逻辑”的防线。5. 分页查询的性能优化与深坑排查5.1 深分页问题当 offset 变得很大时私信列表和消息历史都有分页分页写起来容易性能隐患却很隐蔽。以消息历史为例传统写法是SELECT * FROM message WHERE conversation_id 5_8 ORDER BY create_time DESC LIMIT 0, 10;第一页没问题但当用户翻了 100 页SQL 变成LIMIT 990, 10时MySQL 会扫描前 1000 条再丢弃前 990 条。会话少的时候无所谓一旦某个会话消息上万条这个深分页就是噩梦。我在实际项目中遇到过一个“分析型用户”的测试账号和另一个号来回发了上万条消息导致消息历史接口从 50ms 慢慢涨到 800ms。后来我采用了基于游标的分页SELECT * FROM message WHERE conversation_id 5_8 AND create_time #{lastCreateTime} ORDER BY create_time DESC LIMIT 10;前端每次滚动加载时把当前列表里最早一条消息的 create_time 传给后端作为查询条件。这样每次都只扫描需要的区间不会再有无谓的偏移量消耗。这个优化看起来简单但对深分页的改善是数量级的。会话列表也是同样的思路。第一次加载用LIMIT 0, 10后续翻页用last_message_time作为游标。当然会话列表通常不会翻太多页但养成好习惯写分页时脑子里多想一层后面省事很多。5.2 避免 N1 查询列表接口性能的生命线N1 查询是后端新人最容易犯的低级错误也是最容易背锅的性能杀手。会话列表返回 10 条数据如果每条都去数据库查一次用户信息那就是 10 条额外 SQL。如果每页 20 条就是 20 条额外 SQL。这些 SQL 单看都能跑但累积起来接口的响应时间被拖得很难看。正确的做法是批量查。我的 Service 层逻辑是这样的查会话列表拿到 List 遍历这个 List把每个会话的“对方用户 ID”收集成一个 Set调用userMapper.selectByIds(Set)一把查出所有用户组装成 MapInteger, User再循环填充到 VO 里。这一步做完原来可能要发 11 条 SQL 的接口稳稳压在 2 条 SQL 以内。私信列表这个功能性能优化的核心就是 SQL 数量控制把 N1 消灭掉接口想慢都难。5.3 索引失效的排查过程分享有一次私信列表接口突然从 20ms 涨到了 200ms我第一次跑 EXPLAIN 发现 SQL 没有走索引猜测是 where 条件里(from_id ? OR to_id ?)的两个字段分别建了索引但 MySQL 在某些情况下没有自动走索引合并导致全表扫描。排查过程和解决方案是这样的先EXPLAIN SELECT * FROM conversation WHERE from_id 5 OR to_id 5 ORDER BY last_message_time DESC发现 type 是 ALL说明全表扫描再试UNION ALL的方式把 OR 拆成两条查询再合并结果测试后性能恢复最终 SQL 改成SELECT * FROM conversation WHERE from_id ? AND status 0 UNION ALL SELECT * FROM conversation WHERE to_id ? AND status 0 ORDER BY last_message_time DESC但这只是针对特定数据分布的选择。如果你的场景数据量没那么大OR其实也不至于太差。我的建议是上线前一定 EXPLAIN 一把别凭感觉认为有索引就一定走索引MySQL 的优化器有自己的小九九数据分布不同结果也不同。6. 热门场景扩展未读推送与列表实时刷新6.1 未读推送的几种实现思路私信列表做完之后产品一般紧接着就会提“要实时未读提醒”。实现思路从简单到复杂大概有这几条路方案一前端定时轮询。每个一段时间请求一次未读数接口。开发最简单但损耗大、实时性一般适合对实时性要求不高的场景。方案二WebSocket 长连接。服务端主动推送“新消息”事件前端收到后刷新列表或播放提示音。实时性好但后端要维护连接、处理心跳复杂度上升一个台阶。方案三SSEServer-Sent Events。如果只是服务端单向推送未读提醒SSE 比 WebSocket 更轻量前端用 EventSource 就能接不需要额外引入复杂的连接管理。我自己的项目初期用的是轮询方案因为用户量不大一分钟轮询一次服务端压力完全可控。等做到实时聊天阶段再换 WebSocket。如果你现在的私信列表还停留在“打开页面才刷新”的阶段不必急着上长连接技术先把核心链路做扎实后续扩展再说。6.2 会话列表实时刷新时要注意的坑假如你上了 WebSocket当新消息推送到前端时前端一般会重新拉一次会话列表这时要注意新消息导致 last_message_time 变化列表项顺序要重新排。如果你的接口 ORDER BY last_message_time DESC刷新后自然会把最新会话排到最前面这个逻辑没问题。但有的前端会做列表项局部更新不上拉整页。这种优化更激进同时也更容易出 bug。比如 A 正在查看列表一条新消息推过来前端把对应会话项的 lastMessage 改了、unreadCount 加了 1但没重新排序那视觉上“最新会话没有置顶”用户就会觉得系统有问题。我的建议是列表刷新尽量走“后端排序 整页局部重渲染”不要让前端自己维护排序逻辑。排序是后端的事前端只负责展示。7. 实战遇到的高频报错与解决方案7.1 空指针会话为 null 时直接操作发送私信时如果会话不存在需要先插入会话。我在第一版代码里直接conversationMapper.selectByConversationId后判断 null 再插入。但有一次并发测试时两个请求同时进入都判断会话不存在都执行了插入结果出现一条重复会话。解决方式前面已经提到给 conversation_id 加唯一索引插入时捕获 DuplicateKeyException再重新查询一次会话。这里的核心是数据库兜底代码逻辑跑得再欢唯一索引永远是你最后一道防线。7.2 MyBatis 的结果映射问题下划线和驼峰我用的是 MyBatis数据库字段是last_message_timeJava 属性是lastMessageTime如果没开启驼峰映射查出来的时间是 null。这个错误太经典了我见过无数人栽在这一步。解决方法是在 MyBatis 配置里开启mybatis: configuration: map-underscore-to-camel-case: true如果项目里确实不想开这个配置那就要在 SQL 里写别名例如SELECT last_message_time AS lastMessageTime。我的建议是直接开驼峰映射省心省力别在别名上做文章。7.3 分页总数统计的性能优化分页接口要返回 total通常前端要用它计算总页数。我的第一版实现是SELECT COUNT(*) FROM conversation WHERE from_id ? OR to_id ?每次列表刷新都执行一遍。其实这个 COUNT 也是走全表扫描或索引扫描的会话多了以后也是个隐患。优化思路有两类缓存 total在会话量不变的情况下total 不需要实时计算可以放到缓存里定期失效或变更时更新。不返回 total改成“是否还有下一页”的布尔字段前端只根据这个字段判断是否继续加载。省一次 COUNT逻辑更简洁。两种方案我都用过。私信列表这种无限下滑的场景前端根本不关心总页数只关心还能不能继续滚所以第二种方案最合适。接口返回结构里加一个hasNext字段配合LIMIT pageSize 1的多查一条技巧优雅又高效。具体做法是查询时多查一条比如 pageSize 10实际LIMIT 11如果返回行数是 11说明还有下一页取前 10 条返回如果返回不到 11说明没有了直接把全部数据返回。一条 SQL 同时搞定数据和分页状态避免单独的 COUNT 查询。7.4 时间字段的时区问题私信列表展示“刚刚”“5分钟前”“昨天”这种相对时间前端通常用消息的 create_time 和当前时间计算。但如果后端返回的时间没经过时区处理或者返回的是字符串而不是时间戳前端算起来很容易出错。我建议接口返回统一用long类型时间戳或者带时区的 ISO 格式让前端自己去格式化。不要在某个地方返回Date对象在另一个地方返回String格式乱七八糟前端得写一堆兼容代码。8. 核心代码结构复盘与优化建议8.1 Controller、Service、Mapper 的代码组织私信功能的三层结构我再次复盘一下最顺手的组织方式controller/ ConversationController.java MessageController.java service/ ConversationService.java MessageService.java mapper/ ConversationMapper.java MessageMapper.java vo/ ConversationVO.java MessageVO.javaController 层只做参数接收和结果包装Service 层负责事务、组装、过滤Mapper 层只做数据库操作。分层清晰的最大好处是测试好写、问题好定位。我见过有人把所有逻辑都堆在 Controller 里一个方法三四百行真是灾难。8.2 参数校验与用户输入的安全设计私信接口必须校验参数至少要做以下几件事接收方用户 ID 必须存在且不能是当前用户自己消息内容不能为空长度限制在合理范围内比如 500 字以内敏感词过滤必须在入库前完成如果系统有“拉黑”功能发送前还要判断是否被对方拉黑。这些校验看起来琐碎但它们决定了功能是否能在真实环境里稳定跑起来。我在第一版只做了非空校验结果上线后被朋友用脚本刷了一堆空消息和超长消息列表页直接被撑爆。后来补上了长度限制和内容过滤才算消停。8.3 从列表页跳详情页的幂等性设计用户可能会重复点击同一个会话前端会连续发起多次详情请求。后端的详情接口天然是幂等的第一次请求把未读状态置为已读第二次请求查询时所有消息已经是已读状态置已读的 UPDATE 影响行数为 0不会产生副作用。这一点很关键接口设计成幂等的前端就不用心惊胆战地做防抖。唯一要注意的是查询消息历史和标记已读两个操作如果不在一个事务里可能出现“先看到未读消息列表但已读状态还没更新”的瞬时状态。解决方式前面说了Transactional 保证两者原子成功。9. 测试方法与个人心得9.1 测试数据准备与场景覆盖我在开发私信列表时建了一套固定的测试账号用来模拟真实用户行为。核心测试场景包括新用户首次使用会话列表为空接口返回空数组前端不能报错单一会话、多个会话、大量会话三种数据量下的列表响应时间未读数为 0 和未读数很大的情况前端显示要符合预期A 给自己发消息的边界情况如果后台允许一方删除会话后另一方的会话和未读数不受影响敏感词过滤后列表页摘要和详情页内容保持一致。这些场景看起来基础但能覆盖私信功能百分之八十以上的业务边界。测试数据不用多够用就行关键是场景要全。9.2 性能压测的简单思路压测不必一上来就上专业工具先用 Postman 或者脚本把核心接口测一遍知道响应时间和吞吐量大概什么量级再决定要不要深度排查。我在本地用的是一个简单的 Java 脚本模拟 100 个用户并发发送私信、并发拉取列表然后观察接口平均响应时间和 P99 响应时间数据库连接池是否被打满是否有死锁或锁等待超时。测试结果给了我两个优化方向开连接池并且合理配置最大连接数并发写请求不多时采用批量插入和事务合并能有效降低数据库压力。这两个方向在真实项目里都有用尤其是压测发现数据库连接池满的时候别急着加机器先看是不是客户端连接管理有问题。9.3 排查线上问题时的系统化思路线上如果出了私信相关的 bug我一般按这样的顺序排查先看异常日志有没有 SQL 异常、空指针、非法参数再看慢 SQL 日志确认是不是接口响应慢、SQL 走了全表扫描用测试账号复现观察接口入参和返回查会话表和消息表的数据确认脏数据产生的原因。这套流程说起来简单但真的能解决大部分问题。我遇到过最经典的一次线上问题是用户反馈“发消息后对方收不到”。查了半天发现消息写进了数据库但会话表的 conversation_id 因为大小写或空格原因和消息表不一致导致会话列表查不到这条消息。最后定位到是用户昵称里带了特殊字符拼接 conversation_id 时出了问题。从那以后我给 conversation_id 的生成加了一个强校验只允许数字和下划线乾坤大挪移级别的 bug 再没出现过。10. 写在最后的个人体会私信列表功能做完以后我最大的体会是后端开发的复杂度往往不在“技术不会”而在“细节忘做”。会话ID的生成规则、未读计数的更新条件、已读标记的准确定位、分页查询的深分页问题这些单拎出来都是很小的点但组合在一起足以决定一个私信功能是好用还是难用。我再分享一个自己常用来检验代码质量的土办法把自己当成用户对着页面把功能从头到尾点一遍每点一步就想想后端发生了什么。比如“我看到列表第 3 个会话有红点点进去红点消失退出来第 3 个排到第 1 个”如果你能在心里把这个过程对应的 SQL 和事务流程完整说出来说明代码是通的如果哪个环节想不通那这里八成埋伏着 bug。这套私信列表的设计方案不一定要原封不动照搬但核心的思路——会话表和消息表分离、冗余未读数、事务保证一致性、分页用游标、避免 N1 查询——放到任何社区类项目的私信功能里都是可以复用的。如果你正在做类似的功能希望这篇能帮你少踩几个坑。
阅读完成 · 觉得有帮助?