☰
AI对话系统高并发会话存储架构:Redis+MySQL分层设计与一致性实践
2026/9/30 12:51:37 网站建设 项目流程

做AI对话系统,很多人第一反应是“给大模型发请求、收回复”,但真正到了高并发场景,卡脖子的往往不是模型推理,而是会话状态到底该怎么存。我带的项目就踩过这个坑:上线初期QPS不高,直接用本地内存存会话上下文,一切岁月静好;等流量涨到每秒几千、实例扩到十几台之后,各种“上一轮对话没上下文”“用户消息串线”“发版后会话全部中断”的问题集中爆发。最后不得不做了一次从内存到Redis+MySQL的架构升级。

这次升级表面看是换了存储组件,实际上真正难啃的是数据一致性。中间踩过不少坑,也总结了一套能落地的方案,今天把这套从设计、实施到排障的完整过程整理出来,希望能给同样在做AI对话系统、准备搞分层存储的同学省点弯路。

1. 为什么内存方案撑不住高并发:先讲清楚三个硬伤

1.1 AI对话系统对存储的真实需求

AI对话系统和普通Web应用最大的区别在于,它几乎是“有状态”的。用户在对话过程中,系统必须记住这个会话之前聊了什么,才能组装出完整的Prompt送给大模型。所谓上下文,本质上就是一次对话里按顺序排列的历史消息列表,加上当前会话的状态信息,包括会话ID、用户ID、当前处于哪个阶段、Token用量、模型参数、时间戳等等。

数据特征也很鲜明:每个会话的消息按时间严格有序,读到“上一轮”内容的频率极高,几乎每次请求都要把整个上下文捞一遍;而写入频率也很高,每生成一轮回复就要追加一条消息;此外数据有很强的时效性,一个会话可能热聊十分钟,然后彻底不再活跃。

这种场景用生活类比就是呼叫中心的接线员。你不能只接起电话就说话,还得记得前几通电话里用户报过的姓名、订单号、诉求,否则对方一说“我刚刚不是说了吗”,整个对话就崩了。接线员的大脑就是内存,记事本就是数据库,而高并发意味着同时有十万个接线员在接电话,大脑不够用是必然的。

1.2 本地内存方案的三个致命问题

架构升级前,我们用的是最简单的方案——每台应用实例在自己的JVM堆内存里放一个ConcurrentHashMap<sessionId, 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支持右侧追加、左侧裁剪,RPUSH+LTRIM可以非常优雅地实现“永远只保留最近20轮”。这个组合操作是原子的,在高并发下不会出现读到半截消息的问题。

TTL的设计也值得单独说。会话空闲超过30分钟就过期删除,这是从产品角度定的“遗忘阈值”。到期后Redis会淘汰这个Key,下次用户再发起请求时,从MySQL回放历史消息重建上下文。如果业务要求更长周期的记忆,可以把TTL放宽到24小时,但要注意Redis内存成本会随活跃会话数线性增长。我算过一笔账:假设单会话热数据20KB,10万活跃会话就是2GB,30分钟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对话系统里遇到时,场景和教科书不太一样。

缓存穿透,指的是查询一个根本不存在的Key,Redis永远不会有,请求每次都打到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 延迟双删到底删几次、间隔怎么定

延迟双删是解决“删除缓存后、写库前,有请求回填旧数据”这一窗口期的经典手段。标准流程是:

  1. 先删除Redis中的缓存Key
  2. 更新MySQL数据
  3. 休眠一小段时间(例如500ms)
  4. 再次删除该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语义决定了消费者可能收到重复消息,一旦重复写入MySQL,dialog_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对话系统的用户对“对话连续性”极其敏感,一次上下文错乱带来的信任损失,往往比一次慢请求严重得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询