AI帮我写了个Redis分布式锁,上线当天凌晨3点被叫醒
目录
- 那个凌晨三点的电话
- AI给出了"看起来完美"的代码
- 锁没续上,业务全乱了
- 重新理解Redisson的可重入锁
- 给AI加约束条件后重试
- 几条经验
---
那个凌晨三点的电话
手机震了三次我才接。运维老张的声音带着困意又夹着火气:"订单系统挂了,十分钟积压了三千多单,你赶紧看。"
我爬起来打开监控大盘,线程池全满,Redis连接数飙到上限,大量的lock wait timeout日志刷屏。翻到那块代码的时候,我整个人凉了半截——这是上周让AI帮我写的那段分布式锁逻辑。
事情的起因是业务上了一个新需求:用户下单后要锁定库存,同一SKU不能重复扣减。时间紧,我就把需求描述丢给了AI:"帮我写一个基于Redis的分布式锁,要支持可重入和自动续期,Java + Spring Boot。"
AI很快给出了答案,代码结构清晰,注解齐全,连注释都写得像模像样。我扫了一眼没看出毛病,跑了几次本地测试也没问题,就合了PR。
两周后,它在凌晨三点崩了。
AI给出了"看起来完美"的代码
先看AI产出的那段代码(我直接贴原始版本):
@Componentpublic class RedisDistributedLock {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_PREFIX = "dist_lock:";
private static final long DEFAULT_EXPIRE = 30; // 秒
public boolean tryLock(String lockKey, String requestId, long expireSeconds) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue()
.setIfAbsent(LOCK_PREFIX + lockKey, requestId,
expireSeconds, TimeUnit.SECONDS)
);
}
public boolean releaseLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
return redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(LOCK_PREFIX + lockKey),
requestId
).equals(1L);
}
}
第一眼看过去没什么问题。SETNX加Lua脚本释放,标准的教科书写法。AI还贴心地用UUID做requestId防止误删。
但问题藏在它没写出来的地方。这段代码缺了最核心的一块:锁续期。
锁没续上,业务全乱了
业务场景是这样的:用户下单后要调三个接口——查库存、锁定库存、创建订单。整个链路正常情况下跑完大概8秒,偶尔因为第三方接口响应慢会到20多秒。
AI生成的锁是固定30秒过期,没有自动续期机制。正常情况够用,但那天凌晨有个合作方的库存查询接口抽风,延迟到了40多秒。锁在第30秒自动过期释放了,这时候第一个线程还在跑业务逻辑。第二个线程拿到锁,也去扣库存,结果同一个SKU被扣了两次。
更要命的是,第一个线程跑完后去释放锁——它释放的实际上是第二个线程的锁。因为锁过期后被第二个线程重新获取了,requestId已经变了。
这个Bug在本地测不出来,因为本地调用库存接口几乎瞬时返回。压测的时候也只跑了正常时延的场景。只有生产上那种"第三方接口突然变慢"的真实情况才能触发。
重新理解Redisson的可重入锁
说实话,我在这之前对分布式锁的理解停留在"SETNX + Lua脚本就够了"。出了事才认真看了Redisson的源码,发现它做了几件我忽略的事:
第一,Redisson的RLock是基于Redis Hash结构实现的,不是简单的String key。Hash的field存的是线程ID,value存的是重入次数。这样同一个线程可以多次获取同一把锁。
第二,它用了Watchdog机制做自动续期。获取锁成功后,会启动一个定时任务,每隔lockTimeout/3的时间去续期。默认锁30秒,那每10秒续一次,续到30秒。业务逻辑没跑完就一直续。
第三,解锁的时候会先检查Hash中自己的field值,减1,减到0才真正删key。不存在"释放了别人的锁"的问题。
我把AI生成的代码替换成Redisson后,核心逻辑变成了这样:
RLock lock = redissonClient.getLock("dist_lock:" + lockKey);try {
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
// 业务逻辑,即使跑超过30秒,Redisson会自动续期
doBusinessLogic();
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
tryLock(5, 30, TimeUnit.SECONDS)的含义是:最多等5秒获取锁,锁的初始过期时间30秒(Watchdog会自动续)。
给AI加约束条件后重试
有了这次教训,我重新提需求的时候加了明确的约束:
> "使用Redisson实现,不要自己手写Redis锁。同时把锁KEY的命名规范约束为'业务模块:资源类型:资源ID'三段式,并且把异常场景列出来。"
这次AI产出的代码就靠谱多了,还主动加了:
- 锁KEY统一用三段式命名(order:sku:10001)
- finally块里用isHeldByCurrentThread()判断再解锁
- tryLock失败时返回明确的错误码而不是抛异常
我把这个对比过程整理了一下。同一个需求,不加约束和加约束,AI的产出质量天差地别。
不加约束:手写SETNX,缺续期,缺可重入,缺异常处理加约束后:直接用Redisson,三段式KEY,完整finally,异常码规范
几条经验
写这篇文章的时候,那个凌晨三点的狼狈还历历在目。总结下来:
**AI生成的代码不能只看功能对不对,要看它在异常条件下对不对。** 分布式系统里的坑往往藏在超时、网络分区、进程重启这种边缘场景。AI默认是按"一切正常"来生成代码的。
**别让AI帮你选方案,你得先告诉它用什么方案。** 如果我一开始就说"用Redisson实现分布式锁"而不是"帮我写一个分布式锁",后面这些事都不会发生。AI默认会选最简单而不是最稳妥的实现。
**涉及中间件的代码,用成熟的库而不是自己造轮子。** Redis分布式锁这种场景,Redisson已经做过大量的边缘case处理。让AI手写实现,它不会考虑到那些在生产环境踩了多年的坑。
那个凌晨三点,我在群里发了一条消息:"业务代码可以交给AI,但基础设施代码你得比AI更懂才行。"有人回了个"+1"。我想,这大概就是现阶段AI编程的真实边界。