
做AI对话系统很多人第一反应是“给大模型发请求、收回复”但真正到了高并发场景卡脖子的往往不是模型推理而是会话状态到底该怎么存。我带的项目就踩过这个坑上线初期QPS不高直接用本地内存存会话上下文一切岁月静好等流量涨到每秒几千、实例扩到十几台之后各种“上一轮对话没上下文”“用户消息串线”“发版后会话全部中断”的问题集中爆发。最后不得不做了一次从内存到RedisMySQL的架构升级。这次升级表面看是换了存储组件实际上真正难啃的是数据一致性。中间踩过不少坑也总结了一套能落地的方案今天把这套从设计、实施到排障的完整过程整理出来希望能给同样在做AI对话系统、准备搞分层存储的同学省点弯路。1. 为什么内存方案撑不住高并发先讲清楚三个硬伤1.1 AI对话系统对存储的真实需求AI对话系统和普通Web应用最大的区别在于它几乎是“有状态”的。用户在对话过程中系统必须记住这个会话之前聊了什么才能组装出完整的Prompt送给大模型。所谓上下文本质上就是一次对话里按顺序排列的历史消息列表加上当前会话的状态信息包括会话ID、用户ID、当前处于哪个阶段、Token用量、模型参数、时间戳等等。数据特征也很鲜明每个会话的消息按时间严格有序读到“上一轮”内容的频率极高几乎每次请求都要把整个上下文捞一遍而写入频率也很高每生成一轮回复就要追加一条消息此外数据有很强的时效性一个会话可能热聊十分钟然后彻底不再活跃。这种场景用生活类比就是呼叫中心的接线员。你不能只接起电话就说话还得记得前几通电话里用户报过的姓名、订单号、诉求否则对方一说“我刚刚不是说了吗”整个对话就崩了。接线员的大脑就是内存记事本就是数据库而高并发意味着同时有十万个接线员在接电话大脑不够用是必然的。1.2 本地内存方案的三个致命问题架构升级前我们用的是最简单的方案——每台应用实例在自己的JVM堆内存里放一个ConcurrentHashMapsessionId, SessionContext。单机演示、小流量测试完全没问题读写都是纳秒级也不用考虑网络开销。但流量一旦上来三个硬伤就全暴露了。第一个是单机容量天花板。假设一个活跃会话的上下文对象占10KB这已经是很保守的估算了因为要存消息列表、Token计数、中间Prompt片段。100万活跃会话就是10GB这还没算对象头、Map结构、GC复制等额外开销。堆内存设多大都不够而且GC压力会直接把服务拖垮。第二个是水平扩容后状态不共享。这是最隐蔽也最致命的问题。当实例从1台扩到10台前面挂负载均衡按轮询分发请求同一个用户的两轮对话很可能被分到不同机器。A机器内存里有用户的上下文B机器没有于是用户明明说的是“刚才那个问题再解释一下”系统却完全不记得刚才发生了什么。线上表现就是“对话断片”。第三个是发布重启即失。每次发版、扩缩容、故障重启本地内存里的会话全部清空。AI对话场景最不能接受的就是用户聊到一半系统把上下文全部忘光。而且实例重启后历史消息只能从日志里翻但日志一般只留推理请求和响应中间状态很难完整重建。1.3 升级方向热数据进Redis、全量落MySQL既然内存方案不可行那就要引入真正的持久化存储。当时团队评估过几种方案最终确定的是“Redis管热、MySQL管全”的双层架构。这背后有几个很实际的考量。Redis的读写是微秒级能支撑高并发下频繁的上下文读取它的数据结构天然适合做消息序列List、Hash、ZSet都能直接派上用场。但Redis本质是缓存内存成本高持久化机制RDB/AOF在极端场景下还是有丢失风险不适合当唯一的真相源。MySQL则相反写盘可靠、支持复杂查询和事务是保存全量会话数据的合理位置但扛不住每秒上万次的上下文读操作。所以结论不是二选一而是按数据的“热度”分层最近活跃会话的热数据放Redis全量历史会话和审计数据沉淀到MySQL。Redis这边负责扛请求MySQL这边负责保底两者通过一套一致性机制来同步。接下来重点讲这套架构具体怎么设计以及那些坑到底坑在哪里。2. 双层存储架构设计Redis管热、MySQL管全2.1 Redis层的Key设计与会话生命周期Redis层设计的第一步是定Key规范。我建议统一用session:{userId}:{sessionId}这种格式好处是前缀清晰、便于按用户维度排查也方便后续做数据迁移。千万别图省事直接拿sessionId裸存否则线上出了问题想按用户捞数据Redis的scan会让人怀疑人生。会话的元信息我用Hash结构存字段包括userId、sessionId、status、model、tokenUsage、lastActiveTime。原因很简单Hash支持单字段更新比如改一个status不需要把整个对象取出来重写这在并发场景下能少踩很多覆盖写的坑。消息序列我用List存每个会话最多保留最近20轮消息。每轮消息是一个JSON字符串包含role、content、messageId、timestamp。为什么是List而不是把消息拼成一个字符串因为List支持右侧追加、左侧裁剪RPUSHLTRIM可以非常优雅地实现“永远只保留最近20轮”。这个组合操作是原子的在高并发下不会出现读到半截消息的问题。TTL的设计也值得单独说。会话空闲超过30分钟就过期删除这是从产品角度定的“遗忘阈值”。到期后Redis会淘汰这个Key下次用户再发起请求时从MySQL回放历史消息重建上下文。如果业务要求更长周期的记忆可以把TTL放宽到24小时但要注意Redis内存成本会随活跃会话数线性增长。我算过一笔账假设单会话热数据20KB10万活跃会话就是2GB30分钟TTL意味着内存占用被控制在一个稳定水位上而不是无限膨胀。2.2 MySQL层的表结构与写入策略MySQL层要解决的是“全量可靠存储”。表结构设计上我用了两张核心表。dialog_session表存会话维度信息字段包括id、user_id、session_id、status、token_usage、created_at、updated_at。注意给session_id建唯一索引因为后面要做幂等唯一索引是兜底防线。dialog_message表存消息明细字段包括id、session_id、message_id、role、content、model、latency_ms、created_at。这里有两个关键设计一是message_id加唯一索引用于防止消息重复写入二是以session_id为维度建普通索引因为最频繁的查询是“查某个会话的全部消息”没有索引的话这张表会快速变成全表扫描的重灾区。写入策略上我强烈建议走异步落库而不是同步写MySQL。AI对话的响应链路本身就有大模型推理的几百毫秒到几秒耗时如果每一步都同步写MySQL不仅让用户等待时间变长还会把数据库连接池打爆。我们的做法是先把新消息写入Redis返回给用户成功响应然后通过消息队列把“落库”的任务异步发给消费者。消费者拿到消息后写入dialog_message再更新dialog_session的状态和updated_at。这套异步流程牺牲了一点实时性但换来了写入链路的稳定。唯一要防的是“Redis成功、MySQL失败”这种不一致后面的幂等和补偿机制就是干这个的。2.3 一条对话消息的完整读写路径从用户发送一条消息到系统返回回复完整的存储链路是这样的。第一步请求进入网关先根据用户ID和会话ID查Redis里的会话Hash。如果命中说明是活跃会话直接用List里的最近消息组装Prompt。如果没有命中就去MySQL的dialog_message表查最近20轮回填到Redis的List里再组装Prompt。第二步调用大模型接口生成回复。这期间用户可能觉得慢连续发好几条消息所以从发起请求到最终写回所有写入都要走分布式锁或幂等控制防止并发兜底时出现重复。第三步回复生成后把用户消息和模型回复作为两条记录RPUSH到Redis的List然后立刻LTRIM修剪只保留最近20轮同时更新会话Hash的lastActiveTime和tokenUsage。第四步把这两条消息投递到MQ异步写入MySQL。消费者写入前先查message_id是否已存在存在就直接跳过不存在才执行INSERT。这条链路最关键的地方在于Redis和MySQL之间的同步不是实时的存在一个短暂的时间窗口这期间系统读到的是Redis里的最新数据而MySQL里还是旧数据。这个窗口期本身不可怕可怕的是处理不当会演变成长期不一致。下面这一章就是整个架构升级里最让我长记性的部分。3. 一致性陷阱拆解我在线上踩过的四种坑3.1 先写库还是先写缓存顺序错了全盘皆输第一次做双层存储时我的第一反应是“既然Redis是缓存那就先更新MySQL再更新Redis”。结果上线不到一周就出问题了某次数据库写成功后Redis更新操作抛了异常缓存里留下的还是旧数据。之后所有读请求都命中旧缓存用户看到的上下文永远是上一次的。这就是缓存更新的经典陷阱。如果“更新缓存”而不是“删除缓存”那么缓存更新的失败率会一直困扰你。更麻烦的是即使没有异常并发场景下“后写的覆盖先写的”也会造成数据错位。比如请求A和请求B几乎同时处理同一个会话A先写库先写缓存B后写库后写缓存但B的缓存写操作先执行完A的缓存写后执行完结果就是数据库里是B的新数据缓存里是A的旧数据而且永远不会自发恢复。后来我把方案改成业界标准的Cache Aside模式读请求先读缓存未命中再读MySQL并回填写请求先更新MySQL再删除缓存。注意这里是“删除”而不是“更新”。原因是删除成本低失败了下一次读回填即可而更新需要把整个对象序列化写进去成本高还容易出错。但只删一次缓存还不够。考虑一个极端时序请求A删除缓存后、还没写MySQL的间隙请求B读到旧数据并回填了缓存然后A写完MySQL。此时缓存里是旧数据数据库里是新数据Redis这条Key的TTL如果设得长这个不一致能持续几个小时。解决方案就是后面要讲的延迟双删这里先记住结论顺序错了全盘皆输顺序对了也还要小心窗口期。3.2 缓存穿透、击穿、雪崩三兄弟别搞混这一节的内容在很多Redis面试题里都有但真正在AI对话系统里遇到时场景和教科书不太一样。缓存穿透指的是查询一个根本不存在的KeyRedis永远不会有请求每次都打到MySQL。在AI对话系统里恶意用户或者客户端Bug会拿着随机生成的会话ID来请求Redis查不到、MySQL也查不到每个请求都白白打一次数据库。解决方法是给“空结果”也加缓存TTL设短一点比如30秒同时在代码里对不存在的会话做标记过滤掉明显是在遍历ID的请求。如果穿透量特别大可以在网关层加布隆过滤器但这个方案实现成本高一些我先用空值缓存扛住了。缓存击穿指的是一个热点Key在失效的瞬间大量请求同时打到底层存储。AI对话场景下某个超级热门用户正在和AI热聊他的会话Key就是天然热点一旦TTL到期同时涌进来的几条新消息都会发现缓存未命中全部打到MySQL。解决方法是互斥锁缓存未命中时先尝试获取分布式锁拿到锁的线程负责重建缓存其他线程短暂自旋等待后重新读缓存。这个方案要有兜底超时防止重建线程挂掉导致请求全堵在锁等待上。缓存雪崩指的是大量Key在同一时刻失效或者Redis整体不可用。会话Key如果都用相同的TTL在同一个时间点集体过期数据库就会突然收到一波远超正常水位的读流量。解决方法是给TTL加随机抖动比如基础值30分钟加减5分钟同时Redis要部署哨兵或Cluster保证高可用业务侧再配降级开关。3.3 MySQL主从延迟带来的“串线”错觉读写分离是MySQL常用的扩展手段主库负责写从库负责读。但主从同步是异步的正常情况下延迟几十毫秒高峰期可能到秒级。这就带来一个很刁钻的问题用户刚发完消息系统写入了主库然后立刻发起下一轮请求读请求被路由到从库而从库还没同步到这条数据于是系统读到的是缺了最近几条消息的旧上下文。现象非常迷惑看起来像“消息串线”——明明用户刚说的话系统好像没收到。实际上不是系统丢了消息只是读到了落后的从库。解决方案是分级处理。对AI对话这种强上下文依赖的场景我用Redis做了一个“读屏障”写主库成功后立刻把最新状态写入Redis读请求优先读Redis。只要Redis里有数据就不去读从库。只有Redis未命中时才去查从库或主库。这样就绕开了主从延迟让用户总是能读到自己的最新写入。如果业务上能接受更简单的方案另一个做法是会话维度的读写都强制走主库。AI对话的读多写少但“会话读取”对一致性要求极高牺牲一点主库压力换取逻辑简单完全值得。3.4 分布式锁用错锁了个寂寞在异步落库和缓存重建的场景里分布式锁几乎是必备品。但用锁的方式不对跟没锁一样。我当时犯过一个典型错误用SETNX加锁后释放锁时直接DEL。在高并发下如果线程A持有锁超时自动过期线程B拿到新锁并完成操作后释放锁这时线程A才执行完也去DEL。结果就是把线程B的锁误删了线程C趁机进来整个互斥形同虚设。正确的做法是给锁加一个owner标记也就是唯一token释放锁时用Lua脚本先比对token再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段Lua脚本保证了“只能删自己加的锁”从根源上避免误删。同时锁的超时时间要设置得比业务操作的最长耗时大最好再加看门狗自动续期防止业务执行太久锁先过期。另一个容易被忽视的是锁的粒度。比如重建某个会话的上下文缓存锁粒度应该是lock:session:{sessionId}而不是全局一把锁。全局锁会把所有会话的缓存重建串行化高并发下等于自残。锁是为了串行化“同一会话内的竞争操作”不是为了串行化所有操作。4. 实战落地最终一致性的四种武器4.1 延迟双删到底删几次、间隔怎么定延迟双删是解决“删除缓存后、写库前有请求回填旧数据”这一窗口期的经典手段。标准流程是先删除Redis中的缓存Key更新MySQL数据休眠一小段时间例如500ms再次删除该Key第二次删除的目的是清掉步骤2完成前被其他请求回填的旧数据。那么延迟时间到底怎么定我的经验是这个值必须大于“回填旧数据”的最大耗时也就是大于一次MySQL查询和Redis写入的总时间同时还要覆盖主从同步的延迟。如果主从延迟普遍在300ms以内500ms是比较安全的起始值如果监控显示延迟峰值到2秒那就得设置成2.5秒。核心实现逻辑可以这样写public void updateSessionWithCacheInvalidate(SessionContext ctx) { // 第一次删缓存 redisTemplate.delete(session: ctx.getSessionId()); // 更新数据库 sessionMapper.update(ctx); // 延迟后二次删除 scheduledExecutor.schedule(() - { redisTemplate.delete(session: ctx.getSessionId()); }, 500, TimeUnit.MILLISECONDS); }要注意延迟双删也有失败场景。比如第二次删除时Redis暂时不可用就会留下旧缓存。所以更稳妥的做法是删除操作失败后把删除动作投递到MQ由消费者以重试机制来补偿删除而不是放弃不管。这个方案把“删除缓存”从同步调用变成了最终一致性的异步任务可靠性高了很多。4.2 幂等机制与消息队列异步补偿前面提到异步落库这里最怕的问题是消息重复投递。MQ的at-least-once语义决定了消费者可能收到重复消息一旦重复写入MySQLdialog_message表里就会出现两条相同内容后续读取上下文时会把同一句话重复拼进Prompt大模型就会产生“复读机”效果。解决方法是幂等。我给每个消息生成全局唯一messageId在dialog_message表上建唯一索引。消费者写入前先尝试INSERT如果报唯一键冲突说明消息已经处理过直接跳过。用数据库的唯一约束做幂等比在代码里先查后插更可靠因为先查后插永远有并发窗口。消息队列的另一大用途是异步补偿缓存删除。凡是需要“让Redis里的数据和MySQL保持一致”的场景都可以在MySQL事务成功后发一条“缓存失效”消息Transactional public void saveMessage(Message msg) { messageMapper.insert(msg); // 事务成功后发送缓存失效消息 rabbitTemplate.convertAndSend(cache.invalid, msg.getSessionId()); }消费者收到消息后执行缓存删除或更新。如果执行失败MQ自带的重试机制会继续投递。重试达到上限后进入死信队列由告警系统通知开发人员人工介入。这套链路保证了即使缓存删除偶发失败最终也会被补偿掉不会出现“一直不一致”的状态。4.3 binlog监听方案让MySQL主动通知缓存失效延迟双删和消息补偿都属于业务侧自己控制的一致性方案适用面广但有一个通病业务代码侵入性强每次写操作都要记得发消息、删缓存。时间一久难免有漏网之鱼。后来我引入了binlog监听方案思路是完全反过来的——让MySQL的数据变更主动驱动缓存失效。具体做法是开启MySQL的binlog并且用ROW格式记录然后通过Canal这类中间件模拟MySQL从库协议订阅binlog变更事件解析出dialog_session和dialog_message表的INSERT、UPDATE、DELETE操作再把这些事件推送到MQ由消费端执行对应的缓存更新或删除。这套方案的业务侵入性几乎为零底层数据只要变更缓存最终一定会被修正而且是数据库层面的“事实来源驱动”。但它有几个前提条件要确认清楚第一MySQL必须开启binlog否则无源之水第二binlog解析是异步的可能带来几百毫秒到秒级的延迟必须接受这一点第三Canal本身就是一套需要运维的组件部署和监控都要有专人负责。所以我的建议是分阶段来团队小、业务逻辑简单优先用消息队列补偿代码少、见效快等系统复杂度上来之后再演进到binlog监听方案把一致性问题从业务代码里彻底剥离出去。4.4 兜底策略对账任务与降级开关不管采用哪种同步方案我都强烈建议再加一道兜底防线——对账任务。它的思想很朴素后台定时扫描MySQL中最近更新的会话记录对比Redis中对应的缓存数据发现不一致就修正。对账任务的实现要考虑效率不能全表扫描。我用的方案是给dialog_session表加一个version字段每次更新数据都version version 1。对账任务只查询“updated_at在过去10分钟内且version与Redis中的版本不一致”的记录然后以MySQL为准重建Redis缓存。这样对账的扫描范围小对数据库的压力也可控。另外必须有降级开关。高并发场景下Redis不可能永远稳如泰山。我们在配置中心里维护了一个开关当Redis的可用性监控连续出现异常时可以把缓存读关闭让所有请求直接走MySQL。虽然响应会变慢但至少数据是对的。等Redis恢复后再打开缓存读并预热热点Key。这个开关在压测和真故障时都救过我的命一定要有并且要演练过。5. 线上问题排查实录与自检清单5.1 一次上下文错乱故障的全过程有一次线上流量高峰客服反馈大量用户“对话上下文错乱”——上一轮明明是用户A在聊下一轮系统回复里的上下文却变成了用户B的内容。第一反应是Redis被污染了我赶紧查了对应会话的Redis数据。排查下来发现session:{userId}:{sessionId}这个Key下Hash里的userId字段居然和消息List里的内容对不上。再追下去真相让人哭笑不得客户端在某些场景下会复用同一个sessionId并且并发发送多条消息而我们当时的写入逻辑是先读旧上下文、拼上新消息再整体覆盖写回这个“读-改-写”流程没有加锁导致两个并发请求互相覆盖一个请求写入的用户A消息被另一个请求写回的用户B消息整体覆盖了。定位之后修复方案分了三层第一层所有会话上下文的“读-改-写”操作都增加分布式锁锁粒度精确到sessionId第二层把整体覆盖改为基于List的追加写入结合LTRIM做长度控制避免整段覆盖第三层在写入MySQL时用messageId唯一索引兜底重复消息直接丢弃。这起故障的教训非常深刻一致性不是只在Redis和MySQL之间要考虑应用内部并发修改同一份状态也一样会翻车。5.2 监控指标与告警配置建议这套架构上线后我把监控分成了三个层面缺一不可。Redis层面核心指标是缓存命中率、内存使用率、过期Key数量和慢查询。命中率低于90%就要警惕说明大量请求在穿透到MySQL内存使用率持续接近maxmemory则要考虑扩容或优化TTL。另外要特别监控耗时超过50ms的Redis命令通常意味着出现了大Key操作比如LRANGE一个大List或HGETALL一个大Hash这是线上性能杀手。MySQL层面核心指标是主从延迟时间、活跃连接数、慢查询数量和死锁数量。主从延迟直接关系到我们前面讲的读屏障设计是否还有效连接数过高会让落库链路出现阻塞进而引发MQ消费堆积。业务层面核心指标是“缓存删除失败次数”“对账不一致数量”和“MQ补偿重试次数”。这些指标能快速反映一致性机制是否在正常工作每条指标都应该配置告警。比如“对账不一致数量”从0跳到几十说明同步链路某个环节出了问题需要立刻介入。5.3 高并发架构升级的自检清单最后整理一份自检清单每次做架构升级或上线新功能前我都会对照着过一遍所有写操作是否都有幂等键MySQL对应表是否建了唯一索引缓存更新用的是“删除”还是“更新”删除失败有没有补偿机制缓存Key的TTL是否加了随机抖动避免集体过期热点会话的缓存重建是否加了分布式锁锁是否有owner标记和Lua脚本释放主从延迟是否被考虑关键读请求是否走了Redis读屏障或主库读是否存在并发“读-改-写”同一份状态的代码是否都加了锁是否配置了Redis降级开关Redis彻底不可用时能不能切到MySQL直读是否跑了缓存穿透和雪崩的压测MySQL能不能扛住回源流量这套架构升级做完之后我最深的体会有两点。第一一致性方案永远没有一劳永逸线上环境千变万化能靠机制解决的不要靠自觉能靠幂等兜底的不要靠概率。第二架构升级不要追求一步到位先跑通再优化比如先上消息队列补偿稳定后再演进到binlog监听每一层都要有监控和告警托底。AI对话系统的用户对“对话连续性”极其敏感一次上下文错乱带来的信任损失往往比一次慢请求严重得多。