这是AI辅助开发中「表面功能可用,边界条件全漏」的典型代表,电商、库存、支付等强一致性场景极易触发线上资损。
- 场景背景
某电商系统开发「商品扣减库存」接口,开发者借助AI快速生成Redis分布式锁代码,目标是防止并发下单导致超卖。AI给出的实现基于SETNX + EXPIRE组合,单测串行/低并发场景均表现正常,大促活动上线后出现库存超卖,且部分商品库存被扣为负数。
- 问题现象
• 大促高峰时段部分热门商品出现超卖,实际下单量超过商品库存总量。
• 监控显示部分锁key长期未过期,占用Redis内存;部分请求拿到已过期的锁,出现并发执行。
• 异常日志中偶发「删锁失败」「锁不存在」报错,但业务逻辑仍正常执行。
- 根因分析
AI生成的代码仅实现了「加锁-执行业务-解锁」的基础流程,完全遗漏了分布式锁的核心工程约束,存在4个致命缺陷:
- 加锁非原子操作
AI代码分两步执行:先setnx占锁,成功后再expire设置过期时间。若占锁成功后服务宕机/进程崩溃,过期时间未设置,该锁会永久存在,后续所有请求都无法拿到锁,形成死锁。
- 解锁不校验持有者
解锁时直接执行del key,未校验锁的归属。若线程A业务执行超时,锁自动过期释放,线程B成功加锁;此时线程A执行完毕执行解锁,会直接删掉线程B持有的锁,导致线程C又能加锁成功,锁彻底失效。
- 锁过期时间无续期机制
AI写死过期时间(如30s),但业务逻辑(库存校验、订单创建、扣减库存)在数据库慢查询、GC卡顿等场景下执行时长可能超过30s,锁自动过期后并发请求直接穿透,引发超卖。
- 解锁非原子操作
解锁逻辑是「先get判断值是否相等,再del」,两步操作非原子。高并发下判断相等后、执行删除前,锁可能刚好过期并被其他线程重新加锁,同样会误删他人的锁。
- 解决方案
(1)紧急止血
线上临时切换为数据库乐观锁(版本号机制)兜底,先止住超卖,再修复分布式锁实现。
(2)根本修复:原子性分布式锁标准实现
基于Redis单命令原子性 + Lua脚本原子解锁,修复所有边界缺陷,核心代码如下:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Component
public class RedisDistributedLock {
private final StringRedisTemplate stringRedisTemplate;
// 解锁Lua脚本:原子性判断持有者+删除
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS1) == ARGV1 then " +
" return redis.call('del', KEYS1) " +
"else " +
" return 0 " +
"end";
private final DefaultRedisScript<Long> unlockScript;
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
this.unlockScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
}
/**
* 原子加锁:SET key value NX PX expireTime
* NX=不存在才设置,PX=毫秒级过期时间,单命令保证原子性
* @param lockKey 锁key
* @param expireTime 过期时间
* @param timeUnit 时间单位
* @return 锁唯一标识(解锁凭证),加锁失败返回null
*/
public String tryLock(String lockKey, long expireTime, TimeUnit timeUnit) {
// 生成唯一锁标识,区分不同线程/请求的锁
String lockValue = UUID.randomUUID().toString().replace("-", "");
long expireMs = timeUnit.toMillis(expireTime);
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, expireMs, TimeUnit.MILLISECONDS);
return Boolean.TRUE.equals(success) ? lockValue : null;
}
/**
* 原子解锁:Lua脚本保证「判断+删除」原子执行
* @param lockKey 锁key
* @param lockValue 锁唯一标识
* @return 是否解锁成功
*/
public boolean unlock(String lockKey, String lockValue) {
if (lockValue == null) return false;
Long result = stringRedisTemplate.execute(
unlockScript,
Collections.singletonList(lockKey),
lockValue
);
return Long.valueOf(1).equals(result);
}
}
业务使用示例(配合try-finally保证解锁必执行):
public void deductStock(Long productId, int quantity) {
String lockKey = "stock:lock:" + productId;
String lockValue = redisLock.tryLock(lockKey, 30, TimeUnit.SECONDS);
if (lockValue == null) {
throw new BusinessException("系统繁忙,请稍后再试");
}
try {
// 校验库存、扣减库存、生成订单等核心业务
stockService.doDeduct(productId, quantity);
} finally {
// 无论业务成功/异常,必须解锁
redisLock.unlock(lockKey, lockValue);
}
}
(3)生产级进阶方案:引入Redisson
对于长耗时业务、高可用要求高的场景,直接使用Redisson分布式锁,自带看门狗自动续期、可重入、联锁、红锁等能力,避免手动实现的遗漏:
• 锁默认30s过期,业务未执行完时,看门狗线程每10s自动续期为30s;
• 服务宕机后看门狗停止,锁自动过期,不会死锁;
• 完全兼容上述原子加锁/解锁逻辑,开箱即用。
(4)配套优化
• 锁粒度细化:按商品ID加锁,而非全商品统一锁,降低锁竞争;
• 增加锁等待重试机制:加锁失败后自旋重试指定次数,提升用户体验;
• 数据库兜底:库存字段增加unsigned约束 + 乐观锁版本号,作为最后一道防线。
-
AI辅助开发避坑总结
-
并发/锁类代码必须人工核查原子性:AI生成的分布式锁、缓存更新等代码,重点检查「操作是否原子」「异常分支是否兜底」「并发场景下的边界行为」,禁止直接复制上线。
-
警惕「分步实现」的陷阱:Redis、数据库等中间件操作,凡是需要「先判断再执行」的逻辑,必须用原子命令/Lua脚本/事务保证原子性,两步操作必然存在并发漏洞。
-
资损场景强制双校验:涉及库存、金额、优惠券等资损场景,分布式锁之外必须增加数据库层面的兜底校验,不能把一致性完全寄托在缓存锁上。