1、基本原理和实现方式对比
分布式锁:满足分布式系统或集群模式下多个进程可见并且互斥的锁。分布式锁的核心思想就是多线程都使用同一把锁,实现程序串行执行。
 
 分布式锁需要具备的条件:
 
| 特性 | 含义 | 
|---|---|
| 可见性 | 多个线程都能感知到变化 | 
| 互斥性 | 分布式锁的最基本的特性,让程序串行执行 | 
| 高可用 | 程序不易崩溃,时刻保证较高的可用性 | 
| 高性能 | 要求分布式锁具备较高的加锁和释放锁性能 | 
| 安全性 | 要求分布式锁具备一定的安全性 | 
常见的分布式锁有三种:
 Mysql: mysql本身就带有锁机制,但是由于mysql性能本身一般,所以采用分布式锁的情况下,其实使用mysql作为分布式锁比较少见
 Redis: redis作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都使用redis或者zookeeper作为分布式锁,利用setnx这个方法,如果插入key成功,则表示获得到了锁,如果有人插入成功,其他人插入失败则表示无法获得到锁,利用这套逻辑来实现分布式锁
 Zookeeper: zookeeper也是企业级开发中较好的一个实现分布式锁的方案,这里不过多阐述。
 
2、Redis分布式锁实现的核心思路
实现分布式锁需要实现的两个基本方法:
- 获取锁 
  
- 互斥:只能有一个线程成功获取到锁
 - 非阻塞:尝试获取一次,成功返回true,失败返回false
 
 - 释放锁 
  
- 手动释放
 - 超时释放:避免服务宕机导致出现死锁
 
 
核心思路:利用redis的setnx特性实现锁的互斥。当第一个线程setnx返回1,代表它获取锁成功,可以执行业务,然后释放锁;其他线程则等待一段时间后进行重试。
 
3、实现分布式锁 V1.0
- 锁对象接口
 
public interface ILock {
    /**
   * 尝试获取锁
   * @param timeoutSec 超时时间(秒)
   * @return
   */
    boolean tryLock(long timeoutSec);
    /**
   * 释放锁
   */
    void unlock();
}
 
- 锁对象实现类
 
public class SimpleRedisLock implements ILock {
    private StringRedisTemplate stringRedisTemplate;
    // 锁的名字(一般与当前业务模块相关)
    private String name;
    private String LOCK_PREFIX = "lock:";
    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }
    @Override
    public boolean tryLock(long timeoutSec) {
        // value建议设置当前线程的id
        long threadId = Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        // 不要直接返回success,自充拆箱可能会出现空指针异常
        return BooleanUtil.isTrue(success);
    }
    @Override
    public void unlock() {
        stringRedisTemplate.delete(LOCK_PREFIX + name);
    }
}
 
- 业务类-VoucherOrderServiceImpl
 
核心代码:
// 使用分布式锁实现一人一单
SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
// 尝试获取锁
boolean isLock = lock.tryLock(1200);
if (!isLock) {
    return Result.fail("不允许重复下单");
}
try {
    return oneUserAndOrder(voucherId);
} finally {
    lock.unlock();
}
 
/**
 * 一人一单
 *
 * @param voucherId
 * @return
 */
@Transactional
/*
    1、将锁放在方法体上,那么这个方法就是一个同步方法,只有一个线程能够进入,会导致性能问题
 */
public /*synchronized */Result oneUserAndOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    /*
        2、将锁放在方法体内存在的问题:方法执行完毕后,锁会被释放,但事务是由Spring管理的
        此时,事务还未提交,锁就被释放了,下一个进程进来,仍会出现线程安全问题
     */
//        synchronized (userId.toString().intern()){
    // 保证一人一单
    Integer count = query().eq("voucher_id", voucherId)
            .eq("user_id", userId).count();
    if (count > 0) {
        return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
    }
    // 扣减库存,添加乐观锁
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            // 这种方式反而会增加下单的失败率
//                .eq("stock", voucher.getStock())
            // 只要我库存还大于0,就允许用户继续下单
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("秒杀券已售罄");
    }
    // 生成订单
    VoucherOrder order = new VoucherOrder();
    long orderID = redisIdWorker.nextId("order");
    order.setId(orderID);
    order.setVoucherId(voucherId);
    order.setUserId(UserHolder.getUser().getId());
    save(order);
    return Result.ok(orderID);
}
 
- 单元测试
 

 
4、分布式锁误删问题
4.1、误删问题
现考虑一种在分布式锁情况下仍会导致线程安全问题的极端情况:
- 线程1获取锁,获取成功,但因业务阻塞问题,导致分布式锁的TTL过期,锁失效
 - 线程2获取锁,获取成功。
 - 线程1执行完业务,释放锁,也就是把线程2的锁给释放掉了。
 - 线程3获取锁,获取成功。
 - 线程2执行完业务,释放锁,也就是释放了线程3的锁
 - 线程3执行完业务,执行释放锁。
 
这种情况下,线程2和线程3存在线程安全问题。
 导致该问题出现的本质原因在于线程在去释放锁的时候,不加判断,都不看这锁是不是自己的就给人家释放了。
4.2、解决方案
分布式锁会被误删的关键是redis再去删除数据的时候,没有做判断,当前线程没有判断在redis中存储的锁是不是自己的那把锁就直接给删掉了。
 解决方案:给锁添加唯一标识(UUID),删除前做一次查询,判断是不是自己的那把锁,如果是,再做删除操作。
- 核心代码更新
 
获取锁
 
 删除锁
 
- 测试
 
准备两个线程
 
 线程1成功获取锁
 
 
 通过手动删除锁,模拟线程1因业务阻塞导致锁过期被删除
 线程2成功获取锁
 
 线程1执行完业务,删除锁
 
 线程2执行完业务,删除锁
 
5、分布式锁的原子性问题
5.1、原子性问题
目前仍存在一种更为极端的情况会导致分布式锁误删问题
- 线程1正常获取锁,执行业务逻辑,执行完毕准备删除锁
 - 经过判断的确是自己的锁,此事发生线程阻塞等意外导致分布式锁TTL到期
 - 线程2进入,获取到锁
 - 切回到线程1,由于之前已经判断过是自己的锁了,直接执行释放锁操作
 
由此造成了分布式锁的误删问题
 造成该问题出现的本质原因是:释放锁的查询判断和删除操作不具备原子性
5.2、通过Lua脚本解决原子性问题
Lua 是一种轻量级的编程语言,具有简洁的语法和强大的功能。它是一种动态类型的语言,支持函数式编程和面向对象编程。Lua 是一种嵌入式脚本语言,可以轻松地集成到其他应用程序中。
 Redis提供了对Lua的支持实现
 Spring提供了调用Lua脚本的API
 基于这些特性,保证分布式锁删除操作原子性的实现思路:
- 将锁查询及删除操作写入到Lua脚本;
 - 通过Spring调用编写好的Lua脚本
 
由于在Java中只有调用Lua脚本这一行操作语句,从而保证了原子性
- unlock.lua
 
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
 
- 释放锁核心代码
 
public class SimpleRedisLock implements ILock {
    private StringRedisTemplate stringRedisTemplate;
    // 锁的名字(一般与当前业务模块相关)
    private String name;
    private String LOCK_PREFIX = "lock:";
    final String uniqueStr = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }
    @Override
    public boolean tryLock(long timeoutSec) {
        // value建议设置当前线程的id
        long threadId = Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, uniqueStr + threadId, timeoutSec, TimeUnit.SECONDS);
        // 不要直接返回success,自充拆箱可能会出现空指针异常
        return BooleanUtil.isTrue(success);
    }
    /**
     * 通过Lua脚本释放锁,保证操作的原子性
     */
    @Override
    public void unlock() {
        stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(LOCK_PREFIX + name), uniqueStr + Thread.currentThread().getId());
    }
//    @Override
//    public void unlock() {
//        // 查询当前线程的锁
//        String lock = stringRedisTemplate.opsForValue().get(LOCK_PREFIX + name);
//        // 如果当前线程的锁是自己的,才能删除
//        if (lock != null && lock.equals(uniqueStr + Thread.currentThread().getId())){
//            stringRedisTemplate.delete(LOCK_PREFIX + name);
//        }
//    }
}










