基于 Redis 手动实现分布式锁

296 阅读5分钟

关于锁

先简单归纳下对锁的理解:

  • "锁" 是资源分配的需求产物, 无论是现实生活中用的锁还是代码架构上的锁.
  • "锁" 既是一个工具, 也是一种机制, 在代码或者架构逻辑中意味着同步化.
  • "锁" 本质是内存中的一个标识, 同时更是维护这个标识的一整套逻辑.
  • JDK 提供的锁是 JVM 维度的锁, 只能作用于同一个 JVM 内的不同线程.
  • 为了同步不同 JVM 间的线程进入临界区, 分布式锁应运而生.

基于 Redis 的分布式锁

0. 预设一个场景

就拿用例用到烂的秒杀系统来说吧, 毕竟经典场景.

简要逻辑如下图, 服务部署在不同的机器上:

graph TB
C(请求) --访问--> B[服务] --操作--> A[(库存信息)]
E(请求) --访问--> D[服务] --操作--> A
G(请求) --访问--> F[服务] --操作--> A

短时间内大量的秒杀请求打在不同的服务上, 服务对库存信息的写入必定要同步化.

那么从设计的角度如何设计这些服务呢, 比较理智一点的做法是引入只能被一个服务持有的第三方变量, 只有持有这个变量的服务才能访问库存信息.

这个变量和这套规则便是分布式锁.

graph TB
C(请求) --访问--> B[服务] --获取--> L{{"独立于服务外的变量 x (锁标志, 同一时间只有一个线程能获取到)"}} --操作--> A[(库存信息)]
E(请求) --访问--> D[服务] --获取--> L
G(请求) --访问--> F[服务] --获取--> L

接下来逐步实现.

1. 最简单的业务逻辑

假设库存信息已经存好在 Redis 服务里, 键是 "stock", 值为整数(暂无业务意义, 只为了标识有这个键值对).

最简单的业务逻辑可以写成这样:

@RestController
@RequestMapping("/step1")
public class Step1Controller {

    private final BoundValueOperations<String, String> stockOps;

    public Step1Controller(RedisTemplate<String, String> redisTemplate) {
        this.stockOps = redisTemplate.boundValueOps("stock");
    }

    @PostMapping("/product")
    public synchronized Object submitOrder() {
        try {
            // 拿
            long stock = Long.parseLong(Objects.requireNonNull(stockOps.get()));
            // 改
            if (stock > 0) {
                stock--;
            }
            // 存
            stockOps.set(String.valueOf(stock));
        } catch (Exception e) {
            return "秒杀失败";
        }
        return "秒杀成功";
    }
}

直接对库存进行操作, 问题很明显, 只加了 synchronized , 这样的逻辑只适合在单机应用中.

2. 带超时的简单分布式锁

将分布式锁的锁标志存在 Redis 内, 加上加锁/解锁逻辑, 便是一个最简单的分布式锁.

这个逻辑中还特地加上了锁自动过期的逻辑, 有效防止了程序在释放锁前崩溃导致其它线程永远无法获取锁, 从而导致死锁.

@RestController
@RequestMapping("/step2")
public class Step2Controller {
    
    private final RedisTemplate<String, String> redisTemplate;
    private final BoundValueOperations<String, String> stockOps;
    private final BoundValueOperations<String, String> lockOps;

    public Step2Controller(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.stockOps = redisTemplate.boundValueOps("stock");
        this.lockOps = redisTemplate.boundValueOps("lock");
    }

    @PostMapping("/product")
    public Object submitOrder() {
        try {
            // 加锁 设置超时锁自动释放
            boolean locked = Boolean.TRUE == lockOps.setIfAbsent("1", 15, TimeUnit.SECONDS);
            
            if (locked) {
                // 拿 改 存 的业务逻辑
            }
            return "秒杀成功";
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放锁
            if (lockOps.get() != null) {
                redisTemplate.delete("lock");
            }
        }
        return "秒杀失败";
    }
}

锁操作的具体逻辑不应耦合在业务代码中, 可以抽离成 RedisUtils :

@Component
public class RedisUtils {

    public RedisTemplate<String, String> getRedisTemplate() {
        return this.redisTemplate;
    }

    private final RedisTemplate<String, String> redisTemplate;

    private final BoundValueOperations<String, String> lockOps;

    private static final String LOCK = "lock";
  
    public RedisUtils(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.lockOps = redisTemplate.boundValueOps(LOCK);
    }
  
    /**
     * 加锁逻辑
     * @return boolean
     */
    public boolean tryLock() {
        return Boolean.TRUE == lockOps.setIfAbsent("1", 15, TimeUnit.SECONDS);
    }
  
    /**
     * 释放锁逻辑
     */
    public void unlock() {
        if (lockOps.get() != null) {
          redisTemplate.delete(LOCK);
        }
    }
}

然后业务代码就可以写成这样了:

    @PostMapping("/product")
    public synchronized Object submitOrder() {
        try {
            // 获取锁
            if (redisUtils.tryLock()) {
                // 拿 改 存 的业务逻辑
                return "秒杀成功";
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放锁
            redisUtils.unlock();
        }
        return "秒杀失败";
    }

不过这样还存在严重的问题: 谁都能闲着没事来解一下锁!

这就有可能导致, 别的程序员在某个地方直接调用 redisUtils.unlock(), 本来有锁的直接被解了.

所以正常情况下, 分布式锁的加锁和解锁的动作必须由同一个线程来完成.

要达到这个要求也不难, 如下.

3. 带调用闭环逻辑的简单分布式锁

"调用闭环" 指的是一个线程调用了这个分布式锁的加锁, 那么解锁也必须由这个线程完成.

实现: 引入全局 ID 区分线程, 使用 threadLocal 存储.

RedisUtils 只需稍作修改:

@Component
public class RedisUtils {

    public RedisTemplate<String, String> getRedisTemplate() {
        return this.redisTemplate;
    }

    private final RedisTemplate<String, String> redisTemplate;

    private final BoundValueOperations<String, String> lockOps;

    private static final String LOCK = "lock";
  
    private final ThreadLocal<String> threadLocal =
      ThreadLocal.withInitial(() -> UUID.randomUUID().toString());
  
    public RedisUtils(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.lockOps = redisTemplate.boundValueOps(LOCK);
    }
  
    /**
     * 加锁逻辑
     * @return boolean
     */
    public boolean tryLock() {
        return Boolean.TRUE == lockOps.setIfAbsent(threadLocal.get(), 15, TimeUnit.SECONDS);
    }
  
    /**
     * 释放锁逻辑
     */
    public void unlock() {
        if (threadLocal.get().equals(lockOps.get())) {
          redisTemplate.delete(LOCK);
        }
    }
}

上述代码即可解决闭环问题.

不过这个分布式锁仍然存在有问题的地方.

如下图, 当同一个线程的方法间调用发生时(即 方法 A 调用 方法 B), 而两个方法都有访问临界资源的情况时, 在方法 B 中无论是加锁还是解锁都是不可接受的.

graph TB
X((start)) --> A
C --> Y((end))
subgraph 方法A
A(1.加锁) --> B(2.逻辑处理) --> G(6.另一些逻辑处理) --> C(7.解锁)
end
subgraph 方法B
D(3.加锁) --> E(4.逻辑处理) --> F(5.解锁)
end
B --> D
F --> G

方法 B 里的第 3 步加锁不会成功, 而且有多余的 IO 成本.

而解锁更严重, 一般解锁逻辑是放在 finally 块里, 大概率是会执行的, 一执行就是提前释放锁, 会导致线程回到 A 方法时无锁操作临界资源, 进而导致程序错误.

所以这就涉及到一个问题: 锁的可重入性.

要解决也不难, 参考 JVM 中的锁设计即可.

4. 带可重入性的分布式锁

只需要加多一个变量存重入次数就行了, 每加一次锁, 变量+1, 每解一次锁变量-1.

由于这个变量是单个 JVM 线程内生效的, 所以无需存到 Redis, 只需本线程内部维护即可.

用一个键值对来保存线程唯一标志和重入次数, 修改 RedisUtils 如下:

@Component
public class RedisUtils {

    public RedisTemplate<String, String> getRedisTemplate() {
        return this.redisTemplate;
    }

    private final RedisTemplate<String, String> redisTemplate;

    private final BoundValueOperations<String, String> lockOps;

    private static final String LOCK = "lock";

    private final ThreadLocal<lockEntry> threadLocal =
            ThreadLocal.withInitial(() -> new lockEntry(UUID.randomUUID().toString(), 1));

    public RedisUtils(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.lockOps = redisTemplate.boundValueOps(LOCK);
    }

    /**
     * 加锁逻辑
     * @return boolean
     */
    public boolean tryLock() {
        // 加锁 设置超时
        boolean locked = Boolean.TRUE == lockOps.setIfAbsent(threadLocal.get().uuid, 15, TimeUnit.SECONDS);
        // 可重入
        if (!locked && threadLocal.get().uuid.equals(lockOps.get())) {
            threadLocal.get().count++;
        }
        return locked;
    }

    /**
     * 释放锁逻辑
     */
    public void unlock() {
        if (!threadLocal.get().uuid.equals(lockOps.get())) {
            return;
        }
        // 可重入
        if (threadLocal.get().count > 0) {
            threadLocal.get().count--;
        }
        // 释放锁
        if (threadLocal.get().count == 0L) {
            redisTemplate.delete(LOCK);
        }
    }

    @Data
    @AllArgsConstructor
    static class lockEntry {
        private String uuid;
        private Integer count;
    }
}

至此为止这个分布式锁就相对能用了.

当然这个版本的分布式锁也存在不完善的地方, 到底是哪里不完善呢? 你能发现吗?