ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

会话置顶功能设计:从is_top布尔字段到top_time时间戳排序

会话置顶功能设计:从is_top布尔字段到top_time时间戳排序 最近网上有个梗挺有意思“宝宝能不能只顶我呀哦说错了微信置顶我。”把它当段子看一笑而过把它当需求看其实非常典型。很多人做聊天类产品时都会收到类似的诉求把某个会话钉在列表最上面无论是亲人、同事、置顶群还是客服窗口。既然是CSDN技术博客我更关心的是“微信置顶我”背后那个真正的工程问题会话置顶功能到底应该怎么设计才能同时满足产品体验、后端性能和多端一致。这个功能看起来简单实际做起来很容易翻车。最典型的错误是给会话表加一个is_top字段0表示不置顶1表示置顶。单个用户只置顶一个会话时这个方案勉强能用一旦用户同时置顶多个会话问题立刻暴露谁排前面置顶会话来了新消息要不要把别的顶下去取消置顶后回到哪个位置前端排序和后端排序不一致怎么办这些问题如果不在一开始想清楚后面每次改需求都要返工。这篇文章会把“置顶一个会话”拆成一套可落地的规则和代码实现。文章会从需求规则、数据库设计、后端排序、分页与多端同步、测试和排错几个角度完整走一遍会话置顶功能的实现思路。看完之后你可以直接把方案搬到自己的 IM、电商客服或社交类项目里不需要再从一个布尔字段开始踩坑。1. 会话置顶功能从需求到实现的完整链路所有列表类产品都会面临同一个问题列表本质上是按时间排序的但用户可以人为打破这个顺序。会话置顶就是这个打破规则的典型操作。用户希望某一个会话永远出现在最上面不受新消息、新会话的干扰。听起来很简单但这意味着列表排序规则从“单一时间字段”变成了“多字段组合排序”每个字段的优先级、空值处理、时间方向都要重新定义。从技术链路上看一个完整的会话置顶功能至少包含五部分第一产品规则定义比如是否允许多个会话同时置顶新消息是否改变置顶顺序第二数据模型设计不能只存一个布尔值要保存置顶时间和置顶顺序第三后端查询与排序保证列表接口返回的数据顺序正确第四客户端展示与本地排序避免列表跳动第五多端同步和增量更新保证用户在手机、电脑上看到的顺序一致。这篇文章的核心判断是会话置顶不是一个布尔状态而是一个带时间戳的排序状态。理解了这一点上面的五个环节都能顺理成章地实现。没有理解这一点后面做的所有功能都是打补丁。2. 基础概念会话置顶、消息置顶与收藏星标的区别先澄清几个容易混淆的概念。微信聊天列表里的“置顶聊天”指的是把一个会话固定在聊天列表顶部属于会话维度的操作。钉钉或企业微信里的“消息置顶”通常是在群聊中把某一条消息固定在输入框上方属于消息维度的操作。收藏和星标则是另一种语义用户把内容标记为重要但并没有改变列表排序。这三个概念解决的问题完全不同技术实现也完全不同。会话置顶改变的是会话列表的排序需要改会话表或列表缓存消息置顶改变的是单聊或群聊页面的消息展示需要为消息增加置顶标志或单独存储置顶消息ID收藏星标通常只影响用户自己的收藏夹与正常聊天列表没有关系。可以用一个表格快速对比功能类型操作维度影响位置核心存储排序是否改变会话置顶会话会话列表顶部会话表置顶时间是消息置顶单条消息聊天窗口顶部消息表置顶标志独立展示区收藏 / 星标内容收藏夹收藏表不改变原列表很多开发包括我早期也会把这三者混在一起导致后面做“取消置顶”时顺手把收藏状态也清了。所以在需求评审阶段一定要让产品经理明确这个需求究竟是会话置顶还是消息置顶文章标题里的“微信置顶我”其实也是歧义来源。用户可能想表达的是“把我的聊天会话置顶”也可能想表达“一条消息只能置顶给我看”。如果产品经理不追问开发很可能做错方向。3. 需求拆解把“只顶我”变成产品规则既然标题是“只顶我”那我们就从这句话开始拆需求。假设用户希望某个会话被置顶产品侧需要回答以下六个问题每个问题都直接影响数据结构第一是否允许多个会话同时置顶微信允许用户置顶多个聊天这种情况下就一定要有置顶时间或者置顶序号否则无法决定谁在上面。第二多个置顶会话之间按什么排序一般按置顶操作的时间倒序刚置顶的排最上面。第三置顶会话来了新消息是否要把这个会话临时移到置顶区第一位微信的做法是置顶会话收到新消息时会保持在置顶列表里并按消息时间在置顶区内部刷新排序。第四取消置顶后会话回到什么位置通常是回到普通会话区并按最后消息时间重新参与排序。第五置顶是否需要过期时间大多数产品不做自动过期但客服系统可能会需要“置顶N小时后自动取消”。第六置顶状态是否多端同步同步到哪些端这些规则不定义清楚后端的ORDER BY就没法写。下面是一份可以参考的默认需求表规则项默认建议是否允许多个会话置顶允许多个置顶会话排序按置顶时间倒序后置顶的在上新消息是否影响置顶顺序影响新消息时间参与置顶区排序取消置顶后位置回到普通区按最后消息时间排序置顶是否有过期时间默认无是否多端同步建议同步由服务端统一排序这里特别提醒一点“只顶我”这种表达在真实需求里很容易被扩展成“只置顶我这个人其他人都不要置顶”产品经理不要被昵称带偏应直接确认操作对象和范围。开发也要在需求评审时提出极端场景如果用户把全部分组都置顶了列表就退化成纯置顶模式还需要保留普通区吗把这种边界场景在评审阶段问清楚远比上线后再改字段便宜。4. 数据库设计不要用 is_top要用 top_time很多项目的首版设计是这样的CREATE TABLE conversation ( id BIGINT PRIMARY KEY, user_id VARCHAR(64) NOT NULL, peer_id VARCHAR(64) NOT NULL, last_message_time DATETIME NOT NULL, is_top TINYINT NOT NULL DEFAULT 0 );这个表在只有一个置顶会话时是可以工作的查询时先按is_top倒序再按last_message_time倒序SELECT * FROM conversation WHERE user_id ? ORDER BY is_top DESC, last_message_time DESC;但用户置顶第二个会话后两个置顶会话的is_top都是1SQL 不知道谁更靠前只能退回按last_message_time排序。结果就是用户先置顶了会话A后来又置顶了会话B但因为A的新消息时间更晚A反而排到了B前面。这个行为往往不符合“最后置顶的排最上面”的产品预期。所以更通用的设计是把布尔字段改成可空的置顶时间字段。置顶时写入当前时间取消置顶时置为 NULL。这样置顶状态和置顶顺序都在一个字段里表达而且天然支持多会话置顶。推荐建表语句如下CREATE TABLE conversation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, peer_id VARCHAR(64) NOT NULL, last_message_time DATETIME NOT NULL, top_time DATETIME NULL, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_user_peer (user_id, peer_id), KEY idx_user_last_msg (user_id, last_message_time), KEY idx_user_top (user_id, top_time) );使用top_time代替is_top有几个明显好处置顶顺序有时间戳可以直接排序取消置顶只需要把字段置空不需要额外维护状态多端同步时只需要同步top_time字段本地也能据此排序。当然它也有代价如果数据量极大在top_time上做排序会依赖索引和分页策略。这个问题后面单独说。对应的更新置顶状态语句也很简单-- 置顶 UPDATE conversation SET top_time NOW(), update_time NOW() WHERE user_id ? AND peer_id ?; -- 取消置顶 UPDATE conversation SET top_time NULL, update_time NOW() WHERE user_id ? AND peer_id ?;注意这里不要把置顶和最后消息时间绑定到同一个更新事务里。置顶是用户主动操作新消息是系统事件两者如果互相覆盖很容易产生脏数据。最好的方式是每次发消息只更新last_message_time置顶操作只更新top_time互不干扰。到了查询层再组合排序。5. 后端实现置顶排序的 Java 代码数据模型确定后后端要做的事情就非常清晰查询会话列表然后按照“置顶区优先、置顶时间倒序、普通区按最后消息时间倒序”的规则排序。下面用一个简化的 Spring Boot MyBatis 示例来演示完整链路。先定义实体类// 文件路径src/main/java/com/example/im/entity/Conversation.java public class Conversation { private Long id; private String userId; private String peerId; private LocalDateTime lastMessageTime; private LocalDateTime topTime; public Conversation() { } public Conversation(Long id, String userId, String peerId, LocalDateTime lastMessageTime, LocalDateTime topTime) { this.id id; this.userId userId; this.peerId peerId; this.lastMessageTime lastMessageTime; this.topTime topTime; } public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getUserId() { return userId; } public void setUserId(String userId) { this.userId userId; } public String getPeerId() { return peerId; } public void setPeerId(String peerId) { this.peerId peerId; } public LocalDateTime getLastMessageTime() { return lastMessageTime; } public void setLastMessageTime(LocalDateTime lastMessageTime) { this.lastMessageTime lastMessageTime; } public LocalDateTime getTopTime() { return topTime; } public void setTopTime(LocalDateTime topTime) { this.topTime topTime; } }实际项目里可以直接使用 Lombok 的Data这里写完整是为了让大家看到字段语义。接下来是 Mapper 层。查询列表时SQL 本身也可以做一次兜底排序避免部分链路漏掉排序规则// 文件路径src/main/java/com/example/im/mapper/ConversationMapper.java public interface ConversationMapper { Select(SELECT id, user_id, peer_id, last_message_time, top_time FROM conversation WHERE user_id #{userId} ORDER BY CASE WHEN top_time IS NULL THEN 1 ELSE 0 END, top_time DESC, last_message_time DESC) ListConversation listByUserId(Param(userId) String userId); Update(UPDATE conversation SET top_time #{topTime}, update_time NOW() WHERE user_id #{userId} AND peer_id #{peerId}) int updateTopTime(Param(userId) String userId, Param(peerId) String peerId, Param(topTime) LocalDateTime topTime); Update(UPDATE conversation SET top_time NULL, update_time NOW() WHERE user_id #{userId} AND peer_id #{peerId}) int clearTopTime(Param(userId) String userId, Param(peerId) String peerId); }这里特别注意ORDER BY中的CASE WHEN top_time IS NULL THEN 1 ELSE 0 END。如果写成简单的top_time DESCNULL 值在 MySQL 中排序时会排在最前面这正好和我们的预期相反。所以要用CASE WHEN把 NULL 强制放到最后再用top_time DESC排序非空置顶时间。有些业务需要把排序规则放进 Java 代码统一管理尤其是客户端也要做本地排序时服务端和客户端必须使用同一套规则。可以抽一个独立的排序工具类// 文件路径src/main/java/com/example/im/util/ConversationSorter.java import java.util.Comparator; import java.util.List; public class ConversationSorter { private ConversationSorter() { } public static void sort(ListConversation list) { list.sort(Comparator .comparing(Conversation::getTopTime, Comparator.nullsLast(Comparator.reverseOrder())) .thenComparing(Conversation::getLastMessageTime, Comparator.reverseOrder())); } }这段代码是会话置顶实现里最容易写错的地方。很多人会写成Comparator.comparing(Conversation::getTopTime).reversed()这样确实把置顶时间倒序了但同时也会把最后一个比较器反转导致普通区的最后消息时间变成正序。正确写法是给comparing显式传入一个nullsLast(reverseOrder())的比较器只改变topTime这一列的方向最后消息时间仍然需要单独用reverseOrder()。最后提供一个服务层封装把查询、排序、置顶、取消置顶组合起来// 文件路径src/main/java/com/example/im/service/ConversationService.java import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.List; Service public class ConversationService { Resource private ConversationMapper conversationMapper; public ListConversation listConversations(String userId) { ListConversation list conversationMapper.listByUserId(userId); ConversationSorter.sort(list); return list; } public void topConversation(String userId, String peerId) { conversationMapper.updateTopTime(userId, peerId, LocalDateTime.now()); } public void untopConversation(String userId, String peerId) { conversationMapper.clearTopTime(userId, peerId); } }服务层只做三件事查询、排序、写置顶状态。实际项目里可能还要加缓存、权限校验和审计日志但核心链路就是这三步。把排序独立成工具类后单元测试也能直接覆盖不必启动整个 Web 容器。6. 运行与验证测试数据和预期排序结果写完后端代码不能只靠肉眼看代码判断对不对必须构造测试数据验证排序结果。下面这个测试用例覆盖四类典型数据置顶时间不同的会话、普通会话、最后消息时间比置顶会话更晚的普通会话、以及最后消息时间更早的普通会话。// 文件路径src/test/java/com/example/im/util/ConversationSorterTest.java import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; public class ConversationSorterTest { public static void main(String[] args) { ListConversation list new ArrayList(); LocalDateTime base LocalDateTime.of(2024, 1, 1, 0, 0); // A普通会话最后消息时间 10:00 list.add(new Conversation(1L, u1, A, base.plusHours(10), null)); // B置顶会话置顶时间 10:00最后消息时间 12:00 list.add(new Conversation(2L, u1, B, base.plusHours(12), base.plusHours(10))); // C置顶会话置顶时间 09:00最后消息时间 11:00 list.add(new Conversation(3L, u1, C, base.plusHours(11), base.plusHours(9))); // D普通会话最后消息时间 11:30比 B 的置顶时间还晚 list.add(new Conversation(4L, u1, D, base.plusHours(11).plusMinutes(30), null)); ConversationSorter.sort(list); for (Conversation c : list) { System.out.println(c.getPeerId() topTime c.getTopTime() lastMessageTime c.getLastMessageTime()); } } }按文章定义的排序规则预期输出顺序是B topTime2024-01-01T10:00 lastMessageTime2024-01-01T12:00 C topTime2024-01-01T09:00 lastMessageTime2024-01-01T11:00 D topTimenull lastMessageTime2024-01-01T11:30 A topTimenull lastMessageTime2024-01-01T10:00这个结果包含三个重要验证点置顶会话永远在普通会话前面置顶组内按置顶时间倒序B 比 C 后置顶所以 B 排第一普通组内按最后消息时间倒序D 虽然最后消息时间晚于 C 的置顶时间但它没有进入置顶区所以排在 C 后面。如果实际输出不是这个顺序通常问题就出在排序比较器的nullsLast方向上。运行这段测试不需要启动数据库因为它只依赖ConversationSorter和实体类。如果想让 SQL 层也被测试覆盖可以引入 H2 内存数据库在测试里建表并执行同样的ORDER BY比较结果是否和 Java 排序一致。这也是工程上推荐的做法同一套规则至少要用两套实现互相验证避免某一个链路出现“看着对、实际错”的问题。7. 分页、缓存与多端同步的实现细节列表功能做到后面一定会有分页。分页和置顶功能天然有冲突因为经典的LIMIT offset, size分页假设的是“列表顺序固定”。一旦用户在某页之间发生了置顶操作后续页的数据顺序可能整体前移导致用户看到重复或遗漏数据。解决这个问题没有银弹通常有三种思路。第一种是“置顶区固定长度不参与普通区分页”。后端把置顶区单独查询并返回普通区从第 0 条开始按最后消息时间分页。这种方案适合置顶数量很少的产品查询逻辑也比较清晰。第二种是“不分页置顶区只对普通区分页”这和第一种类似但对客户端的数据合并提出了要求客户端要先把置顶列表拼到普通列表前面。第三种是用游标分页以top_time或last_message_time做 where 条件而不是用offset。游标分页更稳定但需要同时考虑两个排序字段复杂度更高。缓存方面很多团队会直接缓存整个会话列表。这在小规模用户量下没有问题但一旦用户量上来缓存和数据库的一致性就会变成风险点。更推荐的做法是只缓存会话基础数据和top_time字段列表顺序始终由实时查询或统一排序服务生成。置顶操作更新top_time后缓存只需要更新对应字段不需要重建整个列表。多端同步是另一个容易被低估的问题。微信这类产品中会话列表通常有 Web、Android、iOS、桌面端等多个入口。如果置顶状态只保存在本地用户在手机端置顶了会话电脑端不会同步体验就会很割裂。建议在服务端统一保存top_time通过增量同步协议把置顶字段下发到各端。置顶操作的幂等性也很重要同一用户短时间内重复置顶同一个会话后一次操作应该只是把top_time覆盖为最新时间而不能制造重复记录。8. 常见问题与排查思路问题现象可能原因排查方式解决方案置顶了多个会话顺序总是乱使用了is_top布尔字段缺少置顶时间查看表结构和置顶操作代码改用top_time按置顶时间倒序NULL 置顶时间排在最前面SQL 直接按top_time DESC排序查看执行计划和分析返回结果使用CASE WHEN把 NULL 放最后Java 排序把所有字段都倒序了错误使用Comparator.reversed()检查排序比较器代码用nullsLast(reverseOrder())限定单字段置顶会话收到新消息后跑到普通列表置顶字段和最后消息时间字段混用更新检查消息发送流程的 SQL置顶字段只由置顶操作更新分页时看到重复数据使用 offset 分页且列表顺序动态变化复现连续翻页操作改为游标分页或单独处理置顶区手机端和电脑端置顶状态不一致置顶状态只存在客户端本地查看客户端本地存储逻辑服务端统一管理并同步top_time在线客服会话自动取消置顶失败缺少定时任务或过期字段检查服务端定时任务日志增加expire_time字段并扫描过期置顶排查这类问题时第一步永远是先看排序字段的原始值而不是直接看列表接口。很多时候前端已经把数据顺序排乱了后端返回的顺序却是对的。建议在接口调试时先打开浏览器的 Network 面板确认接口返回的 JSON 中每个会话的topTime和lastMessageTime再判断问题出在服务端还是客户端。9. 最佳实践与工程建议结合上面的实现会话置顶功能的工程建议可以总结为七条。第一条永远不要用布尔字段表达可排序状态。哪怕当前产品只允许一个置顶会话也要为未来的多置顶场景留出余量使用top_time这类可空时间字段成本几乎为零。第二条排序规则必须集中定义并且让服务端和客户端共用同一份规则。如果两边各写一套很容易出现同样的数据在不同端展示顺序不一致。比较理想的方式是由后端返回已经排好序的列表前端只负责展示不做二次排序。第三条置顶字段和最后消息时间字段要分离更新。发消息只更新last_message_time用户置顶只更新top_time避免两个高频操作互相覆盖。也可以在消息实体里冗余一份conversation_id但不要允许消息发送逻辑直接改置顶状态。第四条注意时区问题。top_time如果存储的是 UTC 时间后端的排序逻辑应该统一使用 UTC展示层再转本地时区。如果服务端和数据库时区不一致排序结果可能出现“同时置顶的会话顺序不稳定”的问题。第五条分页方案要提前设计。不要在写完列表接口后再考虑分页和置顶的冲突。如果产品允许大量会话存在尽量采用游标分页并在查询条件里把置顶区单独剥离。第六条同步协议要做到增量。多端同步时不要每次全量下发整个会话列表而是把topTime变更作为一条轻量指令下发客户端接收后更新本地缓存。这样可以大幅降低带宽和客户端渲染压力。第七条给操作加上安全和审计边界。置顶虽然是低频操作但也要校验当前用户是否有权限操作目标会话。如果是客服系统还要记录置顶操作人、操作时间和原因方便后续追溯。如果客服频繁置顶某个用户可能还需要做频率限制。10. 总结与后续学习方向会话置顶功能不是一个高难度的分布式系统但它是一个很典型的“看着简单、做起来全是规则”的列表工程。只要抓住一个核心点——置顶不是一个布尔值而是一个带时间的排序状态后面的数据库设计、Java 排序、分页和多端同步都会变得非常自然。如果你是第一次在项目里做类似功能建议先不要急着写代码而是把文章第三部分的需求规则表拿出来和产品经理逐条确认。规则确认后再动手建表、写排序工具类。这样一次开发就能覆盖掉大多数边界场景避免上线后再去补top_time字段或改排序逻辑。后续如果想继续深入可以研究三个方向一是游标分页在复杂排序规则下的实现方式它比 offset 分页更稳但写起来也更细致二是客户端本地排序与服务端排序的一致性校验如何通过埋点或端到端测试尽早发现不一致三是会话列表埋点与会话分组、免打扰、消息提醒等业务叠加时的完整数据模型。掌握了这些你就不只是会“置顶一个会话”而是真正理解了会话列表这一类功能的工程本质。
返回列表