问题
今天在尝试使用Redis实现分布式锁,实现分布式环境下一人一单场景,使用Jmeter测试时order表一直生成了两条记录,因为当第一个线程拿到锁后,只要订单创建成功,后续线程即使拿到锁了查询到order表有数据就不会进行插入。这里刚开始很神奇的点在于,我打印控制台发现有两个线程拿到锁后打印当前order表数据都是空,后面想了很多地方,最后发现是我把锁释放的操作放在@Transation里面了,这样锁先释放了,数据库事务还没有提交,order表还没有数据,此时后面线程拿到锁后由于事务未提交,查询到的order表数据就是空的
线程B 数据库 Redis锁 线程A 线程B 数据库 Redis锁 线程A 核心问题:锁释放在事务内,事务未提交锁已释放 此时线程A事务未提交,数据库无订单数据 最终order表因A/B双事务提交,生成两条重复订单 1. 获取分布式锁(lock:order:1001) 锁获取成功 2. 查询订单(order:1001)→ 无数据 查询结果为空 3. 执行插入订单SQL(事务未提交,数据仅在事务内可见) 4. 释放分布式锁(@Transactional内执行,锁先释放) 锁释放成功 5. 获取分布式锁(lock:order:1001)→ 锁已释放,抢占成功 锁获取成功 6. 查询订单(order:1001)→ 线程A事务未提交,无数据 查询结果为空 7. 执行插入订单SQL(事务未提交,数据仅在事务内可见) 8. 线程A事务提交(第一条订单写入数据库) 9. 线程B释放分布式锁 锁释放成功 10. 线程B事务提交(第二条订单写入数据库) 分布式锁+@Transactional 导致一人多单的核心流程
原本的代码
Redis锁
java
public interface Lock {
boolean lock(String orderId);
void unlock(String orderId);
}
public class SimpleRedisLock implements Lock{
private final RedisTemplate<String, Object> redisTemplate;
public SimpleRedisLock(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public static final long TTL = 1200;
public static final String LOCK_KEY = "lock:order:";
private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean lock(String orderId) {
String threadId = ID_PREFIX + Thread.currentThread().threadId();
Boolean b = redisTemplate.opsForValue().setIfAbsent(LOCK_KEY + orderId, threadId, TTL, TimeUnit.SECONDS);
return Boolean.TRUE.equals(b);
}
@Override
public void unlock(String orderId) {
redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(LOCK_KEY + orderId),
ID_PREFIX + Thread.currentThread().threadId());
}
}
// lua脚本
-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
service层代码
java
@Service
@Transactional(rollbackFor = Exception.class)
@Slf4j
public class SeckillService {
@Autowired private RedisTemplate<String, Object> redisTemplate;
@Autowired private SeckillSupport seckillSupport;
/**
* 一人一单场景
* */
public void seckill(Long userId, Long productId) {
SimpleRedisLock lock = new SimpleRedisLock(redisTemplate);
boolean isLock = lock.lock(userId + "-" + productId);
if (!isLock) {
throw new BusinessException("请勿重复下单");
}
try {
log.warn(Thread.currentThread().threadId() + "-" + isLock);
seckillSupport.createOrder(userId, productId);
} finally {
lock.unlock(userId + "-" + productId);
}
}
}
support层代码
java
@Component
@Slf4j
public class SeckillSupport {
@Autowired private ProductMapper productMapper;
@Autowired private OrderMapper orderMapper;
@Transactional
public void createOrder(Long userId, Long productId) {
List<Order> orders = orderMapper.findAll();
log.warn("线程" + Thread.currentThread().threadId() + "查询订单" + orders.toString());
// 1.是否已经下单
int isOrdered = orderMapper.count();
if (isOrdered > 0) {
// 1.1已经下单返回错误信息,事务回滚
throw new BusinessException("已下单");
}
// 2.更新库存,更新失败就是库存不足
if (productMapper.updateStockById(productId) <= 0) {
throw new BusinessException("库存不足");
}
// 3.创建订单
log.warn("线程" + Thread.currentThread().threadId() + "创建订单");
Order order = new Order(null, userId, productId, LocalDateTime.now(), LocalDateTime.now());
orderMapper.insert(order);
}
}
原因分析
由于事务的传播机制影响,这里都是REQUIRED,seckillSupport外部有事务,所以加入外部事务成为其一部分,因此事务未提交锁先释放导致数据库中出现了两条重复数据

解决方法
第一种:如果外部不需要事务,删除外部事务注解
直接删除SeckillService的事务注解,这样外部没有事务,SeckillSupport的createOrder方法会自己新建一个事务,且事务提交在锁释放之后,这样就不会出现锁释放后其他线程在事务还没有执行前查询到了空的order表

第二种:把createOrder设置为REQUIRES_NEW
REQUIRES_NEW不管外部有没有事务都需要新建事务,且外部事务与内部事务互不影响,使用这种传播方式需要保证逻辑是可以独立运行的,不然外部事务执行异常时内部事务无法进行回滚
java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createOrder(Long userId, Long productId) {
List<Order> orders = orderMapper.findAll();
log.warn("线程" + Thread.currentThread().threadId() + "查询订单" + orders.toString());
// 1.是否已经下单
int isOrdered = orderMapper.count();
if (isOrdered > 0) {
// 1.1已经下单返回错误信息,事务回滚
throw new BusinessException("已下单");
}
// 2.更新库存,更新失败就是库存不足
if (productMapper.updateStockById(productId) <= 0) {
throw new BusinessException("库存不足");
}
// 3.创建订单
log.warn("线程" + Thread.currentThread().threadId() + "创建订单");
Order order = new Order(null, userId, productId, LocalDateTime.now(), LocalDateTime.now());
orderMapper.insert(order);
}
总结
刚开始还以为是我写的分布式锁有问题,改了发现还是不对,而且Jmeter测试中100个线程不会出现问题,试了200个线程和2000个线程都是一样的问题,后面还拿了30000个线程去测试,数据库一直都是2条重复数据。事务的传播机制确实需要按需进行设置,单纯加注解在很多场景下虽然适用,但像下单支付这种场景还是多考虑一点