1. 分布式AI系统的“三件套”:缓存、锁与事务
第七篇了。前几篇我们从分布式训练框架说到参数同步,又聊了模型推理服务化,不少朋友在后台问我:这些组件之间到底靠什么“黏”在一起?训练任务调度、特征读取、模型版本切换、推理结果回写,每一环都有数据在多个节点之间流动,稍不留神就出现重复计算、状态错乱、数据对不上。
这一篇我打算把分布式AI系统里面最容易被忽略,却最要命的三样基础设施讲透:分布式缓存、分布式锁和分布式事务。别觉得它们是老生常谈,在大模型训练和推理场景里,这三样东西的用法跟传统互联网后端有非常大的区别,踩坑方式也完全不一样。文章会从原理讲到实操,再把我自己调试过程中遇到的那些“诡异问题”一并列出来,希望能省掉你几天的排查时间。
这篇内容适合正在搭建分布式AI训练平台、推理服务网关,或者做AI中台的朋友参考。如果你还在单机阶段,也可以先收藏,等到数据量上来、节点一多,这些坑你迟早会撞上。
2. 设计思路拆解:为什么AI系统比普通后端更需要这三样
2.1 分布式AI系统里的“胶水层”
先理清一个概念:很多人提到分布式AI,第一反应就是“多卡训练”“模型并行”,认为核心都在框架层。但实际生产环境中,AI系统是一个复杂的软件工程系统——训练平台要管理几千个任务,推理服务要应对几十万QPS,特征平台要保证毫秒级读取,模型仓库要处理版本发布与回滚。这些业务逻辑横跨多个微服务、多个数据源,它们之间需要一套通用的协同机制。
这套机制就是我说的“胶水层”:缓存负责让数据读得更快,锁负责让并发操作不打架,事务负责让多步操作不出现半截状态。没有它们,AI系统就像没有调度员的路口,车再多也是堵死。
2.2 三类组件的职责边界
先给一个总览,后面章节再逐项展开。
| 组件 | 核心问题 | AI场景典型应用 | 传统后端典型应用 |
|---|---|---|---|
| 分布式缓存 | 数据读取性能 | 特征缓存、推理结果缓存、模型元数据缓存 | 商品详情缓存、会话缓存 |
| 分布式锁 | 并发互斥控制 | 训练任务抢占、模型版本切换、定时任务防重 | 订单库存扣减、秒杀防超卖 |
| 分布式事务 | 多节点数据一致性 | 训练任务状态流转、模型文件与元数据联动、计费与配额扣减 | 订单创建与库存扣减、转账汇款 |
2.3 一个容易被忽视的事实:AI场景比电商更复杂
我刚开始做AI平台的时候,觉得这套东西跟电商后端没什么区别,直接把原来的Redis锁和事务方案搬过来,结果很快就翻车了。原因在于:电商场景的状态流转是短事务,秒级甚至毫秒级完成;而AI场景里一个训练任务的执行时间可能是几个小时,模型文件的上传和元数据更新可能隔了十几分钟,推理请求的回写路径可能跨了三个服务。长周期、多阶段、异步化这三个特征,决定了我们不能照搬互联网后端的那套“本地事务+单一数据库”的方案。
这也是为什么需要专门写一篇来讲AI系统里的缓存、锁和事务——它们承担的职责更重,设计方案更不能想当然。
3. 分布式缓存:让特征和结果数据“找得快”
3.1 AI场景下缓存到底在缓存什么
很多朋友对缓存的印象还停留在“数据库前面挡一层Redis”,但对AI系统来说,缓存的对象要丰富得多。我归纳下来主要有三类:
第一类是特征缓存。线上推理服务在每一轮请求里都要读取用户特征、物品特征、上下文特征,这些特征原本存放在特征数据库或者HDFS上的宽表里,直接查库往往要几十毫秒到上百毫秒。把热点特征灌进Redis,延迟能压到1-2毫秒。这个在推荐系统、广告系统里几乎是标配。
第二类是推理结果缓存。同一个问题的相似请求,结果其实可以复用。比如AI问答系统里同一段用户输入,在一定时间窗口内可以缓存答案,避免重复调用大模型推理接口。这个既能省算力,又能显著降延迟。但要注意缓存key的设计要包含模型版本号——模型升级后旧缓存必须失效,这个细节我见过无数人踩坑。
第三类是模型元数据缓存。模型文件通常存在对象存储或HDFS里,但模型的名字、版本号、路径、SHA256校验值、状态这些元数据高频被读取。把这些元数据放在Redis里,能避免每次部署都去扫描文件系统。
3.2 缓存架构选型:Redis Cluster为何是主力
AI系统里的缓存方案,我推荐优先考虑Redis Cluster,而不是单机Redis或者Memcached。
单机Redis在数据量小的时候没问题,但AI平台的特征数据动辄几十GB甚至上TB,单机内存扛不住,就算扛得住,持久化和主从切换也会成为瓶颈。Memcached虽然也有分布式能力,但数据结构太简单,AI场景里我们经常需要用Hash存储特征组、用Sorted Set做排名缓存、用Stream做任务队列,这些Redis原生支持,Memcached做起来就很吃力。
Redis Cluster的槽位分片机制(16384个哈希槽)能让我们把数据均匀分布在多个节点上,客户端算好CRC16再去访问对应节点,扩容的时候按槽位迁移就行。我在实际部署里用的是6个节点(3主3从),每个节点分配一定比例的槽位,实测下来单节点故障时从节点接管很快,对线上推理几乎无感知。
3.3 热key与数据倾斜:AI场景最容易踩的坑
Redis Cluster最怕的不是容量不够,而是热key。AI系统里热点特别集中:比如热门商品的embedding、大V用户的特征,这些key会被超高并发访问。某个key的QPS如果达到单节点处理上限,集群里其他节点空闲也没用,因为Redis Cluster的数据分片是“一个key一个节点”,没法像读写分离那样分摊——读请求只能打到持有这个key的节点上。
我有一次做推荐系统压测,特征缓存的QPS打到了单节点瓶颈,主节点CPU跑满,从节点闲得发慌。后来分析发现,一个超级热门用户的特征key占了总读请求量的40%以上。当时用了一个笨办法解决:在代码里对这个热key做“本地缓存+Redis回源”的两级缓存,本地缓存扛掉大头压力,Redis只做兜底。再往后,我在代码里引入了热key探测,通过统计Redis的慢查询和节点流量来自动发现热点,然后进行key拆分。
除了热key,缓存穿透在AI场景也很常见。AI系统经常有“批量打分”的场景,一批请求里混入了大量不存在的ID,如果每个ID都去数据库查一遍,数据库会直接被打挂。解决方法是布隆过滤器,或者对空结果也做短暂缓存。我一般两层一起上:布隆过滤器挡掉不存在的key,空值缓存兜底,有效期3-5秒就够了。
另一个容易被忽略的问题是缓存与数据源的一致性。AI平台的特征数据是从离线数仓同步过来的,同步周期可能是小时级别。如果特征的缓存过期时间设得太长,线上拿到的就是过期的特征。我在做特征缓存时,给每个key都加上了“数据版本号”,同步任务每跑完一轮就递增版本号,读取的时候发现缓存里的版本号和当前版本不一致就主动失效。这样就避免了“缓存里的特征明明已经更新了,但线上还在用旧值”的尴尬。
4. 分布式锁:让训练任务和模型发布不“打架”
4.1 为什么单机锁解决不了分布式问题
AI平台里有太多需要“互斥”的场景。最简单的例子:同一个模型要发布新版本,发布流程要把新文件上传到对象存储、更新元数据、切换流量,这个过程如果两个发布任务同时跑,文件可能被写乱,流量切换可能丢失一半请求。
单机上的Lock只能在一个进程里生效,多个节点的服务之间要靠分布式锁。我见过有人用数据库的唯一索引实现锁,比如在表里插入一条记录表示“有人正在发布”,发布完了再删掉。这在低并发下能用,但既不优雅也不可靠——如果发布进程崩溃了,这条记录永远删不掉,锁就永久死锁了。
4.2 Redis锁从入门到可靠:一个演进过程
在AI系统里,我用的最多的还是Redis分布式锁,但要实现一个可靠的Redis锁并不简单,我踩过不少坑,把演进过程分享出来。
第一版:SETNX加过期时间。最原始的写法是SETNX lock_key unique_value,成功拿到锁,处理完业务后DEL释放。问题很明显:如果拿到锁的进程在处理过程中崩溃了,锁永远不会释放。解决办法是加上过期时间:SET lock_key unique_value NX EX 30。这个命令是原子的,既占位又设过期时间,比分开两步可靠得多。
第二版:释放锁时校验值。加了过期时间之后又有新问题:如果进程处理时间太长,锁在过期后自动释放,另一个进程拿到了锁,这时候第一个进程处理完了执行DEL,把别人刚拿到的锁给删了。这个必须避免。解决方案是在释放锁的时候先比较value是否是自己写入的那个唯一标识,一致才删除。不能用先GET再DEL的两步操作,因为这两步之间锁可能已经过期被他人拿到,必须用Lua脚本原子地完成“比较+删除”:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end第三版:使用Redisson自动续期。过期时间设多久是个难题。设太短,长任务还没跑完锁就过期了,另一个任务会并发执行;设太长,进程崩溃后锁要很久才能被其他任务抢到。再加上AI训练任务的执行时间波动特别大,没法预估。
Redisson这个库解决得很优雅:它有一个“看门狗”机制,拿到锁之后默认每10秒检查一次,如果锁还在自己手上,就把过期时间续到30秒。只要进程活着,锁就不会过期;进程挂了,看门狗自然停止,锁在30秒后自动释放。实测下来非常稳。
我用Java写过一版核心逻辑,如果你也在用Java做AI平台调度,可以直接参考:
RLock lock = redissonClient.getLock("train:lock:model:" + modelId); boolean locked = false; try { // 等待5秒获取锁,拿到后leaseTime传-1表示启用看门狗续期 locked = lock.tryLock(5, TimeUnit.SECONDS); if (locked) { // 执行模型发布/训练任务编排 doPublish(modelId); } else { throw new RuntimeException("获取分布式锁超时,请稍后重试"); } } finally { if (locked) { lock.unlock(); } }4.3 结合AI调度场景的锁最佳实践
AI场景里还有一些值得注意的锁设计细节。比如训练任务抢占:GPU资源有限,多个训练任务抢同一批卡。我这里用的是“锁+资源检查”两步走,先抢锁,抢到锁后再检查GPU空闲情况,如果资源不足就释放锁并把任务放回队列,避免长时间占用锁。
再比如定时任务防重:AI平台里有大量定时任务,定时清理日志、定时同步特征数据、定时打点统计。如果平台是双节点部署,定时任务在每台机器上都会触发,就必须用分布式锁保证同一时刻只有一个节点在执行。我一般用SET lock_key unique_value NX EX 60在任务入口处抢锁,抢到就开始跑,跑完就删除。这里有个小技巧:锁的过期时间设置为正常情况下任务最大耗时的1.5到2倍,既不会误杀正在跑的任务,又能在进程死后迅速释放。
还有一个实践心得:锁的粒度尽量小,不要一把锁锁住所有操作。我之前设计模型发布锁时,一开始用了一把全局锁,导致不同模型之间的发布操作互相阻塞。后来改成按模型ID粒度加锁,不同模型可以并发发布,互不影响,相同模型的发布仍然串行,这样并发度和安全性都保住了。
5. 分布式事务:让模型状态和文件存储不“各说各话”
5.1 AI系统里的事务问题从哪来
很多人觉得“事务”是电商后端才需要的东西,AI系统里哪有什么事务?这个想法是错的,而且会出大事故。举几个我实际遇到的例子:
模型文件与元数据的联动。模型发布时,要往文件存储里传一堆权重文件,同时要在数据库里更新模型版本记录。文件传完了,数据库更新失败了,或者反过来,这种情况下模型的状态就乱了,线上流量可能打到不完整的模型上。
训练任务状态流转。一个训练任务从排队、调度、运行、成功、失败到归档,每一个状态变更往往涉及多个系统:调度系统更新任务状态、资源系统释放GPU、监控系统写入指标。如果这些操作没有一致性保障,就会出现任务显示“运行中”但GPU已经被释放的诡异情况。
计费与配额扣减。AI平台给用户提供训练服务,任务跑完要扣费、要更新配额。计费系统、订单系统、账户系统之间的一致性也是典型事务问题。
5.2 几种事务方案的原理和选型
标准的分布式事务方案有好几种,先整理成表格,再结合AI场景分析。
| 方案 | 原理 | 优点 | 缺点 | 适用的AI场景 |
|---|---|---|---|---|
| 二阶段提交(2PC) | 先投票再提交 | 强一致 | 阻塞、协调者单点 | 极少用,AI系统通信时间长 |
| TCC(Try-Confirm-Cancel) | 业务内部分三阶段 | 性能较好、不锁资源 | 业务侵入大,每个操作要写三段逻辑 | 模型发布、任务状态流转 |
| 本地消息表 | 业务表和消息表同库事务 | 实现简单、可靠 | 需要消息表、可能重复消费 | 计费、订单、异步通知 |
| 事务消息(半消息) | 消息队列确认后才投递 | 实现相对简单 | 依赖MQ能力 | 训练任务编排、状态异步流转 |
| 最终一致性+补偿 | 各服务独立提交,定期对账 | 高可用、简单 | 一致性有延迟 | AI平台里大多数场景 |
TCC在AI系统里是最推荐的方案之一。以模型发布为例,Try阶段检查文件完整性、预留版本号;Confirm阶段把流量切到新模型;Cancel阶段回滚流量到旧模型。虽然要写三段方法,但每个阶段逻辑清晰,出问题也好排查。
本地消息表适合计费这种场景。任务跑完后在任务系统库里同时写入“任务完成”和“待计费消息”,这两个操作在同一个本地数据库事务里,保证不会出现“任务完成但没触发计费”。后面异步地把消息投递到MQ,再消费处理。这套方案我被坑过太多次,后面会详细说。
2PC在AI系统里我基本不建议用。AI系统的服务之间通信延迟高,事务跨越时间长,2PC的阻塞特性会让整个链路卡死,协调者一旦宕机更是大灾难。
5.3 实操案例:模型版本切换的最终一致性方案
我这边线上模型发布走的是一个混合方案:主链路用TCC,异步部分用本地消息表。核心流程拆解一下:
第一步,发布请求进来后,先在数据库里把模型记录的状态改成“发布中”,同时向文件存储上传新版本权重文件。第二步,上传完成后执行Confirm,把模型表的“线上版本号”字段更新成新版本号,然后通过配置中心推送流量切换指令。第三步,如果上传中出错,执行Cancel,把模型状态回滚为“发布失败”,线上版本保持不变。
这里面有个容易漏的坑:流量切换指令的下发必须是异步且可重试的,不能因为网络抖动导致指令丢失。我的做法是把“切换流量”这条指令写进本地消息表,然后由worker轮询投递,消息被多个消费端确认后才删除。这样即使某个节点挂了,其他节点也能把消息消费掉,不会出现模型文件已经是新版但线上流量还在走旧版的“悬空”状态。
另一个AI场景需要注意的:长事务拆分。AI训练任务动辄几个小时,不可能把整个任务过程包在一个事务里,必须把任务拆成多个阶段,每个阶段单独提交,阶段之间用状态机驱动。比如“排队→申请资源→开始训练→训练完成→释放资源→计费”,每个步骤都是独立事务,加上重试和补偿逻辑。这也是为什么AI平台里的“工作流引擎”如此重要——它与分布式事务天然配合,任务编排本身就是一套事务编排。
6. 实操路上的坑与排查技巧实录
6.1 缓存与数据源不一致:双写引发的“灵异事件”
现象:特征缓存更新完之后,线上偶尔读到旧值,过几分钟又恢复正常。一开始我以为是网络问题,查了半天发现是缓存和数据库双写时序不一致。
我之前用的是“先更数据库,再删缓存”的策略,但AI平台的同步脚本经常批量更新,数据库更新和缓存删除之间隔了很长时间,期间就有请求读到旧缓存。后来自研了一个轻量方案:每份特征数据带版本号,更新数据库时递增版本号,缓存key统一拼接版本号。读取时先拿当前版本号,再读对应版本的缓存,版本不匹配就穿透到数据库。实测下来双写问题彻底解决,代价是多一次版本号读取,但走的是Redis,成本极低。
6.2 锁超时误杀:训练任务被“自己人”打断
这个问题我必须重点讲,因为太隐蔽了。线上训练任务在跑的过程中,忽然收到“获取锁超时”的报错,但实际锁明明没有被别人持有。排查发现原因在Redisson的leaseTime参数上——我一开始显式传了10秒的leaseTime,而任务的实际执行时间超过10秒,锁被自动释放后又被人抢到,两个任务就开始冲突。改成不传leaseTime、启用看门狗自动续期之后,问题消失。
这里提醒大家:用Redis锁做长任务互斥,一定要了解续期机制,不能凭直觉设置过期时间。如果不用Redisson,自己实现续期也可以,开一个定时任务每隔三分之一过期时间去刷新过期时间,但要小心续期逻辑本身不能成为新的故障点。
6.3 事务空回滚与重复补偿:一个经典连环坑
在实现TCC模型发布方案的时候,我踩过一个典型的坑:Try阶段因为网络超时报错了,消息队列触发了Cancel,但Try阶段实际上根本没执行成功,Cancel却执行了回滚逻辑,结果把一条原本正常的模型记录搞成了“发布失败”。
这就是空回滚问题——补偿动作执行在业务还没真正开始之前。解决方法是给每个事务操作加事务ID,在Try阶段先写一条预留流水记录,Cancel阶段检查流水记录是否存在,不存在就说明Try没执行,直接返回成功不做事。反过来还有悬挂问题:Try阶段超时后Cancel先执行了,但Try的请求在网络上迟到了,之后才被业务方处理——这会导致“先取消了,又尝试执行”的矛盾。我的方案是在Try入口检查事务状态,如果已经是“已取消”就直接拒绝执行。
这些问题在文档里基本不会写,但生产环境一定会遇到。凡是上分布式事务的系统,一定要先定义好事务状态机,把“未开始、执行中、成功、已取消”这些状态想清楚,再动手写代码。
6.4 监控与排查工具:日志、链路追踪、锁监控
分布式系统排查问题,没有工具等于盲人摸象。我这里列一套自己常用的组合:
- 日志:所有缓存读写、锁的获取释放、事务的各个阶段都要打结构化日志,至少包含事务ID、业务主键、耗时、结果。
- 链路追踪:SkyWalking或者Jaeger可以串起整个调用链,看得到锁在整个链路里占了多少时间。
- Redis监控:慢查询日志、key的空间使用、节点的CPU和内存都要盯。热key问题如果等到用户反馈才发现,损失已经造成了,最好在Redis层面加一层流量统计,超过阈值自动告警。
- 分布式锁可视化:我给锁加了一套轻量记录,每次获取锁/释放锁都写一份审计,包括获取者IP、进程ID、业务ID、耗时。排查“这个锁为什么被抢走了”这类问题,这套审计日志是救命稻草。
7. 常见问题速查表
整理一张速查表,方便你遇到问题时直接对号入座。
| 症状 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 缓存读取频繁超时 | 热key打在单个节点上 | 查看Redis节点CPU分布 | 本地缓存+热key拆分 |
| 缓存数据与数仓不一致 | 双写时序问题 | 对比数据版本号 | 版本号机制 |
| 定时任务重复执行 | 锁过期时间太短或锁未续期 | 查看锁的过期日志 | 看门狗续期或合理设置过期时间 |
| 两个任务同时跑同一模型 | 锁的粒度太大或释放出错 | 查看锁审计日志 | 按模型ID细粒度加锁,释放时校验唯一ID |
| 任务状态显示运行中但资源已释放 | 状态流转缺少事务保证 | 查看状态机流转记录 | 用TCC或状态机+本地消息表 |
| 计费消息没产生 | 本地消息表和业务操作不在同一事务 | 查看消息表记录 | 本地消息表和业务表同库事务 |
| Try执行失败但Cancel把数据改错了 | 空回滚 | 查看事务状态记录 | Try写预留流水,Cancel检查流水 |
| 模型发布后流量还在旧版 | 流量切换指令丢失 | 查看消息投递状态 | 指令入本地消息表,异步可重试投递 |
这张表是我实际经验里浓缩出来的,不能说覆盖所有场景,但覆盖了90%以上的“看起来很难查”的问题。如果你遇到的问题不在这张表里,建议先按“先看日志、再看锁、最后看状态机”的顺序排查。
8. 写在最后:一点个人体会
做分布式AI系统这几年,我最大的体会是:分布式带来的问题,最终要靠分布式思维来解决。缓存、锁、事务从来不是独立的中间件,而是一套完整的设计哲学——缓存让你更快地拿到数据,锁让并发下的行为可控,事务让长流程的状态可信。你可以先跑通一版最简单的Redis锁,再逐步演进到Redisson、再到TCC事务方案,每一步都有价值,都能让你对系统的理解更深入一层。
最后再分享一个小经验:分布式系统排障,不要一上来就查代码逻辑。先把日志拉齐,沿着“锁在哪里被拿、在哪里被放、事务在哪一步断掉、消息有没有被消费”这条主线走一遍,80%的问题都能定位到具体环节。剩下的20%,才是真正考验你对这套机制理解深度的地方。希望这篇能让你把那20%也变成可以应对的日常。