JAVA-实战8 Redis实战项目—雷神点评(4)分布式锁

小鳥の翼がついに大きくなって

分布式锁

集群下的线程并发安全问题

在集群环境下,每台机器对应一个JVM,都存在一个独立的锁监视器,而常规的线程锁synchronized只能保证一个JVM的多线程安全,在集群环境下下无法生效
image

基本原理和实现方式

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

分布式锁的特点:

多进程可见
互斥
高性能
高可用
安全性

分布式锁示意图如下:
image

分布式锁常见实现方式主要基于MYSQL,Redis,Zookeeper这三种,对比如下:
image

Redis实现分布式锁

Redis实现分布式锁的基本操作:
获取锁,利用setnx的互斥特性

setnx lock thread1

释放锁,手动释放

del lock

为防止死锁,加锁时设置超时时间

expire lock 10

setnx后添加属性,nx是互斥,ex是设置超时时长

setnx lock thread1 nx ex 10

获取锁时失败的话,要么阻塞失败等待释放锁,要么非阻塞失败立即返回结果。

非阻塞式锁效果如下,尝试一次成功返回true失败返回false
image

实现代码如下:

// 加锁的工具类
@Component
public class SimpleRedisLock implements ILock{
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private String Name;
    // 加锁方法
    @Override
    public boolean TryLock(long TimeoutSecond) {
        long CurrentThreadId = Thread.currentThread().threadId();

        Boolean SuccessOrFail = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+Name,CurrentThreadId+"",TimeoutSecond, TimeUnit.SECONDS);

        return Boolean.TRUE.equals(SuccessOrFail);
    }
    // 去锁方法
    @Override
    public void UnLock() {
        stringRedisTemplate.delete(KEY_PREFIX+Name);

    }
}

修改服务层结构,将一人一单的判断以及下单记录封装成为独立方法,并且添加@Transactional保证余量更新和购买记录生效的一致性,随后对于该部分进行获取锁判断,由于加锁为非阻塞锁所以如果获取锁失败那么直接返回错误结果,如果获取锁成功那么执行业务,而无论业务是否成功都要释放锁

@Slf4j
@Service
public class VoucherOrderServiceImpl implements VoucherOrderService {
    @Autowired
    private VoucherSecondKillMapper voucherSecondKillMapper;
    @Autowired
    private VoucherOrderMapper voucherOrderMapper;
    @Autowired
    private RedisIdWorker redisIdWorker;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    @Transactional
    public ResultData VoucherSecondKill(Long VoucherId) {
        VoucherSecondKillData Voucher = voucherSecondKillMapper.selectById(VoucherId);

        if(Voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return ResultData.error("The SecondKill dose not start!");
        }

        if(Voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return ResultData.error("The SecondKill has ended!");
        }

        if(Voucher.getStock()<1) {
            return ResultData.error("The Stock is not enough!");
        }
        long UserId = Long.parseLong(CurrentHolder.getCurrent().getId());
        SimpleRedisLock lock = new SimpleRedisLock(ORDER_KEY_PREFIX+UserId,stringRedisTemplate);
        boolean IsLock = lock.TryLock(1200);
        if(!IsLock) {
            return ResultData.error("Error:Repeat Order!");
        }
        try {
            VoucherOrderService proxy = (VoucherOrderService) AopContext.currentProxy();
            return proxy.CreateNewVoucherOrder(VoucherId);
        } finally {
            lock.UnLock();
        }

    }

    @Override
    @Transactional
    public ResultData CreateNewVoucherOrder(Long VoucherId) {
        Long UserId = Long.valueOf(CurrentHolder.getCurrent().getId());
        QueryWrapper<VoucherOrderData> orderlwp = new QueryWrapper<VoucherOrderData>();
        orderlwp.eq("voucher_id",VoucherId).eq("user_id",UserId).select("count(*) as Number");
        List<Map<String,Object>> Result = voucherOrderMapper.selectMaps(orderlwp);
        Long Count = (Long) Result.get(0).get("Number");
        if(Count>0) {
            return ResultData.error("You have already purchased this voucher!");
        }

        LambdaUpdateWrapper<VoucherSecondKillData> lwp = new LambdaUpdateWrapper<VoucherSecondKillData>();
        lwp.eq(VoucherSecondKillData::getVoucherId,VoucherId);
        voucherSecondKillMapper.UpdateSecondKillVoucher(lwp,1L);

        VoucherOrderData NewVoucherOrder = new VoucherOrderData();

        Long VoucherOrderId = redisIdWorker.NewId("Order");
        NewVoucherOrder.setId(VoucherOrderId);

        NewVoucherOrder.setUserId(UserId);

        NewVoucherOrder.setVoucherId(VoucherId);
        voucherOrderMapper.insert(NewVoucherOrder);

        return ResultData.success(NewVoucherOrder);
    }
}

Q:VoucherOrderService proxy = (VoucherOrderService) AopContext.currentProxy(); return proxy.CreateNewVoucherOrder(VoucherId);这两行代码的作用是什么,为什么不是执行调用return CreateNewVoucherOrder(VoucherId);
A:这段代码的核心目的是‌解决SpringBoot中“自调用”导致事务注解(@Transactional)失效的问题‌。

在Spring框架中,如果一个类内部的方法直接调用同一个类中的另一个带有@Transactional注解的方法,事务通常不会生效。因为正常调用Service层方法时请求的是Spring容器中的‌代理对象‌,而代理对象会先执行切面逻辑(如开启事务),然后再调用目标对象的真实方法。如果在 VoucherSecondKill方法内部使用this.CreateNewVoucherOrder(VoucherId)或直接调用CreateNewVoucherOrder(VoucherId),这是对象内部的直接方法调用。‌它绕过了代理对象‌,直接执行了目标方法。因此,CreateNewVoucherOrder方法上的@Transactional注解会被忽略,事务不会开启,如果发生异常也无法回滚。

为了解决这个问题,代码使用了AopContext.currentProxy()来获取当前对象的代理实例,这是SpringAOP提供的一个工具方法,其从当前线程的上下文(ThreadLocal)中获取绑定在当前线程上的‌代理对象实例‌,因为Spring在执行代理方法时,会将代理对象存入ThreadLocal,以便在方法执行过程中随时可以获取到当前代理版本,并通过代理对象来调用目标方法,从而确保事务逻辑被正确触发。

使用时还需要注意,由于代码显式获取当前AOP代理对象,所以需要开启代理暴露,配置方法为在配置类上添加@EnableAspectJAutoProxy(exposeProxy = true)

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class LeiShenCommentApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeiShenCommentApplication.class, args);
    }

}

@EnableAspectJAutoProxy的核心功能依赖于AspectJ的织入器。如果项目中没有引入aspectjweaver依赖,Spring 在初始化AOP上下文时会因为找不到相关类而崩溃。因此还需要添加依赖如下:

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

否则会报错:

Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2026-05-05T22:36:03.404+08:00 ERROR 5044 --- [LeiShenComment] [           main] o.s.boot.SpringApplication               : Application run failed

封装常量

public class RedisConstants {
    public static final String KEY_PREFIX = "lock:";
    public static final String ORDER_KEY_PREFIX = "order:";
}

开始测试,首先要模拟集群下的多线程,在IDEA底边栏添加services处添加SpringBoot服务,随后添加新配置里选择添加虚拟机选项并设置端口
image
添加完成后,在获取锁处设置断点,重新启动服务,可以看到线程1获取锁成功,而线程二失败了
image
image
此时查看Redis数据库:
image

Redis锁误删问题

如果当前存在多线程并发,其中

线程1先启动并加锁但是由于某些原因导致线程阻塞,从而导致Redis锁超时被释放
线程2启动后获取锁成功并加锁,开始执行业务
线程1恢复后执行业务完成并释放锁(为防止死锁无论执行情况如何都要释放锁)
线程3启动后获取锁成功并加锁,开始执行业务,此时线程2和线程3并行执行

示意图如下,这显然不符合加锁的初衷
image

很显然,解决问题的关键在于释放锁时对锁进行判断
image

业务流程图修改如下:
image

解决锁误删问题

修改分布式锁实现,满足:

1.在获取锁时存入线程标示(可以用UUID表示)
2.在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

如果一致则释放锁
如果不一致则不释放锁

修改加锁工具类代码如下:

@Data
@AllArgsConstructor
public class SimpleRedisLock implements ILock{
    private String Name;
    private StringRedisTemplate stringRedisTemplate;
    private static final String ID_PREFIX = UUID.randomUUID() +"-";

    @Override
    public boolean TryLock(long TimeoutSecond) {
        // 针对Redis锁的value添加一个UUID,其中每一个线程的UUID都保证独一无二
        String CurrentThreadId = ID_PREFIX + Thread.currentThread().getId();

        Boolean SuccessOrFail = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+Name,CurrentThreadId,TimeoutSecond, TimeUnit.SECONDS);

        return Boolean.TRUE.equals(SuccessOrFail);
    }

    @Override
    public void UnLock() {
        String CurrentThreadId = ID_PREFIX + Thread.currentThread().getId();
        String LockThreadId =  stringRedisTemplate.opsForValue().get(KEY_PREFIX+Name);
        if(CurrentThreadId.equals(LockThreadId)) {
            stringRedisTemplate.delete(KEY_PREFIX+Name);
        }
    }
}

执行测试:
首先执行线程一,加锁完成
image
image

物理删除锁模拟线程一锁超时
image

随后执行线程二,加锁成功并且成功执行
image
image

重新执行线程一并释放锁,发现当前Redis分布式锁和线程一的锁不同,所以无法释放
image

继续执行线程二,发现当前Redis分布式锁和线程二的锁相同,所以可以释放
image

分布式锁原子性问题

如果线程一在判断进行锁判断完成后发生阻塞,阻塞完成后接下来继续执行去锁操作,依旧会导致已加的锁误删,示意图如下:
image

这时需要使用Lua脚本来解决问题@冰川日菜,一起噜♪起来!

Lua脚本

为了解决分布式锁在释放时可能出现的误删问题,需要保证“判断锁是否属于当前线程”和“删除锁”这两个操作的原子性。Lua脚本是解决这一问题的最佳方案,因为Redis执行Lua脚本时具有原子性,中间不会插入其他命令。

使用Lua语言执行Redis命令,其中agrs_num表示key类型参数。

EVAL "return redis.call(command,key,value)" args_num [arg...]

image

测试效果如下:
image
image

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数(务必要严格区分大小写):
image
image

进一步改造分布式锁

首先编写Lua脚本unlock.lua,其中KEYS代表锁在Redis中的Key,ARGV代表当前尝试解锁的线程的唯一标识:

-- 比较线程中标识和锁的标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
    -- 如果一致,说明锁确实是当前线程持有的,则去锁
    return redis.call('del',KEYS[1])
end
-- 如果不一致(说明锁已过期或被他人持有),则不执行删除,直接返回0
return 0

修改分布式锁工具类如下:

@Data
@AllArgsConstructor
public class SimpleRedisLock implements ILock{
    private String Name;
    private StringRedisTemplate stringRedisTemplate;
    private static final String ID_PREFIX = UUID.randomUUID() +"-";
    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 TryLock(long TimeoutSecond) {
        String CurrentThreadId = ID_PREFIX + Thread.currentThread().getId();

        Boolean SuccessOrFail = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+Name,CurrentThreadId,TimeoutSecond, TimeUnit.SECONDS);

        return Boolean.TRUE.equals(SuccessOrFail);
    }

    @Override
    public void UnLock() {
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX+Name), // KEYS
                ID_PREFIX + Thread.currentThread().getId() // ARGV
                );
    }
}
posted @ 2026-05-05 20:48  tcswuzb  阅读(27)  评论(0)    收藏  举报