☰
AI对话系统高并发下Redis+MySQL缓存一致性实战:延迟双删与兜底策略
2026/9/30 7:39:01 网站建设 项目流程

1. 单机内存扛不住高并发,为什么必须上 Redis+MySQL

1.1 AI对话系统和普通接口不一样的负载特征

AI对话系统和普通CRUD接口最大的区别,就是它的“响应”依赖一整段历史上下文。用户发来一句消息,服务端得先把前几轮的消息、系统提示词、当前会话状态从存储里捞出来,拼装成prompt送给模型,模型生成结果后又要写入新的上下文。也就是说,一次用户请求至少伴随一次上下文读取和一次上下文写入,而且每次读写的不是一个字段,而是一整段结构化数据。高并发一上来,这种读写放大比普通业务严重得多。

我这次升级的AI客服系统,高峰期每秒进来的用户消息有800条左右,每一条消息平均要读取8KB左右的上下文,同时写入8到12KB的新内容。按这个量算,单是上下文读写就相当于每秒16MB级别的数据传输。这个流量放在应用内存里确实不难,可怕的是它有状态:每个用户的上下文必须连续、不能丢,一旦应用进程重启、扩容缩容,那些内存里的状态就变成一团乱麻。

单机内存缓存处理这种负载,表面上能顶一阵子,但它的本质是“把所有鸡蛋放在JVM这一个篮子里”。一旦流量模型从“纯读多”变成“读多写也多”,内存里的对象增长速度会远超预期,GC就变成了第一杀手。我当时看到监控里Full GC次数一分钟跳三四次,就知道这条路已经走到头了。

1.2 内存态架构的三个致命问题

原系统是纯内存存储:用户在哪个Pod上处理,上下文就保存在那个Pod的ConcurrentHashMap里。这玩意的致命点有三个。第一,多实例状态不一致,同一个用户后面的请求被负载均衡打到了另一台实例上,上下文就直接丢了。第二,重启即丢失,线上发布、机房抖动导致的节点重启,所有活跃会话归零,用户侧表现就是“刚才聊到一半,机器人突然失忆”。第三,内存里塞了太多上下文对象,8GB堆里一半是会话数据,Full GC频繁,STW一长,接口RT就上去了,反过来又加剧雪崩。这三个问题本质上是同一个根源:把有状态数据放进了无状态应用的内存里。

解决思路也很直接:把状态挪到独立的存储层。考虑到AI对话的强实时性,全走MySQL肯定不行,全走Redis又怕丢数据、没审计、内存太贵。于是Redis+MySQL变成了一个最经典的组合。Redis扛热数据的读和短时写入,MySQL做最终落库和长尾查询。这也是大部分AI对话系统后台的标准姿势。

当时团队里也有人提过把所有数据都放Redis,用AOF持久化保证不丢。这个思路听上去简单,但Redis本质上还是内存数据库,一亿条历史会话全塞进去,内存成本和淘汰策略都是麻烦。而且AI对话系统有大量的按用户、按时间范围查询需求,Redis的scan在亿级key下非常难用。MySQL在这块就有天然优势:索引、事务、审计、备份生态都成熟。

1.3 Redis 和 MySQL 的职责切分

具体切分上,我按数据的“冷热”来分。

  • 热数据:时长在30分钟以内的活跃会话,包括未结算的上下文、当前轮次的生成状态、流式输出进度。这些放Redis,KV结构,直接以userId或者sessionId做key。
  • 冷数据:已经结束的会话、历史聊天记录、审计日志,放MySQL。这些数据需要按用户检索、按时间排序,Redis的sorted set也能做,但成本和可靠性不如MySQL。

不过,分完这个工,真正的难点才刚开始。热数据放Redis,意味着Redis的key和MySQL里那行记录存在“同一份逻辑数据”的两个副本;只要有两个副本,就有一致性的问题。很多人以为把缓存一清、重启一下就好了,在高并发AI对话场景下,这套天真想法会被在线事故直接教做人。

2. 加了 Redis+MySQL 之后,一致性坑比想象中多

2.1 “先更新缓存再写库”的坑

我最早实现的版本,出于“让用户尽快读到最新上下文”的直觉,写路径是先把新的上下文JSON写入Redis,再去更新MySQL。这个顺序在单线程下看似没问题,但高并发下全是雷。最经典的一个:Redis写入成功,MySQL更新失败,事务回滚了,但缓存里已经是新数据。等Redis key过期,读到的是旧数据,或者下次服务降级时直接从MySQL读到旧上下文,用户生成的上下文丢了一截。你以为缓存是加速层,实际上它已经变成错误数据的制造者。这个坑几乎每个做缓存的人都会踩一遍,我自己也不例外。

为什么不能靠Redis的TTL自动纠正?因为过期之后下一次读取会把MySQL里的旧数据重新写到缓存里,错误会被反复放大。尤其AI对话里,一段上下文错乱之后,模型后面的回答质量会断崖式下跌,用户感知非常明显。

2.2 “先删缓存再写库”也很玄

踩完第一个坑,我把写路径改成Cache Aside的标准姿势:先更新MySQL,提交后删除Redis key,等下次读取再回源。按理说,读多写少场景下这已经够用了,但AI对话的写路径恰恰是“每来一条消息就写一次”,写频率远高于普通缓存场景。而Cache Aside有个著名的并发破窗:

线程A更新MySQL,线程B正好读到旧数据并把旧数据回写缓存;线程A完成后再删除缓存,删除操作和B的回写之间如果存在时间差,Redis里就会残留旧上下文。更麻烦的是,AI对话里读操作不是简单读一次,它前面的模型调用耗时几秒,用户在这个窗口内又发来新消息,两个线程交错的概率非常高。

这个问题有个改进版叫“延迟双删”:先更新MySQL,删除一次缓存,过500ms再删一次。第二次删除就是用来清掉“读线程在第一次删除后写回去的旧数据”。延迟双删不能保证100%一致,但能把冲突窗口从“持续到TTL过期”压缩到“几百毫秒”,在实际业务里已经够用。这里值得注意:删缓存不是同步等待模型调用,而是更新完上下文立刻删,第二次删也尽量异步化,不要影响接口的主链路。

2.3 序列化、连接池和过期时间这些低级坑

除了顺序,另一个容易出问题的是Redis里的数据形态。我踩到过的:同一段上下文,Java侧用JDK序列化,另一个消费端用JSON解析,结果反序列化直接报错,缓存命中率瞬间掉到0。高并发下类似问题会变成雪崩的导火索。所以我后来统一规范:Redis里的value一律用统一的JSON结构,并带一个version字段;读端做版本兼容,老版本数据宁可回源也不硬解析。

连接池也是高频坑。Redis连接池配小了,高并发下等待连接的时间直接加到RT上;配大了,Redis服务端连接数爆表,CPU飙高。MySQL连接池更敏感,一次上下文更新如果慢SQL,连接池一旦打满,所有请求都在排队,连心跳检测都会超时。这类问题不在数据一致性范畴,但同样能把一个架构搞崩,所以我后来把两个连接池的参数单独拎出来做了压测,而不是按默认值直接上线。

3. 高并发AI对话系统里的落地实战:延迟双删 + binlog 兜底

3.1 我最终选用的写路径设计

在反复试错之后,我最终确定了一条比较成熟的链路,分成四步:

  1. 请求到达,用短TTL的分布式锁锁住该会话(key: lock:session:userId),避免同一个用户的上下文并发更新。
  2. 在MySQL事务内更新会话上下文(source of truth是MySQL)。
  3. 事务提交后,立刻异步删除对应的Redis key。
  4. 延迟500ms后再次删除,同时开启binlog监听兜底:任何更新MySQL会话表的操作,都广播一条删缓存消息。

为什么把MySQL作为source of truth放在第一步?因为缓存可以丢失,MySQL不能作为“补丁板”。只要MySQL里的数据是完整的,Redis里最坏的情况就是miss,miss后从MySQL回源,最多多几十毫秒延迟,不会出现数据不可恢复的错乱。延迟双删解决的是短暂并发窗口里的脏读,binlog兜底解决的是前面三步因为宕机、网络抖动漏删缓存的情况。加上分布式锁后,同一个用户上下文更新的竞争基本被串行化,剩下的问题只剩“锁内操作要足够快”。

这里特别想说明一下为什么不用“先更新Redis再异步写MySQL”的响应式方案。模型生成一段上下文后,如果先写Redis,用户立刻能看到最新结果,但MySQL落库是异步的,一旦Redis宕机,最近的几十万条会话记录就可能全丢。AI对话系统的上下文,丢了不是重试能补回来的——它没有任何可重放的外部数据源。所以我宁可牺牲一点响应速度,也要保证MySQL先落库。

3.2 核心代码实现(Java + Spring Boot)

下面是我简化后的核心代码,真实项目里会有更多防御性判断,但主链路就是这套。

读路径还是Cache Aside,但做了一个空值保护,防止缓存穿透打到MySQL:

public SessionContext getContext(String userId) { String key = "session:ctx:" + userId; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, SessionContext.class); } SessionContext ctx = sessionMapper.selectById(userId); if (ctx != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(ctx), 30, TimeUnit.MINUTES); } else { // 空值也缓存,TTL短一些,防止穿透 redisTemplate.opsForValue().set(key, "", 1, TimeUnit.MINUTES); } return ctx; }

写路径,重点看事务提交后的缓存删除:

@Transactional public void updateContext(String userId, SessionContext newCtx) { // 1. 更新MySQL,作为唯一数据源 sessionMapper.updateContext(userId, newCtx); // 2. 等事务真正提交后再删Redis TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { deleteCacheWithDelay(userId, 0); deleteCacheWithDelay(userId, 500); } }); } private void deleteCacheWithDelay(String userId, long delayMs) { executor.schedule(() -> { redisTemplate.delete("session:ctx:" + userId); }, delayMs, TimeUnit.MILLISECONDS); }

binlog监听兜底,我用的是Canal那套思路,监听MySQL的row事件,命中会话表就删缓存。伪代码如下:

public void onSessionTableChange(CanalEntry.RowChange rowChange) { for (CanalEntry.Column column : rowChange.getAfterColumnsList()) { if ("user_id".equals(column.getName())) { String userId = column.getValue(); redisTemplate.delete("session:ctx:" + userId); } } }

需要强调一点:延迟双删里的500ms不是拍脑袋。我是在压测环境里模拟了读线程回写旧缓存的最坏窗口,加上生产环境模型调用的95分位耗时后,取了一个比最坏窗口略大的值。如果模型调用时间很长,这个值要跟着调大,否则第二次删除可能发生在读线程回写之前,等于白删。反过来也不能调太大,因为第二次删除之前,客户端已经能从Redis读到刚回源的旧数据。

3.3 并发更新会话上下文时怎么保护

延迟双删解决的是“读-写”冲突,但AI对话系统还有一个更隐蔽的“写-写”冲突:同一个用户连续发两条消息,两个线程同时拿到旧上下文,各自拼上自己的内容,先后写回MySQL,后写的那个人会把先写的那段上下文覆盖掉。这个问题缓存策略解决不了,必须在业务层做约束。

我的做法是per-user级别的串行化。具体有两种落地方式,按你的技术栈选:一是用Redis分布式锁,以userId为粒度,每次更新上下文前先拿锁,锁的超时时间要大于一次模型生成的最长耗时,并设置看门狗续期;二是更简单粗暴——把同一个用户的对话更新请求发到同一个MQ分区,或者后端同一个工作线程,天然串行。我当前项目因为入口压力大,选了分布式锁的方式,但这句话写出来容易,做起来难,后面常见问题里我会展开讲锁失效的坑。

另外一个兜底手段是版本号。MySQL的会话表加一个version字段,更新时带上乐观锁条件:UPDATE session_context SET ctx = ?, version = version + 1 WHERE user_id = ? AND version = ?。即使分布式锁偶尔没生效,也不会出现静默覆盖,而是抛冲突,让上层选择重建或者重试。高并发下乐观锁带来的冲突率不能太高,但作为最后一道防线,成本很低,值得保留。

3.4 参数与容量规划

连接池和TTL我在测试环境花了很多时间。Redis用的是Lettuce连接池,线上8C16G的实例,我把最大连接数压到50,读多写少,实际上30左右就能满足2万QPS的读缓存;但AI对话场景写操作也不少,所以我调到了60。MySQL连接池用HikariCP,maximum-pool-size设的是30,这个数字不是随便给的:单次上下文更新时,事务里有两到三条SQL,平均耗时按20ms算,一个连接能支撑50TPS,30个连接撑1500TPS,已经留了余量。

Redis key的TTL我设成30分钟。这个数字参考了业务里“用户超过30分钟不说话,会话自动归档到MySQL”的约定。TTL太长会让热点会话数据长期占用内存,太短会导致回源MySQL频率高,30分钟刚好。另外,Redis的淘汰策略用的是allkeys-lru,但注意:如果内存触顶,LRU策略会优先淘汰那些不活跃的会话key,这部分miss是正常的,不会引发一致性问题;真正要防的是把刚更新的key挤掉,所以我给所有会话key做了热点保护,确保高并发热数据不会因为容量被清掉。

4. 压测、常见问题排查与实操心得

4.1 升级前后的性能对比

我把升级前后的核心指标整理成了一张表,用的是我自己压测环境4节点应用+1主2从Redis+MySQL主从的结果,不保证所有环境一致,但趋势有参考价值。

指标纯内存方案Redis+MySQL方案
单机QPS上限3200(受GC和内存限制)5800(缓存命中率95%以上)
95分位RT460ms(GC STW拖累)120ms
上下文丢失窗口节点重启即丢几乎为零(MySQL先落库)
Full GC频率每分钟3到5次长稳后几乎没有
会话路由约束必须粘滞到同一节点任意节点可处理

升级的意义不是单纯把QPS拉高。真正解决了问题的是把状态抽出去以后,应用层可以随意扩缩容,发布的时候也不需要摘流量、等长连接里的会话慢慢耗尽。对线上团队来说,这比性能数字重要得多。

4.2 踩过的坑:缓存穿透、击穿、雪崩、序列化魔鬼

先说穿透。AI对话系统里,用户不存在会话是最常见的穿透场景:每个人进来都先查一次,查不到然后打到MySQL。后来我在Redis里放了空值缓存,TTL一分钟。注意,空值不能设置默认空字符串解析,否则反序列化会炸,我统一封装成NullValue对象,读侧判空。

击穿发生在热点会话key失效的那一刻。曾经有一个大流量运营账号触发过,单key被打到MySQL,导致慢查询。我后面给读路径加了互斥锁:缓存miss的时候,同一key只有一个线程能回源,其他线程短暂等待,等回源完成后再读缓存。这个锁是JVM级别的本地锁,只在单进程内有效,我的服务是多实例的,所以我换成了Redis分布式锁,并加了一个短超时,避免锁长时间占住。

雪崩差点碰到一次:我最初把TTL都设成30分钟,并且是全量同一时刻建key,导致高峰期大量key几乎同时过期,回源流量集中打到MySQL。后来我把TTL做了一个随机偏移,30分钟内加0到300秒的随机数,把过期时间打散。这个操作成本低,收益非常明显。

序列化问题我在2.3里已经提过,这里再补充一个真实案例:JDK序列化和JSON混用报错后,我直接看Redis里的key,value是二进制乱码,人肉完全没法排查。后来我统一用GenericJackson2JsonRedisSerializer,并且在对象里带上类型信息,这样不同的接口、不同的消费端读到的都是带类型信息的JSON,反序列化不会因为多态问题炸掉。

4.3 一致性问题速查表

高并发下忙起来容易忘事,我维护了这么一张速查表,每次上线前对着过一遍:

现象可能原因解决办法
对话上下文丢失一段Redis写成功,MySQL写失败先写MySQL,提交后再删缓存
缓存偶发读到旧上下文读线程回写旧数据延迟双删 + binlog兜底
同一用户上下文被覆盖写-写并发per-user分布式锁 + 版本号
缓存命中率突然掉到零序列化格式变化统一JSON序列化,带version
单key高并发回源MySQL热点key击穿分布式锁重建缓存
大量key同时过期TTL设置过齐随机偏移TTL

这些坑不只在AI对话系统里存在,所有Redis+MySQL双层架构都会面临。我之前看过很多人讨论缓存一致性,网上能搜出一堆大道理,但落到具体的业务时间线里,真正能救命的还是可靠的顺序:先写库、再删缓存、延迟兜底、锁住并发。顺序对了,一半的坑就自动闭上了。

4.4 我个人坚持的几个经验

最后分享几条我这次升级沉淀下来的经验,不算标准答案,但都是我实实在在撞过墙换来的。

第一,MySQL永远是source of truth,缓存什么都可以丢,MySQL不能丢。你做再多缓存优化,底层只要有一张完整的表,系统最坏情况只是慢,不会错。AI对话里的上下文丢了,连自动补全都补不回来,这个理念最重要。

第二,延迟双删只是一个“概率收敛”手段,不是一个“绝对正确”机制。要想让一致性收敛更快,可以在读回源时做一个version比较:如果缓存里的version小于MySQL里的最新version,就不写回缓存。这和乐观锁配合,能显著减少脏数据窗口。

第三,别迷信分布式锁。Redis主从切换时锁存在丢失的窗口,极端情况下两个线程同时拿到锁。我现在的做法是:分布式锁只是第一道闸,MySQL里的乐观锁才是第二道闸。两道闸都失效的概率,比单靠一道锁低得多。

第四,压测一定要模拟真实的高并发写。我最初压测是纯读,结果上线后写路径一压就现原形:事务提交后删除缓存的操作占用了太多主线程,导致接口RT突刺。后来我把删除操作全部改成异步线程池,核心线程数单独设置,避免和业务线程池互相污染。这个小改动帮我在上线前避免了一次线上事故。

记得第一次调那个500ms延迟参数时,我觉得多少都无所谓,后来压测发现窗口没踩准,浪费了两天时间。折腾久了,我慢慢总结出一句话:一致性方案里的每一个时间参数,都应该来自线上真实数据,而不是来自直觉。这套方案上线跑了一个月,没有再出现过上下文错乱和缓存穿透事故。做AI对话系统的后端,到了一定规模一定会面对“状态存哪里”的问题,Redis+MySQL是现阶段最稳妥的组合之一,但它不是无脑堆组件就能解决一切。把一致性顺序想清楚,把兜底机制做到位,这比选多高端的组件都管用。

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

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

立即咨询