尧图精选

基于SpringBoot+RabbitMQ+Redis的私信系统架构设计与实战

🕒 发布时间:2026/10/1 17:36:18 📁 来源:尧图网络
私信系统这东西看着功能简单不就是你发一条、我收一条嘛。可真要在一个日活几十万的社交平台上落地从消息不丢、不乱序到已读回执实时同步再到历史消息秒开不卡顿每一环都是坑。我去年完整做了一版基于 SpringBoot RabbitMQ Redis MySQL 的私信模块从设计文档到上线压测都跑了一遍这篇就把整套方案、核心代码思路和踩过的坑一起梳理出来给正在设计 IM 或站内信的同学做个参考。1. 整体架构设计与核心思路拆解先讲清楚为什么选这套技术栈。私信系统的核心诉求有三个发送要快、状态要准、历史要全。这三个诉求单靠任何一款中间件都搞不定必须各司其职。1.1 技术选型背后的取舍逻辑MySQL 作为最终数据落库的唯一真相源存全量消息和历史记录。RabbitMQ 负责异步削峰和解耦用户点发送按钮后接口立刻返回真正的推送和落库动作放到消息队列里慢慢消化。Redis 则承担两个职责一是热点消息的缓存让用户翻聊天记录时不用每次都查 MySQL二是已读状态和未读计数的瞬时存储因为已读状态是高频写操作直接写 MySQL 会把数据库拖垮。这套组合的本质是把写路径和读路径拆开。写路径走 MySQL RabbitMQ保证可靠性和顺序性读路径走 Redis保证性能和体验。刚开始我也纠结过要不要直接用 WebSocket 长连接做实时推送但私信场景和群聊不一样用户在线状态不稳定离线消息必须要靠消息队列兜底RabbitMQ 的持久化机制天然适合干这个活。1.2 整体数据流向全景图我习惯先把链路图画清楚再动手写代码。整个私信系统的主流程是这样的发送方调用POST /api/im/message/send接口请求体包含接收方 uid、消息类型文本/图片/语音、消息内容。SpringBoot 接口层做参数校验后先把消息主记录写入 MySQL 的im_message表此时消息状态是SENDING。同步构造消息 ID用雪花算法生成把消息内容序列化后投递到 RabbitMQ 的im.message.queue队列。接口立刻返回发送成功给客户端真正的投递动作由异步消费者完成。消费者从队列拿到消息后依次执行三个动作更新 MySQL 消息状态为SENT、写入 Redis 对应会话的 ZSET 缓存、通过 WebSocket 推送在线通知给接收方。接收方打开聊天窗口时客户端先拉 Redis 缓存的最近消息再调已读接口上报最后一条已读消息 ID。这套流程的关键在于接口层和消息投递层完全解耦。即使 RabbitMQ 短暂不可用消息也已经落进了 MySQL等 MQ 恢复后消费端还能从队列继续处理不会出现用户以为发出去了、实际丢消息的严重事故。2. 私信发送链路从接口到队列的完整实现发送链路是整个系统的入口也是可靠性要求最高的部分。我见过不少团队把消息先发 MQ 再落库顺序搞反了结果 MQ 一抖动消息就丢了。正确的做法是先落库再投递保证至少一次投递。2.1 消息表结构设计与索引优化消息表是私信系统的核心表设计时我重点考虑了三个问题查询效率、分页游标、消息状态流转。CREATE TABLE im_message ( id bigint(20) NOT NULL COMMENT 雪花算法生成的消息ID, conversation_id varchar(64) NOT NULL COMMENT 会话ID由双方uid拼接生成, from_uid bigint(20) NOT NULL COMMENT 发送方用户ID, to_uid bigint(20) NOT NULL COMMENT 接收方用户ID, content text NOT NULL COMMENT 消息内容, msg_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 消息类型1文本 2图片 3语音, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0发送中 1已发送 2已读 3撤回, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_conversation_time (conversation_id, create_time), KEY idx_from_uid (from_uid), KEY idx_to_uid (to_uid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT私信消息表;这里有两个细节必须强调。第一个是conversation_id的生成规则我采用的是min(from_uid, to_uid)_max(from_uid, to_uid)这种对称拼接方式这样不管从哪个方向发消息查会话时都能命中同一个 ID避免查历史消息时需要(from_uid A AND to_uid B) OR (from_uid B AND to_uid A)这种烂索引。第二个是查询历史消息时不要用 OFFSET 分页而是用游标分页。因为私信消息是持续增长的用户翻页过程中如果来了新消息OFFSET 分页会导致重复数据或跳数据。游标分页只需要记住最后一条消息的 create_time或消息ID下次查询用WHERE conversation_id ? AND create_time ? ORDER BY create_time DESC LIMIT 20就能稳定翻页。2.2 RabbitMQ 交换机与队列配置要点RabbitMQ 的配置直接影响消息投递的可靠性。我用的方案是直连交换机Direct Exchange 持久化队列 手动 ACK。Configuration public class RabbitMQConfig { public static final String IM_EXCHANGE im.exchange; public static final String IM_MESSAGE_QUEUE im.message.queue; public static final String IM_MESSAGE_ROUTING_KEY im.message.send; public static final String IM_DLX_EXCHANGE im.dlx.exchange; public static final String IM_DLX_QUEUE im.dlx.queue; public static final String IM_DLX_ROUTING_KEY im.dlx.routing; Bean public DirectExchange imExchange() { return new DirectExchange(IM_EXCHANGE, true, false); } Bean public Queue imMessageQueue() { MapString, Object args new HashMap(); // 设置死信交换机 args.put(x-dead-letter-exchange, IM_DLX_EXCHANGE); args.put(x-dead-letter-routing-key, IM_DLX_ROUTING_KEY); // 设置队列最大长度防止消息积压撑爆内存 args.put(x-max-length, 100000); return new Queue(IM_MESSAGE_QUEUE, true, false, false, args); } Bean public Binding imBinding() { return BindingBuilder.bind(imMessageQueue()) .to(imExchange()) .with(IM_MESSAGE_ROUTING_KEY); } Bean public DirectExchange imDlxExchange() { return new DirectExchange(IM_DLX_EXCHANGE, true, false); } Bean public Queue imDlxQueue() { return new Queue(IM_DLX_QUEUE, true); } Bean public Binding imDlxBinding() { return BindingBuilder.bind(imDlxQueue()) .to(imDlxExchange()) .with(IM_DLX_ROUTING_KEY); } }这里最值得说的是死信队列DLX。我一开始没配死信结果消费者代码有 bug 时消息一直在队列里被重新投递形成死循环。配置了死信交换机后消费失败的消息会自动转移到im.dlx.queue不会阻塞主队列排查问题时也能直接在死信队列里看到失败原因。消费者这边我强烈建议用手动 ACK 模式配合basicNack的 requeue 参数来控制消息去向。Component Slf4j public class MessageConsumer { RabbitListener(queues RabbitMQConfig.IM_MESSAGE_QUEUE, ackMode MANUAL) public void onMessage(Message message, Channel channel) throws IOException { long deliveryTag message.getMessageProperties().getDeliveryTag(); try { // 1. 反序列化消息体 // 2. 更新MySQL消息状态为已发送 // 3. 写入Redis会话缓存 // 4. 推送WebSocket在线通知 channel.basicAck(deliveryTag, false); } catch (Exception e) { log.error(消息消费失败: {}, e.getMessage(), e); // 业务异常记录日志后确认消息避免死循环 channel.basicNack(deliveryTag, false, false); } } }关于basicNack的第三个参数requeue我踩过一个大坑。最初设置为true意思是消费失败重新入队。但如果是消息本身的数据问题比如内容超长导致反序列化失败重新入队一百次也还是失败反而把 CPU 打满。后来我改成false配合死信交换机把异常消息转储到 DLX 队列人工排查后补发。2.3 发送接口幂等性设计私信发送还有一个容易被忽略的问题接口幂等。客户端网络抖动导致用户点了两次发送按钮如果后端不做幂等处理接收方就会收到两条一模一样的消息。我的方案是让客户端在请求头传一个clientMsgId客户端生成的消息唯一标识后端在处理写入 MySQL 之前先查一下im_message表里是否已存在相同client_msg_id的消息。为了这个查询高效需要在消息表加一个client_msg_id字段并建唯一索引。不光是发送接口已读上报接口同样需要幂等设计。用户频繁打开关闭聊天窗口已读接口可能被调用几十次每次都更新数据库完全没必要。后面讲已读状态时会详细说怎么用 Redis 缓冲来解决这个问题。3. 已读状态同步Redis 缓冲 MySQL 异步落库已读状态是私信系统里最容易被低估的技术点。表面上看就是一个布尔值已读或者未读。但真要做得实时且不拖垮数据库就需要仔细设计读写路径。3.1 为什么不能直接写 MySQL假设一个用户有 200 个会话每个会话有一条未读消息。用户打开 App 后客户端会并发上报 200 个已读请求。如果每个请求都直接UPDATE im_message SET status 2 WHERE id ?数据库瞬间要处理 200 条 update而且这些 update 还都加了行锁。在高峰期大量用户的已读上报会让 InnoDB 的锁竞争变得非常激烈。更麻烦的是已读接口的调用频率远高于发送接口。用户每打开一次聊天窗口就会触发一次发送一条消息只会触发一次。读多写多的场景必须用 Redis 做一层缓冲。3.2 Redis 已读状态存储方案我采用的方案是已读状态第一优先写 Redis然后通过异步批处理回写 MySQL。Redis 里维护两个维度的数据会话维度已读位置im:read:{conversation_id}:{user_id}存储用户在该会话中已读到的最大消息 ID。用 String 类型即可。未读计数im:unread:{user_id}:{conversation_id}存储用户在某会话的未读消息数。已读上报接口的逻辑很简单Service public class ReadStatusService { Autowired private StringRedisTemplate redisTemplate; /** * 上报已读状态 */ public void markAsRead(Long userId, String conversationId, Long lastReadMsgId) { String readKey im:read: conversationId : userId; // 先读Redis里的旧值比对后决定是否更新 String oldValue redisTemplate.opsForValue().get(readKey); if (oldValue ! null Long.parseLong(oldValue) lastReadMsgId) { // 已读位置没有前移直接忽略 return; } redisTemplate.opsForValue().set(readKey, String.valueOf(lastReadMsgId)); // 清除未读计数 String unreadKey im:unread: userId : conversationId; redisTemplate.delete(unreadKey); // 把本次已读事件放入异步队列等待批量落库 asyncBatchSaveReadRecord(userId, conversationId, lastReadMsgId); } }这段代码的精髓在于先用 Redis 做了去重判断。用户连续打开同一个会话十次只有第一次会真正触发后续逻辑后九次因为oldValue lastReadMsgId直接返回连异步落库的请求都不会发。3.3 已读状态批量落库策略异步落库我用的是定时批量合并策略而不是每条已读事件都触发一次数据库更新。具体做法是把已读事件放进一个内存队列或 Redis 列表定时任务每 3 秒合并一次同一个会话同一个用户只保留最大的已读消息 ID然后批量 UPDATE。Component public class ReadRecordBatchTask { Scheduled(fixedDelay 3000) public void flushReadRecords() { // 1. 从队列取出所有待落库的已读记录 // 2. 按 conversation_id user_id 分组每组取最大 lastReadMsgId // 3. 批量执行 UPDATE im_read_record SET last_read_msg_id ? // WHERE user_id ? AND conversation_id ? // 4. 同时更新 im_message 表把该会话内小于等于 lastReadMsgId // 且 status 1 的消息批量置为 2已读 } }这里要特别说明 MySQL 侧的已读记录表设计。私信已读状态不建议直接在消息表上更新每一行的 status 字段因为高并发下会产生大量行锁。我单独建了一张已读记录表记录用户在每个会话中的已读位置。CREATE TABLE im_read_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, conversation_id varchar(64) NOT NULL COMMENT 会话ID, last_read_msg_id bigint(20) NOT NULL COMMENT 最后已读消息ID, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_conv (user_id, conversation_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT已读记录表;用INSERT ... ON DUPLICATE KEY UPDATE实现有则更新、无则插入配合唯一索引uk_user_conv即使多个线程同时写也安全。这张表在查询某用户所有会话的未读数时也很有用可以直接 JOIN 会话表统计。4. 历史消息缓存Redis 如何扛住高频读取私信的历史消息读取是最典型的读多写少场景。用户翻聊天记录时每次都查 MySQL 显然不行。Redis 的 ZSET有序集合是为这个场景量身定做的数据结构。4.1 基于 ZSET 的会话消息缓存设计我用的缓存方案是双 Key结构会话消息索引im:conv:{conversation_id}类型为 ZSETmember 存消息 IDscore 存消息时间戳。消息内容im:msg:{message_id}类型为 String存放消息内容 JSON。为什么要把索引和内容拆开因为用户翻聊天记录时不一定每条消息的内容都需要立即展示。先通过 ZSET 拿到一批消息 ID再按需去取消息内容万一某条消息内容已经从缓存中淘汰了还可以回源 MySQL 查不会导致整页数据缺失。写入缓存的逻辑在消息消费者中完成public void cacheMessage(ImMessageDTO message) { String convKey im:conv: message.getConversationId(); String msgKey im:msg: message.getMessageId(); // 写入消息内容设置5天过期 redisTemplate.opsForValue().set(msgKey, JSON.toJSONString(message), 5, TimeUnit.DAYS); // 消息ID加入会话ZSETscore为发送时间戳 redisTemplate.opsForZSet().add(convKey, String.valueOf(message.getMessageId()), message.getCreateTime().getTime()); // 裁剪ZSET只保留最近500条消息ID防止key无限膨胀 redisTemplate.opsForZSet().removeRange(convKey, 0, -501); }ZSET 最妙的地方在于天然支持按时间范围取数据配合游标分页非常丝滑。用户下拉加载更多时用ZREVRANGEBYSCORE按时间倒序取消息 ID 列表再批量查消息内容。public ListImMessageDTO getHistoryMessages(String conversationId, Long cursor, int limit) { String convKey im:conv: conversationId; // 按时间倒序取小于cursor时间戳的limit条消息ID SetString msgIds redisTemplate.opsForZSet() .reverseRangeByScore(convKey, 0, cursor - 1, 0, limit); ListImMessageDTO result new ArrayList(); for (String msgId : msgIds) { String msgKey im:msg: msgId; String msgJson redisTemplate.opsForValue().get(msgKey); if (msgJson ! null) { result.add(JSON.parseObject(msgJson, ImMessageDTO.class)); } } // 如果缓存未命中部分消息回源MySQL补齐 fillFromDatabase(result, msgIds, conversationId); return result; }4.2 缓存穿透与击穿的防护手段私信场景下缓存穿透的典型场景是用户打开一个很旧的会话缓存里只有最近 500 条消息用户继续往上翻ZSET 里已经取不到更早的消息 ID。如果每次翻页都直接查 MySQL热点会话的深分页查询会拖慢数据库。我的处理方案是缓存回源标记当 ZSET 取回来的消息 ID 数量小于请求的 limit 时说明已经到了缓存边界此时直接走 MySQL 查询完整历史。为了避免同一个会话被并发穿透查询打爆数据库加了一个简单的互斥锁。public ListImMessageDTO getHistoryFromDatabase(String conversationId, Long cursor, int limit) { // 加锁防止缓存击穿 String lockKey im:lock:conv: conversationId; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (!locked) { // 拿不到锁说明有其他线程正在回源短暂等待后重试 Thread.sleep(50); return getHistoryMessages(conversationId, cursor, limit); } try { // 查MySQL历史消息 // 顺便把查到的消息重新写回缓存 } finally { redisTemplate.delete(lockKey); } }还有一个隐蔽的坑Redis 的removeRange裁剪 ZSET 时如果消息发送时间戳相同同一台机器同一毫秒ZSET 的 score 会相同Redis 内部会按 member消息 ID字典序排序。这种情况下裁剪可能误删同 score 的消息。解决方法是 score 用时间戳 * 1000 消息ID后缀确保同毫秒内的消息也有不同的 score。4.3 缓存与 MySQL 的一致性保障聊到缓存就绕不开一致性问题。我的策略是Cache Aside 延迟双删的简化版本。因为私信消息是只追加、不修改的数据撤回是极低频操作所以一致性天然比普通业务系统好做写入侧先在 MySQL 落库再通过 MQ 消费者写 Redis。因为 MQ 是 FIFO 的同一会话的消息写 Redis 的顺序和写 MySQL 的顺序保持一致不会出现缓存乱序。更新侧撤回场景先更新 MySQL 状态为撤回再删除 Redis 中的消息内容 Key。这样下次读取时缓存未命中回源 MySQL 拿到的是已撤回状态然后重建缓存。这里需要注意消息的发送中状态处理。接口层先写 MySQL 状态为SENDING然后投递 MQ消费者再把状态改为SENT。如果这时候用户立刻刷新聊天记录可能读到状态为SENDING的消息。我的处理是在查询接口做一个小优化如果消息状态是SENDING且创建时间超过 30 秒说明 MQ 消费可能失败了查询接口会直接返回消息发送失败的提示同时触发一次补偿投递。5. 实战复盘压测结果与性能瓶颈定位这套系统上线前我做了两轮压测第一轮暴露了不少问题这里把最有价值的几个问题及调优过程写出来。5.1 消息积压导致延迟飙升第一轮压测时我用 200 个并发线程持续发送私信发现消息从发送到接收的延迟从最初的 200ms 一路飙升到 5 秒以上。排查后定位到两个瓶颈第一个是 RabbitMQ 消费者的prefetch设置不合理。默认情况下 RabbitMQ 会给消费者一次推送无限多条消息消费者处理不过来消息全堆积在本地内存里。我把prefetch调整为 50意思是最多同时处理 50 条未确认消息让消费者按拉模式逐个处理。这个改动立竿见影延迟降到了 800ms 左右。spring: rabbitmq: listener: simple: prefetch: 50 concurrency: 10 max-concurrency: 30 acknowledge-mode: manual第二个瓶颈在 Redis 的批量写入。消费者每消费一条消息就同步写一次 Redis高峰期每秒要执行上千次SET和ZADD。后来我用pipeline把批量消息的 Redis 写入合并成一次网络往返性能提升非常明显。public void batchCacheMessages(ListImMessageDTO messages) { redisTemplate.executePipelined((RedisCallbackObject) connection - { for (ImMessageDTO msg : messages) { String convKey im:conv: msg.getConversationId(); String msgKey im:msg: msg.getMessageId(); connection.stringCommands().set( msgKey.getBytes(), JSON.toJSONString(msg).getBytes()); connection.zSetCommands().zAdd( convKey.getBytes(), msg.getCreateTime().getTime(), String.valueOf(msg.getMessageId()).getBytes()); } return null; }); }5.2 数据库连接池被打满压测中另一个严重问题是 MySQL 连接池被打满。排查后发现罪魁祸首是回源查询太频繁。缓存边界判断的条件不够严格导致大量请求穿透到了 MySQL。优化方案有两层。第一层是扩大缓存容量把 ZSET 的removeRange裁剪数量从 500 调整到 2000让更多历史消息留在缓存里。第二层是加了一层轻量索引缓存在 Redis 中额外存一个im:conv:meta:{conversation_id}字符串记录该会话在缓存中的最早一条消息 ID。查询时如果游标大于这个最早 ID说明缓存覆盖范围内直接走 Redis只有游标小于最早 ID 时才回源 MySQL。public ListImMessageDTO getHistoryMessages(String conversationId, Long cursor, int limit) { String metaKey im:conv:meta: conversationId; String earliestMsgId redisTemplate.opsForValue().get(metaKey); if (earliestMsgId ! null Long.parseLong(earliestMsgId) cursor) { // 游标在缓存覆盖范围内走Redis return getHistoryFromCache(conversationId, cursor, limit); } // 超出缓存范围回源MySQL return getHistoryFromDatabase(conversationId, cursor, limit); }5.3 已读状态延迟过高已读状态的体验指标是发送方要尽快看到已读回执。我最初的设计是已读上报只写 Redis定时任务每 3 秒批量落库。但发送方侧边栏的已读标记依赖 MySQL 数据导致用户看到已读回执最迟要等 3 秒。优化方案是发送方查询会话列表时已读状态直接从 Redis 读取而不是查 MySQL。因为 Redis 中im:read:{conversation_id}:{user_id}存的已读位置是实时的MySQL 落库只是为了保证数据持久性。这样已读回执的展示延迟从 3 秒降到了 100ms 以内实时性一下子就上来了。6. 上线后的稳定性保障与监控体系系统上线只是开始真正考验人的是线上稳定性。这里分享三个我在后续运维中持续优化的方向。6.1 消息补偿机制任何消息队列都不敢保证 100% 不丢消息。我上线后遇到过一次 RabbitMQ 节点重启少量消息在持久化之前丢失。虽然概率极低但私信场景对消息完整性要求很高必须要有补偿机制。我的做法是接口层落库 MySQL 时在im_message表里存一个mq_status字段0 未投递、1 已投递、2 已消费。同时启动一个定时任务每 5 分钟扫描一次mq_status 0且创建时间超过 5 分钟的消息重新投递到 RabbitMQ。Component public class MessageCompensationTask { Scheduled(fixedDelay 300000) public void compensate() { // 查询 mq_status 0 且 create_time now() - 5分钟 的消息 ListImMessage pendingMessages messageMapper.selectPendingMessages(); for (ImMessage msg : pendingMessages) { try { rabbitTemplate.convertAndSend( RabbitMQConfig.IM_EXCHANGE, RabbitMQConfig.IM_MESSAGE_ROUTING_KEY, msg); messageMapper.updateMqStatus(msg.getId(), 1); } catch (Exception e) { log.error(补偿投递失败: messageId{}, msg.getId(), e); } } } }这里有个细节补偿投递必须保证幂等。因为消息可能已经被消费者处理过只是 MQ 状态没来得及更新。所以消费者在处理消息时要先判断 MySQL 里该消息的状态是否为SENDING或SENT如果已经是SENT直接 ACK 不重复处理。6.2 Redis 内存治理与淘汰策略Redis 存储了消息内容、会话索引、已读状态、未读计数四类数据内存增长很快。尤其是消息内容 Key设置了 5 天过期但如果某个大 V 用户的消息量巨大Redis 内存还是会告急。我的治理方案是分级缓存热门会话的消息存 Redis5 天普通会话的消息存 Redis1 天冷门会话直接用 MySQL。判断热门与否的方式很简单维护一个会话的最近活跃时间活跃会话的缓存 TTL 自动续期不活跃会话的缓存到期自动淘汰。另外给 Redis 配置了allkeys-lru淘汰策略内存不足时优先淘汰最久未使用的 Key。虽然这不是最稳妥的方案但作为兜底保障是够用的。6.3 核心监控指标与告警最后说说监控。私信系统我重点盯四个指标RabbitMQ 队列积压数im.message.queue深度超过 5000 就要告警说明消费能力跟不上生产速度。消息端到端延迟从发送接口调用到消费者完成推送用 Redis 记录每个消息的处理时间超过 5 秒告警。Redis 内存使用率超过 70% 就要排查是否有 Key 异常增长。MySQL 慢查询重点监控im_read_record表的批量 UPDATE 和im_message表的深分页查询。这些指标我全部通过定时任务上报配合一套简单的告警规则。实测下来对线上问题定位帮助最大的是消息端到端延迟这个指标它能直接反映整条链路的健康状况任何一环出问题都会在这个指标上体现出来。6.4 一套可以直接抄作业的部署检查清单根据我这次实战的经验整理一个私信系统上线前的检查清单每一条都是真实踩过坑换来的RabbitMQ 的队列、交换机、绑定关系是否都声明为 durable持久化消费者是否启用了手动 ACK失败消息是否配置了死信队列转移消息表是否建了conversation_id create_time联合索引是否用了游标分页而不是 OFFSET 分页Redis 的 ZSET 缓存是否设置了裁剪上限score 是否避免同毫秒冲突已读上报是否走了 Redis 去重批量落库任务是否幂等补偿投递任务是否配置消费者是否做了重复消息判断发送接口是否用client_msg_id做了幂等这套清单我后来也用在团队其他项目的架构评审里凡是涉及消息队列和缓存的系统照着过一遍基本能堵住 90% 的坑。私信系统做完后我最大的体会是这类业务看着简单难点全在细节里。消息不丢靠的是落库顺序和补偿机制已读实时靠的是 Redis 缓冲和异步落库历史秒开靠的是 ZSET 缓存和分级淘汰。每一项单独拿出来都不难难点在于把整个链路串起来时如何保证一致性、性能和可靠性的平衡。如果这篇文章能帮你少踩几个坑那就值了。最后再分享一个建议任何涉及消息队列的系统一定提前设计好死信队列和消息轨迹日志线上出问题时能少熬夜。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →