一人一单的场景

意思就是 上一篇中 200人抢100个优惠券, 但是一个人只能抢一个!
像之前那种我们jmeter 中同一个token (同一个用户)抢走所有的肯定是不行的。

刚开始的思路很简单:
每次抢券的时候 我额外查一下这个用户是不是已经有这个券了
返回一个信息:该用户已经购买过这张券了:

//查一下这个用户是否已经抢过优惠券 一人只能获取一单
         int count = query().eq("user_id",userId).eq("voucherId",voucherId).count();

         if(count>0){
             return Result.fail("已经购买过 秒杀券一人只能买一次");
         }
         
         ..........如果=0 就正常减库存生成订单 往下走

然后我们可以对它用syn关键字加锁

但是 在集群情况下 又出现了 并发安全问题:

我们来jmeter 试一下集群两个服务下 ,一堆线程来抢这一张券:100张变成了90张 我们希望结果是99张 只抢走一张

RedissonClient 获取lock以及操作_分布式


所以集群情况下 进程就有多个 你单个进程里面 怎么加锁 都没用。因为你管不了其他进程。


这时候怎么办呢? 这时候就要掏出来一手 最终兵器——分布式锁

比较正规的一个解释是:

RedissonClient 获取lock以及操作_分布式锁_02

比较常见的分布式锁 有三种:

RedissonClient 获取lock以及操作_分布式锁_03

因为这是redis修炼 我们主要还是看 redis的分布式锁。

首先背景很清楚了 两个进程 就是两个jvm。 那么jvm内部的锁 你怎么加锁 也锁不住另一进程。 所以锁必须要独立于这两个进程之外。

在正式开始之前:
我们先在idea 整两台 service 来模拟集群 。然后用nginx做一下 负载均衡。

RedissonClient 获取lock以及操作_分布式_04


这样我们方便配合jmeter测试。

分布式锁需要思考的问题是:


命令的原子性
setnx 和 expire 命令联合使用:

set key value ex nx

在set操作后面 我们通过参数可以同时 加上nx的互斥效果和过期时间 ,这样最大的好处就是把这两个命令合二为一 原子性统一。 这是分布式锁的基础


阻塞和非阻塞的选择:
对于新手 可能不清楚 什么是阻塞和非阻塞,这里我曾经总结过一篇:

这里我们使用非阻塞, 所谓的非阻塞就是 线程1 拿到了锁 线程2发现获取锁失败的时候 就之间返回false给它。 而不是让他的程序一直等在那里 不往下执行/

redis分布式锁的初级版本

public class SimpleRedisLock implements ILock{



    //锁的业务名称
    private String name;

    private static final String KEY_PREFIX = "lock:";

    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name,StringRedisTemplate stringRedisTemplate){
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        //获取线程的标识
       long threadId = Thread.currentThread().getId();
       Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,threadId+"",timeoutSec, TimeUnit.SECONDS);
       //这里拆箱 有可能是空指针,所以这样写
       return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX+name);
    }
}

在这种情况 我们jmeter 同时发给两个服务 然后测试发现ok
100张里面剩99张! 这就ok啦

设计分布式锁的value

这里有一个重要的问题 一定要注意,我们set nx ex的时候 这个分布式锁叫什么名字有讲究
因为你既要表示出 哪个线程得到了这把锁 又要让这个线程id不重复:

可以使用uuid 随机生成一个id 再和线程id拼起来就好了


分布式锁的误删问题

刚才这是理想情况,但是有一种极端情况:
我们获取锁之后 设置的时间比如是5分钟。 可是获取锁之后 我们的线程1业务流程出现了阻塞要10分钟。
业务流程还没完 已经触发了redis锁的超时时间。 这时候锁已经丢了 但是我们还没执行完。
那么又出现了线程安全问题。 这是其一。

其二 这时候线程2 并还没有出现阻塞 它几分钟就执行完了。 这时候它释放了锁。可是这把锁不是它的 原本是线程1 的这时候线程1还没有执行完。 线程2就把这锁给释放了 这个又出来问题了。 这是其二。

其一问题我们暂时也放一放。 我们先说第二个:第二个的解决方法比较好理解:
当一个线程2 执行完去删除的时候 这时候 你要判断一下 这个锁和你的set 进去的那个 value也就是分布式锁的值 是不是相同:

public void unlock() {
      1  String threadId = ID_PREFIX+Thread.currentThread().getId();
      2 
      3  String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX+name);
      4
      5  if (threadId.equals(id)){
      6      stringRedisTemplate.delete(KEY_PREFIX+name);
        }
    }

像这样。 是不是就万事大吉了?

– 答案是么有 这里还有问题(。。坑。。。)

就是操作的原子性
**就是如果你判断成功 到了代码第5行。 这时候你可以删锁了
但是这时候 有一个非常极端的就是 遇到jvm的垃圾回收 full gc
它会拖慢代码的运行。 这时候 你迟迟没有删掉这个锁 导致触发了redis超时释放
这时候! 注意 新的线程拿到了这把锁 开始运行 而你的jvm垃圾回收搞完了 总算是到了删除锁的运行了 这时候 他就把新的线程的锁给删了! 因为这是发生在 if (threadId.equals(id))判断之后 的 所以这个判断拦不住它
**
那怎么解决呢 我们要保证
5 if (threadId.equals(id)){
6 stringRedisTemplate.delete(KEY_PREFIX+name);
这两行代码的原子性。 让他们一起执行 。

怎么保证原子性 请看下一篇 :

Redis修炼 (11.redis lua脚本解决分布式锁的原子性)