分布式环境在@Transation注解下锁释放问题

问题

今天在尝试使用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的事务注解,这样外部没有事务,SeckillSupportcreateOrder方法会自己新建一个事务,且事务提交在锁释放之后,这样就不会出现锁释放后其他线程在事务还没有执行前查询到了空的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条重复数据。事务的传播机制确实需要按需进行设置,单纯加注解在很多场景下虽然适用,但像下单支付这种场景还是多考虑一点

相关推荐
h7ml2 小时前
企业微信API接口的数据一致性保障:Java Seata分布式事务在跨系统审批流程中的应用
java·分布式·企业微信
拾贰_C2 小时前
【centos7 | Linux | redis】Redis安装
linux·运维·redis
升职佳兴2 小时前
Hadoop 三节点集群环境变量工程化:从 /etc/profile 迁移到 /etc/profile.d/ 全过程记录
大数据·hadoop·分布式
fengxin_rou2 小时前
redis主从和集群一致性、哨兵机制详解
java·开发语言·数据库·redis·缓存
珠海西格2 小时前
红区蔓延的底层逻辑:分布式光伏爆发与配电网短板的“时空错配”
大数据·服务器·分布式·安全·架构
小马爱打代码2 小时前
分布式订单系统:订单号编码设计实战
分布式
LSL666_2 小时前
11 redis核心配置参数
数据库·redis·缓存
爱笑的源码基地2 小时前
基于云计算的基层医疗信息系统,springMVC框架开发的云HIS系统源码
spring boot·后端·源码·二次开发·his·源代码·医院管理信息系统