
做社交平台的私信模块之前我也和大多数人一样觉得不就是两张表的事嘛——消息表存内容会话表改未读数。但等这个功能真的放到线上面对大V发消息、群发通知、多端同时在线这些场景时那套同步写库轮询刷新的简单方案根本扛不住。这套系统的设计过程和最终落地结果让我把 SpringBoot、RabbitMQ、Redis、MySQL 这四个技术栈在私信领域该怎么配合用彻底想明白了。这篇文章不只是记录实现了什么更想把每一步为什么这么设计讲清楚。私信发送、已读状态同步、历史消息缓存这三个需求各自都有隐藏的坑已读不是简单改个字段缓存不是把数据塞进 Redis 就完事消息队列也不是接了就能削峰。有意的内容咱们一步步拆解落地的代码和配置也会一并给出。1. 为什么私信系统必须上消息队列和缓存先看清业务模型的真实压力很多私信模块初期用同步写库的方式跑用户量小看不出问题等日活上来就崩。要理解这套架构得先分析私信业务到底在承受什么样的流量特征。1.1 私信流量的三个反直觉特征第一是写多读多且集中。普通内容产品是读多写少但私信是典型的热点用户流量模型一个头部博主发一条消息可能同时推给几万个粉丝的会话列表一个在线客服账号高峰期每秒要处理几十条用户咨询。这种场景下数据库的写入压力不是平均分布的而是集中打在少数几个热点会话上。第二是已读状态属于高频小写。用户每看一条消息理论上都要变更一次已读状态。如果按每条消息一个已读字段来设计一次打开会话可能需要执行几十上百次 UPDATE。更麻烦的是用户会频繁切换会话、反复上下拉写操作产生的锁竞争会直接拖垮 InnoDB。第三是历史消息读取要求低延迟。用户打开和某个人的聊天窗口期望是瞬间看到之前的聊天记录。如果每次都要走一次 MySQL 查询在消息量大的会话上很容易出现几百毫秒甚至秒级的延迟体感非常差。1.2 为什么同步写库的方式必然撑不住直接同步写数据库的方案有两个硬伤一是数据库连接和磁盘 IO 的瓶颈。私信消息写入需要 INSERT 消息表、UPDATE 会话表、UPDATE 未读数三个操作加在一起的事务耗时在 5-10 毫秒但数据库连接池通常只有几十个连接。压测到每秒几百条消息时连接池就会被占满后面的请求全部排队。二是突发流量无法削峰。运营做活动、系统发通知时消息量可能瞬间涨到平时的十倍。如果依赖同步写库MySQL 的 TPS 上限就锁死了系统的整体吞吐量。要么扩容机器成本高且浪费要么把流量挡在系统外丢消息没有第三条路。1.3 三个组件各管一段MQ 削峰、Redis 提速、MySQL 兜底这套系统的职责分配简单说就是让每个组件干自己最擅长的事RabbitMQ解决突发流量怎么接住的问题。客户端发消息先进队列消费端按照自己的节奏落库数据库永远不会被瞬时峰值打满。已读回执、状态变更这类允许延迟的操作也走队列异步处理。Redis解决高频读写怎么扛住的问题。会话列表、最近消息、未读数、已读游标这些访问频率极高、但能容忍秒级丢失的数据放在缓存里读写都在微秒级完成。MySQL解决数据最终放哪的问题。所有消息、会话关系的最终状态以数据库为准Redis 里的数据丢了可以从库里恢复MQ 消费失败了也有重试机制。我把这个结构叫做生产者快、消费者稳、存储全。任何一步都不依赖上一步实时完成系统就有了缓冲和重试的空间。下面详细拆每条链路的设计。2. 私信发送的全链路设计从发送接口到消息落库再到实时推送私信发送这个动作看似只是把内容发给对方实际上后端要串联接口校验、幂等去重、消息队列、数据落库、收件人推送五段逻辑每一段都值得单独设计。2.1 发送接口层的幂等设计客户端重试不是异常是常态客户端的网络环境不稳定用户点了发送但没收到响应应用层通常会自动重试。如果服务端不做幂等处理同一条消息就可能被写入两次。所以发送接口的第一步不是校验内容而是去重。我会让客户端在生成消息时带上一个全局唯一的clientMsgIdUUID 或雪花算法生成服务端收到请求后先执行 Redis 的SETNX操作以im:msg:dedup:{clientMsgId}为 key能写入说明这条消息是第一次进来否则直接返回上一次的处理结果。Boolean first redisTemplate.opsForValue() .setIfAbsent(im:msg:dedup: clientMsgId, 1, Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(first)) { return Response.warn(重复请求请勿重发); }这个 key 设置了 10 分钟过期刚好覆盖客户端最长重试窗口。即使 Redis 里这个 key 因为过期被清理MySQL 里的唯一索引(client_msg_id)也会兜底拦截重复数据。2.2 消息体设计交换机、路由键和队列分片消息进来之后组装成统一的消息体丢进 RabbitMQ。我的消息体结构里除了内容本身还会带上sessionId、fromUid、toUid、msgType、content、clientMsgId和timestamp这几个核心字段。{ msgId: 12890371823910231, sessionId: 9823718237123, fromUid: 10086, toUid: 10010, msgType: TEXT, content: 晚上一起吃饭吗, clientMsgId: uuid-xxxx-xxxx, timestamp: 1718000000000 }RabbitMQ 这边我采用了一个 Topic 交换机加多个队列的方案。所有消息发到同一个交换机路由键按会话维度做分片比如im.message.{sessionId % 8}这样每个分片队列只处理一部分会话的消息避免单个队列的消费者成为瓶颈也方便后续水平扩展消费组。有人认为单聊场景消息量不大用直连交换机默认队列就够了。但一旦后面接入群聊或者系统通知路由键分片的优势就体现出来了。与其到时候改架构不如一开始就留好扩展位。2.3 消费端落库与推送的分工消费者拿到消息后不能只做写库这一个动作。完整的处理链是校验消息在 MySQL 中是否已存在唯一索引兜底幂等插入im_message消息表更新im_conversation会话表的最后一条消息信息写 Redis 的会话最近消息 ZSET更新收件人的未读数字Redis推送实时通知走 WebSocket 网关这里有个容易出问题的地方MySQL 落库和 Redis 更新、推送通知这三件事不能放在同一个事务里。MySQL 的事务无法回滚 Redis 的操作Redis 的操作失败也不应该回滚已落库的消息。我的做法是消息表与会话表的状态更新放在一个本地事务里事务提交成功后再执行缓存更新和推送。如果缓存更新失败就丢进一个本地重试队列或延迟队列RabbitMQ 的延迟插件里几秒后重新消费。推送通知走 MQ 的另一个队列im.push.notify推送失败不影响消息本身的落库。这里有一个实际测出来的数据供参考4 个消费者实例、每个实例并发数为 8单条消息从进入队列到落库完成平均耗时 30 毫秒左右。RabbitMQ 的吞吐量在这个业务场景里远远不是瓶颈真正的瓶颈后来落在 MySQL 的连接池上所以消费端的并发数不建议盲目调大要和数据库连接池匹配。2.4 消息可靠性三件套缺一不可用 MQ 之后最怕的不是慢而是丢消息。我在这个项目里把可靠性分了三层来保障发送端确认Spring Boot 里开启 publisher-confirm-type: correlated发送方可以拿到 Broker 的确认回调没确认的消息触发重发逻辑。持久化交换机、队列、消息都设置 durableRabbitMQ 重启后消息不丢。消费端手动 ack关闭自动 ack消费者处理成功后手动确认处理失败则消息回到队列重新投递超过重试次数后进死信队列由修复程序人工处理。spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true listener: simple: acknowledge-mode: manual retry: enabled: true max-attempts: 3这三层配置到位后消息丢失的隐患基本被堵住了。剩下的风险点在业务侧比如消息落库了但手动 ack 前进程崩溃导致消费者重启后重新消费此时幂等去重机制会拦截重复消息。3. 核心表结构设计消息表、会话表、已读游标字段级拆解表结构是整个系统最实在的部分。很多人在私信模块上踩坑都是因为表设计时没考虑清楚单聊场景的读写模式要么索引建错要么把已读状态设计成了行级更新。3.1 私信消息表别把消息体和业务状态混在一张表里im_message消息表是纯消息内容存储不承担已读未读的实时更新压力。字段设计遵循几个原则用一个自增id作为主键同时作为消息排序的唯一凭证session_id记录所属会话由双方 uid 生成小号在前大号在后确保同一会话唯一消息内容单独存content字段图片、语音等多媒体类型存引用地址client_msg_id加唯一索引兜底幂等CREATE TABLE im_message ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, session_id bigint(20) NOT NULL COMMENT 会话IDminUid_maxUid拼成, from_uid bigint(20) NOT NULL, to_uid bigint(20) NOT NULL, msg_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 1文本 2图片 3语音 4视频, content text NOT NULL, client_msg_id varchar(64) NOT NULL COMMENT 客户端生成的消息幂等ID, deleted tinyint(1) NOT NULL DEFAULT 0, created_at bigint(20) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_client_msg_id (client_msg_id), KEY idx_session_id_id (session_id, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;核心索引只有(session_id, id)这个联合索引。查询某一个会话的历史消息时只需要一条索引范围扫描就能取到数据不需要额外排序。索引数量尽量控制在最少因为私信表的写入频率非常高每多一个索引就意味着每次 INSERT 要多维护一棵 B 树写入速度会明显下降。3.2 会话表为什么要用游标而不是布尔字段记录已读很多初级设计会在消息表里加一个is_read字段每次用户读消息就 UPDATE 一条记录。这在聊天场景是非常糟糕的设计——一次加载 50 条消息就要执行最多 50 次 UPDATE数据库被锁得死死的。我用的是会话级已读游标方案。每个会话只需要记录三个关键值最后一条消息 ID、用户已读到的最大消息 ID、未读数量。CREATE TABLE im_conversation ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, session_id bigint(20) NOT NULL, user_id bigint(20) NOT NULL, peer_uid bigint(20) NOT NULL, last_msg_id bigint(20) NOT NULL DEFAULT 0 COMMENT 会话最后一条消息ID, last_read_msg_id bigint(20) NOT NULL DEFAULT 0 COMMENT 当前用户已读到的最大消息ID, unread_count int(11) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_session_user (session_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;会话表以(session_id, user_id)为维度存储也就是说一个两个人的会话有两条记录分别记录双方各自的已读状态。用户把某条消息标记为已读时只需要 UPDATE 一行last_read_msg_id和unread_count彻底摆脱了每条消息更新一次的噩梦。3.3 历史消息分库分表现在就要想但不能过度设计私信消息表的增速远超普通业务表。一个日活 10 万的平台平均每人每天发 20 条消息一天就是 200 万条一年超过 7 亿条。单表存 7 亿行是不可行的所以分表要提前规划。我采用按session_id哈希取模的算法分成 16 张表后续可扩到 64 张这样同一个会话的所有消息集中在同一张物理表里查询会话历史时不需要跨表合并结果。// 会话ID是一个bigint由两个uid拼接而来 String tableName im_message_ (sessionId % 16);这个方案在单聊场景里是最实用的避免了按用户维度分表后一个会话的消息分散多表的问题也避免了按时间分表后热点数据倾斜的问题。等到单表数据量真正超过千万级再考虑用 Elasticsearch 或 TiDB 做全量历史检索但目前的分表方案撑两三年没有问题。4. 已读状态同步最容易做糙、也最容易出问题的一环已读回执在微信这类产品里属于润物细无声的功能但实现起来技术点非常密集。用户在聊天窗口里看到对方已读这一瞬间背后经历了 Redis 原子更新、异步落库、实时推送三个步骤任何一步设计不到位就会出现已读了但发送方看不到这种诡异现象。4.1 客户端上报累计确认的思路客户端不会一条一条上报已读而是上报一个游标。用户停留在某个聊天窗口时客户端只要跟服务端说这个会话我已经读到第 12345 条消息了服务端就知道小于等于这个 ID 的消息全部视为已读。接口设计为PostMapping(/api/v1/im/conversation/read) public ResponseVoid markRead(RequestBody MarkReadRequest request) { // request: { sessionId: 9823718237123, lastReadMsgId: 12345 } }这个设计参考了 TCP 滑动窗口的累计确认思想。优点很明显不管用户看了 1 条还是 100 条消息一次请求就完成网络重试也不会导致已读状态错乱。4.2 Redis 原子更新Lua 脚本保证会话级别的顺序一致用户打开会话的瞬间会触发已读上报同时可能有多端并发手机和电脑同时在线进来如果用先查再改的普通逻辑很容易出现旧游标覆盖新游标的问题。我用 Redis 的 Lua 脚本做整个更新操作的原子化。脚本拿到当前的已读游标只有新上报的值更大时才更新并同步算出未读数变化local last_read tonumber(redis.call(HGET, KEYS[1], last_read) or 0) local new_read tonumber(ARGV[1]) if new_read last_read then local last_msg tonumber(redis.call(HGET, KEYS[1], last_msg) or 0) local old_unread tonumber(redis.call(HGET, KEYS[1], unread) or 0) local decrement math.min(new_read - last_read, old_unread) redis.call(HSET, KEYS[1], last_read, new_read) redis.call(HINCRBY, KEYS[1], unread, -decrement) return decrement end return 0这个脚本是一个原子操作不会出现两个设备同时上报、后到的旧值把新值覆盖掉的问题。Redis 单线程模型保证了脚本执行的串行性所以不需要额外加分布式锁。4.3 异步落库为什么已读状态不能同步写 MySQL已读状态的特点是量大但容忍延迟。用户点开一个会话无需等数据库更新完成再返回同时如果每个已读请求都同步 UPDATE MySQL在极端情况下比如运营人员群发消息后大量用户同时打开会话数据库会被瞬间打满。我的做法是把已读上报先快速写入 Redis然后丢一条异步任务到 RabbitMQ 的im.read.sync队列由消费者批量更新 MySQL。消费者每处理一条任务就把 Redis 里的已读游标和未读数同步到im_conversation表使用条件更新UPDATE im_conversation SET last_read_msg_id #{newReadMsgId}, unread_count #{newUnreadCount} WHERE session_id #{sessionId} AND user_id #{userId} AND last_read_msg_id #{newReadMsgId};注意最后那个last_read_msg_id newReadMsgId条件这又做了一次幂等保护。哪怕 RabbitMQ 重复投递或者早先的消息后到也不会把已读位置往后拉低。4.4 已读回执实时推送单独走一条队列已读状态更新之后发送方如果在线需要立刻收到对方已读的通知。这个推送不能和普通消息推送混在同一个链路里因为已读回执的优先级更高、数据量更小、延迟要求也更苛刻。我单独开了一个im.receipt.notify队列消费者专门处理已读回执事件通过 WebSocket 网关推送一条轻量级事件给发送方。之所以要单独开队列是因为如果和普通消息混在一起一旦消息量太大发生积压已读回执就会被堵在后面用户会抱怨明明已读了对方却看不到已读标记。{ type: RECEIPT, sessionId: 9823718237123, fromUid: 10010, toUid: 10086, lastReadMsgId: 12345, timestamp: 1718000001000 }这个模块还有一个容易踩的坑用户可能只打开会话看了一眼就退出服务端确实把已读状态写进去了但发送方页面刚好没刷新WebSocket 推送的事件被丢弃。所以客户端收到已读回执后要以服务端返回的lastReadMsgId为准更新本地 UI而不是简单地在收到推送时刷新一下。5. 历史消息缓存设计Redis 不等于万能缓存读写路径要分层聊天的核心体验就是打开会话必须快。历史消息如果每次都穿透到 MySQL在分表之后还要走一次路由和索引扫描延迟很难压下来。我用 Redis 做了一层会话维度的消息缓存和数据库形成两级读路径。5.1 缓存结构选型为什么用 ZSET 而不是 String 或 List历史消息缓存的访问模式有两种拉取最近的 N 条记录以及按游标翻页上拉加载更早的消息。这需要 Redis 里的数据结构天然支持按时间范围取数和分段读取。我用的是 ZSET有序集合成员是消息 ID分数是消息的创建时间。这样每次新消息写入时执行ZADD按时间范围查询时执行ZREVRANGEBYSCORE天然支持从最新往旧翻页# 写入新消息score用消息ID也可以但要保证递增 ZADD im:sess:{sessionId}:msgs 1718000000000 12890371823910231 # 拉取会话最新20条 ZREVRANGE im:sess:{sessionId}:msgs 0 19 WITHSCORES为什么不直接用 List因为 List 按索引翻页没问题但按某条消息之前的 20 条这种游标翻页很不方便。为什么不直接存整个消息对象因为 ZSET 的 member 是字符串直接存 JSON 内容体积大、内存浪费而且缓存里只要保存轻量的消息摘要翻页加载后面还需要查详情时再补全。我的做法是 member 存消息 ID消息的摘要信息消息类型、内容前 100 字、发送者、时间戳存储在另一个 Redis Hash 里key 为im:msg:{msgId}。这样既控制了 ZSET 的内存占用又能在列表页直接展示摘要只有点击图片或语音时再去查完整内容。5.2 写路径Cache Aside 的变种不再先删缓存传统缓存一致性方案里有先更新数据库再删除缓存和先删缓存再更新数据库两种但都不完全适合 IM 场景。私信消息是只追加的不会发生改旧消息的行为所以不存在更新缓存时数据不一致的问题直接用先写库、再写缓存的追加策略就好。消费者落库成功向 Redis 写入时同时做一次 ZSET 长度裁剪防止单个会话的缓存无限膨胀# 写入 ZADD im:sess:{sessionId}:msgs {timestamp} {msgId} # 保留最近200条移除更早的 ZREMRANGEBYRANK im:sess:{sessionId}:msgs 0 -201每个会话的 ZSET 只保留最近 200 条按一条消息摘要平均占用 200 字节计算一个热点会话最多占 40KB 内存500 万个会话全量缓存大约 200GB生产环境按 8 台 32GB 内存的 Redis 实例即可覆盖。如果缓存写失败我会把消息 ID 丢进一个 redis-repair 重试队列由定时任务补写保证缓存最终一致。5.3 读路径缓存命中、缓存穿透和回填策略打开会话页时读路径是这样先查 RedisZREVRANGE取最近 200 条消息 ID再批量查消息摘要 Hash一次性返回列表数据如果 Redis 的 ZSET 不存在比如第一次打开这个会话回源到 MySQL 查最近 200 条回填 Redis这个回填动作要防缓存击穿。如果大批用户同时打开一个从未访问过的会话比如某个新群刚刚拉起来数据库会被同时穿透。我采用单飞策略同一个 session 的回填动作只允许一个线程执行其他请求先等这个回填完成用 Redis 分布式锁实现String lockKey im:sess:lock: sessionId; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { ListMessage history loadFromDb(sessionId, 0, 200); rebuildCache(sessionId, history); }5.4 缓存过期与淘汰不是所有会话都值得常驻内存有些用户的会话三个月没打开聊天记录还占着 Redis 内存就不划算了。我的策略是给会话消息 ZSET 设置一个活跃访问即延长的逻辑用户每次打开会话后台刷新这个 key 的 TTL 为 30 天超过 30 天没有访问的会话让缓存自然过期后续打开时走回填流程。这样内存里永远只保留活跃会话的数据冷门会话的缓存被自动淘汰数据库作为最终的数据源兜底。即使 Redis 实例重启导致全部缓存丢失最坏情况也只是用户打开会话时多等一次 MySQL 的查询时间数据不会丢。6. SpringBoot 整合细节序列化、连接池和 RabbitMQ 队列声明架构设计再合理工程落地时也会被各种细节绊住。下面把 SpringBoot 整合 RabbitMQ 和 Redis 时容易忽略的配置逐条说明包括序列化坑、手动 ack 和 channel 被关闭的问题。6.1 Redis 模板的序列化问题JDK 序列化是隐形炸弹SpringBoot 默认的 RedisTemplate 使用 JDK 序列化存进 Redis 里的 key 会带一串奇怪的二进制前缀可读性极差而且和 Lua 脚本、命令行操作完全不兼容。我的统一配置是使用 StringRedisSerializer 序列化 key使用 Jackson 序列化 value同时把 value 的类型显式指定为 JSON 字符串避免对象序列化后的类信息不可控Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; }在 Redis Desktop Manager 里能看到im:sess:123:msgs这样的清晰 key 结构排查问题时一眼就能定位到是哪条链路的缓存数据。6.2 RabbitMQ 消费者配置手动 ack 和并发数怎么定消费者这块的核心配置有三项acknowledge-mode 必须设为 manual、prefetch 要合理设置、并发消费者数量和 MySQL 连接池匹配。spring: rabbitmq: listener: simple: acknowledge-mode: manual prefetch: 50 concurrency: 4 max-concurrency: 8 retry: enabled: true max-attempts: 3prefetch设为 50意思是消费者一次从队列拉取 50 条消息到本地缓存处理完再取下一批。这样避免每条消息都走一次网络 RPC提高消费吞吐量。concurrency根据数据库连接池大小设置我这边 MySQL 连接池最大 20消费者并发设为 8每个消费者处理消息时占用一个数据库连接不会把连接池耗尽。6.3 队列和交换机声明建议用代码声明而不是页面手动创建RabbitMQ 的队列如果在管理页面手动创建代码里一旦声明参数不一致就会报 406 PRECONDITION_FAILED也就是常说的 clean channel shutdown 错误。这个错误的根因是队列已存在但代码声明时指定的 durable、auto-delete、arguments 等参数和实际队列不一致。RabbitMQ 不允许修改已有队列的参数只能删除队列重建线上环境删除队列会导致消息丢失非常危险。所有队列、交换机、绑定关系我都建议用 Java 代码显式声明并固定参数Configuration public class RabbitMQConfig { public static final String EX_IM_MESSAGE ex.im.message; public static final String Q_IM_MESSAGE q.im.message; public static final String Q_IM_READ_SYNC q.im.read.sync; public static final String Q_IM_RECEIPT_NOTIFY q.im.receipt.notify; Bean public TopicExchange imMessageExchange() { return ExchangeBuilder.topicExchange(EX_IM_MESSAGE).durable(true).build(); } Bean public Queue imMessageQueue() { return QueueBuilder.durable(Q_IM_MESSAGE) .withArgument(x-dead-letter-exchange, ex.im.dlx) .withArgument(x-dead-letter-routing-key, im.message.dlx) .build(); } Bean public Binding imMessageBinding() { return BindingBuilder.bind(imMessageQueue()) .to(imMessageExchange()) .with(im.message.*); } }这样做的好处有两条一是任何环境测试、生产启动 SpringBoot 项目时队列参数都保持一致避免人为误操作二是代码即文档后来接手的人不用去 RabbitMQ 管理页面翻队列配置。6.4 排查 channel shutdown 的完整思路如果真的碰到了rabbitmq cause: clean channel shutdown; protocol method: #methodchannel.close(reply-code406, reply-textPRECONDITION_FAILED ...)排查链路是这样先看 reply-text 后面的描述它通常直接告诉你哪个参数不一致比如inequivalent arg durable for queue q.im.message in vhost /说明 durable 参数对不上去 RabbitMQ 管理页面查看实际队列的 Features 和 Arguments 一栏对比代码里的QueueBuilder声明参数和实际参数差异如果是环境刚搭建、队列里没有重要数据直接删除队列重建如果是线上队列且存在积压消息得新建一个不同名字的队列把消费者切换过去再用 shovel 或生产者改路由把流量迁过去这个坑我在联调环境踩过一次当时就是同事手动创建了队列又没设置持久化我这边代码声明 durabletrue双方一直互相报 406排查到凌晨才发现是参数不一致。7. 实测压测数据与高频聊天场景的性能调优设计文档写再多最终要靠数据说话。这一节记录我在测试环境压测得到的一组真实数据以及针对高频聊天场景的三个重要调优。7.1 压测场景与结果测试配置消息队列 4 个消费者实例并发 8MySQL 8.0 单实例连接池 20Redis 单实例。压测工具使用 JMeter模拟 500 个并发用户持续发送私信 5 分钟。指标数据总发送消息数约 280 万条平均发送接口 RT11ms消息从入队到落库平均延迟35ms消费者单实例吞吐约 1800 条/秒MySQL 平均写入 QPS约 4600Redis 读写平均耗时0.5ms 以下同步写库方案在相同配置下做了对比压测500 并发时 MySQL 连接池直接被打满接口 RT 飙升到 800ms 以上错误率超过 15%。加 MQ 后虽然整体链路变长但发送接口的 RT 反而降下来了因为发送方只需要写 Redis 和 MQ不需要等待数据库落盘。7.2 优化一已读状态批量合并上报客户端打开会话时如果用户快速翻动聊天记录会产生很多个已读游标比如先读了 100 条又读了 150 条。如果每个游标都上报一次服务端就要处理大量重复的更新。客户端侧对已读上报做节流处理会话打开期间只上报最大游标且最多 3 秒上报一次服务端 Redis 里只保留最新值即可不需要处理中间值。7.3 优化二消费端批量写库而非逐条插入消费者默认一条消息一次 INSERT在热点消息场景存在严重的往返浪费。我改成用消息批处理消费者每取到一批消息比如 50 条攒够一定数量或超过 100ms 再执行批量 INSERT。同一个会话的消息在批处理时连续写入MySQL 的插入效率大幅提升实测写库耗时从单条 5ms 降到批量平均 1.5ms/条。7.4 优化三热点会话的消息隔离如果平台有头部用户某个会话可能一直处于高频写入状态。为了防止一个热点会话占满某个分片队列的消费者资源我给每个分区队列增加了一个最大积压水位检测通过 RabbitMQ 管理 API 定期读取队列积压数。积压超过阈值时动态扩容消费者实例同时把热点会话的写入请求在业务层做限流避免单个用户的发送频率拖垮整体系统。8. 设计复盘这套方案能解决的边界和未来扩展方向私信系统上线到现在运行了几个月整体稳定。消息没有出现丢失已读状态偶尔有秒级延迟但用户无感知MySQL 的连接池没有再被打满过Redis 的内存占用也在预期范围内。这套设计的核心价值是把高并发瞬时写入和可靠落库之间的冲突通过 MQ 解耦把高频读取和数据库性能瓶颈之间的冲突通过 Redis 缓存解决。如果以后要扩展群聊功能只需要在会话维度上增加一个群成员表消息表的 session_id 改成群 ID已读游标按每个群成员分别记录即可如果要增加搜索功能历史消息可以异步同步到 Elasticsearch。这些扩展都不需要改动当前的基础链路。最后说一个实际体会做技术选型前先算清楚业务量级比直接套用高并发架构重要得多。私信功能早期用同步写库跑了很久也没出事是因为用户量还没到临界点。等日活上来再重构迁移成本远比一开始就按这套体系设计要高。如果让我重新做一次我仍然会优先选择 SpringBoot RabbitMQ Redis MySQL 这个组合——它不像很多重型框架那么复杂但每一项能力都刚好打在私信业务的痛点上。