秒杀场景下的业务梳理——Redis分布式锁的优化

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

随着互联网的快速发展商品秒杀的场景我们并不少见秒杀是一种供不应求的高并发的场景它里面包含了很多技术点掌握了其中的技术点虽不一定能让你面试立马成功但那也必是一个闪耀的点

前言

假设我们现在有一个商城系统里面上线了一个商品秒杀的模块那么这个模块我们要怎么设计呢

秒杀模块又会有哪些不同的需求呢

全局唯一 ID

商品秒杀本质上其实还是商品购买所以我们需要准备一张订单表来记录对应的秒杀订单。

这里就涉及到了一个订单 id 的问题了我们是否可以像其他表一样使用数据库自身的自增 id 呢

数据库自增 id 的缺点

订单表如果使用数据库自增 id 则会存在一些问题

  1. id 的规律太明显了 因为我们的订单 id 是需要回显给用户查看的如果是 id 规律太明显的话会暴露一些信息比如第一天下单的 id = 10 第二天下单的 id = 11这就说明这两单之间根本没有其他用户下单
  2. 受单表数据量的限制 在高并发场景下产生上百万个订单都是有可能的而我们都知道 MySQL 的单张表根本不可能容纳这么多数据性能等原因的限制如果是将单表拆成多表还是用数据库自增 id 的话就存在了订单 id 重复的情况了很显然这是业务不允许的。

基于以上两个问题我们可以知道订单表的 id 需要是一个全局唯一的 ID而且还不能存在明显的规律。

全局 ID 生成器

全局ID生成器是一种在分布式系统下用来生成全局唯一ID的工具一般要满足下列特性

这里我们思考一下是否可以用 Redis 中的自增计数来作为全局 id 生成器呢

能不能主要是看它是否满足上述 5 个条件

  1. 唯一性每个订单都是来 Redis 这里生成订单 id 的所以唯一性可以保证
  2. 高可用Redis 可以由主从、集群等模式保证可用性
  3. 高性能Redis 是基于内存的本来就是以性能自称的
  4. 递增性increment 本来就是递增的
  5. 安全性。。。这个就麻烦了点了因为 Redis 的 increment 也是递增的规律太明显了。。。

综上Redis 的 increment 并不能满足安全性所以我们不能单纯使用它来做全局 id 生成器。

但是——

我们可以使用它再和其他东西拼接起来~

举个栗子

ID的组成部分

  1. 符号位1bit永远为0
  2. 时间戳31bit以秒为单位可以使用69年
  3. 序列号32bit秒内的计数器支持每秒产生2^32个不同ID

上面的时间戳就是用来增加复杂性的

下面给出代码样例

public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;
​
    private StringRedisTemplate stringRedisTemplate;
​
    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
​
    public long nextId(String keyPrefix) {
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
​
        // 2.生成序列号
        // 2.1.获取当前日期精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增长
        // 每天一个key
        long count = stringRedisTemplate.opsForValue()
                                        .increment("icr:" + keyPrefix + ":" + date);
​
        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

Redis自增ID策略

  1. 每天一个key方便统计订单量
  2. ID构造是 时间戳 + 计数器

扩展

全局唯一ID生成策略

  1. UUID
  2. Redis自增需要额外拼接
  3. snowflake算法
  4. 数据库自增

超卖问题的产生

解决方案

超卖问题是典型的多线程安全问题针对这一问题的常见解决方案就是加锁

锁有两种

一悲观锁 认为线程安全问题一定会发生因此在操作数据之前先获取锁确保线程串行执行。例如Synchronized、Lock都属于悲观锁

二乐观锁 认为线程安全问题不一定会发生因此不加锁只是在更新数据时去判断有没有其它线程对数据做了修改。

如果没有修改则认为是安全的自己才更新数据。 如果已经被其它线程修改说明发生了安全问题此时可以重试或异常。

乐观锁的两种实现

下面介绍乐观锁的两种实现

第一种添加版本号

每扣减一次就更改一下版本号每次进行扣减之前需要查询一下版本号只有在扣减时的版本号和之前的版本号相同时才进行扣减。

第二种CAS法

因为每扣减一次库存量都会发生改变的所以我们完全可以用库存量来做标志标志当前库存量是否被其他线程更改过在这种情况下库存量的功能和版本号类似

下面给出 CAS 法扣除库存时针对超卖问题的解决方案

   // 扣减库存
   boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1") // set stock = stock - 1
                    .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                    .update();

请注意上述的 CAS 判断有所优化了的并不是判断刚查询的库存和扣除时的库存是否相等而是判断当前库存是否大于 0。

因为 判断刚查询的库存和扣除时的库存是否相等会出现问题假如多个线程都判断到不相等了那它们都停止了扣减这时候就会出现没办法买完了。

而 判断当前库存是否大于 0则可以很好地解决上述问题

一人一单的需求

一般来说秒杀的商品都是优惠力度很大的所以可能存在一种需求——平台只允许一个用户购买一个商品。

对于秒杀场景下的这种需求我们应该怎么去设计呢

很明显我们需要在执行扣除库存的操作之前先去查查数据库是否已经有了该用户的订单了如果有了说明该用户已经下单过了不能再购买如果没有则执行扣除操作并生成订单。

// 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 判断是否存在
if (count > 0) {
    // 用户已经购买过了
    return Result.fail("用户已经购买过一次");
}
​
// 扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1") // set stock = stock - 1
        .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
        .update();

并发安全问题

因为上述的实现是分成两步的

  1. 判断当前用户在数据库中并没有订单
  2. 执行扣除操作并生成订单

也正因为是分成了两步所以才引发了线程安全问题 可以是同一个用户的多个请求线程都同时判断没有订单后续则大家都执行了扣除操作。

要解决这个问题也很简单只要让这两步串行执行即可也就是加锁

在方法头上加 synchronized

很显然这种会锁住整个方法锁的范围太大了而且会对所有请求线程作出限制而我们的需求只是同一个用户的请求线程串行就可以了显然有些大材小用了~

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
    // 一人一单
    Long userId = UserHolder.getUser().getId
     // 查询订单
     int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
     // 判断是否存在
     if (count > 0) {
         // 用户已经购买过了
         return Result.fail("用户已经购买过一次");

     // 扣减库存
     boolean success = seckillVoucherService.update()
             .setSql("stock = stock - 1") // set stock = stock - 1
             .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
             .update();
     if (!success) {
         // 扣减失败
         return Result.fail("库存不足");

     // 创建订单
     VoucherOrder voucherOrder = new VoucherOrder();
     .....
     return Result.ok(orderId);
}

锁住同一用户 id 的 String 对象

@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 一人一单
    Long userId = UserHolder.getUser().getId

    // 锁住同一用户 id 的 String 对象
    synchronized (userId.toString().intern()) {
        // 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 判断是否存在
        ......

        // 扣减库存
        ......

        // 创建订单
        ......
     }
     return Result.ok(orderId);
}

上述方法开启了事务但是synchronized (userId.toString().intern())锁住的却不是整个方法先释放锁再提交事务写入订单那就存在一个问题——假如一个线程的事务还没提交也就是还没写入订单这时候其他线程来了却可以获得锁它判断数据库中订单为0 又可以再次创建订单。。。。

为了解决这个问题我们需要先提交事务再释放锁

 // 锁住同一用户 id 的 String 对象
 synchronized (userId.toString().intern()) {
     ......
    createVoucherOrder(voucherId);
     ......
 }
​
@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 一人一单
    Long userId = UserHolder.getUser().getId

        // 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 判断是否存在
        ......

        // 扣减库存
        ......

        // 创建订单
        ......

     return Result.ok(orderId);
}

集群模式下的并发安全问题

刚刚讨论的那些都默认是单机结点的可是现在如果放在了集群模式下的话就会出现一下问题。

刚刚的加锁已经解决了单机节点下的线程安全问题但是却不能解决集群下多节点的线程安全问题

因为 synchronized 锁的是对应 JVM 内的锁监视器可是不同的结点有不同的 JVM不同的 JVM 又有不同的锁监视器所以刚刚的设计在集群模式下锁住的其实还是不同的对象即无法解决线程安全问题。

知道问题产生的原因我们应该很快就想到了解决办法了

既然是因为集群导致了锁不同那我们就重新设计一下让他们都使用同一把锁即可

分布式锁

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

分布式锁的实现

分布式锁的核心是实现多进程之间互斥而满足这一点的方式有很多常见的有三种

MySQLRedisZookeeper
互斥利用mysql本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开连接自动释放锁利用锁超时时间到期释放临时节点断开连接自动释放

基于 Redis 的分布式锁

用 Redis 实现分布式锁主要应用到的是 SETNX key value命令如果不存在则设置

主要要实现两个功能

  1. 获取锁设置一个 key
  2. 释放锁 删除 key

基本思想是执行了 SETNX命令的线程获得锁在完成操作后需要删除 key释放锁。

加锁

@Override
public boolean tryLock(long timeoutSec) {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

释放锁

@Override
public void unlock() {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 释放锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

可是这里会存在一个隐患——假设该线程发生阻塞或者其他问题一直不释放锁删除 key这可怎么办

为了解决这个问题我们需要为 key 设计一个超时时间让它超时失效但是这个超时时间的长短却不好确定

  1. 设置过短会导致其他线程提前获得锁引发线程安全问题
  2. 设置过长线程需要额外等待

锁的误删

超时时间是一个非常不好把握的东西因为业务线程的阻塞时间是不可预估的在极端情况下它总能阻塞到 lock 超时失效正如上图中的线程1锁超时释放了导致线程2也进来了这时候 lock 是 线程2的锁了key 相同value不同value一般是线程唯一标识假设这时候线程1突然不阻塞了它要释放锁如果按照刚刚的代码逻辑的话它会释放掉线程2的锁线程2的锁被释放掉之后又会导致其他线程进来线程3如此往复。。。

为了解决这个问题需要在释放锁时多加一个判断每个线程只释放自己的锁不能释放别人的锁

释放锁

@Override
public void unlock() {
    // 获取线程标示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 获取锁中的标示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);

    // 判断标示是否一致
    if(threadId.equals(id)) {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

原子性问题

刚刚我们谈论的释放锁的逻辑

  1. 判断当前锁是当前线程的锁
  2. 当前线程释放锁

可以看到释放锁是分两步完成的如果你是对并发比较有感觉的话应该一下子就知道这里会存在问题了。

分步执行并发问题

假设 线程1 已经判断当前锁是它的锁了正准备释放锁可偏偏这时候它阻塞了可能是 FULL GC 引起的锁超时失效线程2来加锁这时候锁是线程2的了可是如果线程1这时候醒过来因为它已经执行了步骤1了的所以这时候它会直接直接步骤2释放锁可是此时的锁不是线程1的了

其实这就是一个原子性的问题刚刚释放锁的两步应该是原子的不可分的

要使得其满足原子性则需要在 Redis 中使用 Lua 脚本了。

引入 Lua 脚本保持原子性

lua 脚本

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

Java 中调用执行

public class SimpleRedisLock implements ILock {
​
    private String name;
    private StringRedisTemplate stringRedisTemplate;
​
    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }
​
    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = 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);
    }
​
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }
​
    @Override
    public void unlock() {
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
}
​

到了目前为止我们设计的 Redis 分布式锁已经是生产可用的相对完善的分布式锁了。

总结

这一次我们从秒杀场景的业务需求出发一步步地利用 Redis 设计出一种生产可用的分布式锁

实现思路

  1. 利用set nx ex获取锁并设置过期时间保存线程标示
  2. 释放锁时先判断线程标示是否与自己一致一致则删除锁 (Lua 脚本保证原子性)

有哪些特性

  1. 利用set nx满足互斥性
  2. 利用set ex保证故障时锁依然能释放避免死锁提高安全性
  3. 利用Redis集群保证高可用和高并发特性

目前还有待完善的点

  1. 不可重入同一个线程无法多次获取同一把锁
  2. 不可重试获取锁只尝试一次就返回false没有重试机制
  3. 超时释放锁超时释放虽然可以避免死锁但如果是业务执行耗时较长也会导致锁释放存在安全隐患虽然已经解决了误删问题但是仍然可能存在未知问题
  4. 主从一致性如果Redis提供了主从集群主从同步存在延迟当主宕机时在主节点中的锁数据并没有及时同步到从节点中则会导致其他线程也能获得锁引发线程安全问题延迟时间是在毫秒以下的所以这种情况概率极低
阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: redis