秒杀

121 阅读5分钟

全局id生成器

在redis新建一个自增长的key,利用时间戳(31bit)+序列号(32bit)拼接实现唯一id。此key还可以记录数量。

库存超卖问题

悲观锁

比较适合插入数据。

悲观锁可以实现对于数据的串行化执行,synchronization,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等。

乐观锁

在更改数据时判断之前查询到的数据是否有被修改过。

比较适合更新数据。

版本号法

有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过。

CAS法

利用cas进行无锁化机制加锁,通过判断要更改的数据和查询到的数据是否达到预估的结果。可以看成是版本号法的mini版。

一人一单

单机模式下的线程安全问题

插入数据,使用悲观锁。

控制锁粒度

利用用户id作为锁,锁的范围尽可能的小。

小tips

toString()是new出来的对象,要想保证拿到同一把String锁,得使用intern()方法,她是从常量池拿数据的。

先提交事务再释放锁

避免事务还未提交,锁就释放了导致出现线程安全问题。

小tips

spring中,在方法A通过对象调用方法B,B具有事务,此时要想让B的事务也生效,这个调用对象得是bean(代理对象)。

集群模式下的线程安全问题

分布式锁(手写)

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁,核心思想就是让大家都使用同一把锁。

image.png

误删问题

原因:持有锁的线程还没执行完业务就超时释放了锁,然后删除了别的线程的锁。

解决:线程只能删自己的锁,给每个锁加唯一value。

锁的原子性问题

原因:判断唯一value和释放锁不是同一条语句,中间可能会阻塞,导致别的线程趁虚而入。

解决:Lua脚本把判断和释放锁当成一个命令,保证原子性。

分布式锁(Redisson)

流程图:

image.png

可重入锁

在hash中用value记录重入次数,获取同把锁锁加1,释放同把锁减1。要删除前会先判断value是否等于0。

KEYS[1] : 锁名称

ARGV[1]: 锁失效时间

ARGV[2]: id + ":" + threadId; 锁的小key (field,对应value是重入次数)

获取锁:

"if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + //大key+小key判断
                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              "return redis.call('pttl', KEYS[1]);"

获取锁成功返回的是null,失败则返回锁的剩余毫秒数。

释放锁:

"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
        "return nil;" +
        "end; " +
        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
        "if (counter > 0) then " +
        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
        "return 0; " +
        "else " +
        "redis.call('del', KEYS[1]); " +
        "redis.call('publish', KEYS[2], ARGV[1]); " +
        "return 1; " +
        "end; " +
        "return nil;"

锁重试

利用信号量(Semaphore,并发工具类)和PubSub(发布订阅模式,一种设计模式)功能实现等待,唤醒,获取锁失败的重试机制。

    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
        Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            return true;
        }
        
        time -= System.currentTimeMillis() - current; //最大等待时间-获取锁消耗的时间
        if (time <= 0) {
            acquireFailed(waitTime, unit, threadId);
            return false;
        }
        
        current = System.currentTimeMillis();
        //subscribe订阅释放锁的消息(publish)
        RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
        //await:当方法在指定的时间内完成是为true
        if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
            if (!subscribeFuture.cancel(false)) {
                subscribeFuture.onComplete((res, e) -> {
                    if (e == null) {
                        //超时取消订阅
                        unsubscribe(subscribeFuture, threadId);
                    }
                });
            }
            acquireFailed(waitTime, unit, threadId);
            return false;
        }

        try {
            //剩余等待时间 - 等待订阅消耗的时间
            time -= System.currentTimeMillis() - current;
            if (time <= 0) {
                acquireFailed(waitTime, unit, threadId);
                return false;
            }
        
            while (true) {
                long currentTime = System.currentTimeMillis();
                ttl = tryAcquire(waitTime, leaseTime, unit, threadId);//重试
                // lock acquired
                if (ttl == null) {
                    return true;
                }
                //再次判断剩余等待时间
                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }

                // waiting for message
                currentTime = System.currentTimeMillis();
                if (ttl >= 0 && ttl < time) {//判断过期时间是否小于剩余等待时间
                    subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                } else {
                    subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                }

                time -= System.currentTimeMillis() - currentTime;
                if (time <= 0) {
                    acquireFailed(waitTime, unit, threadId);
                    return false;
                }
            }
        } finally {
            unsubscribe(subscribeFuture, threadId);
        }
    }

超时续约

利用watchDog,每隔一段时间,重置超时时间。只有在未指定锁超时时间时才会使用看门狗,每隔一段时间:releaseTime(默认30s) / 3,重置超时时间,确保锁是因为业务执行完释放,而不是因为阻塞释放。

具体源码就不放了,我还没弄懂,以后有需要再来看。ps:P67

MutiLock

使用multiLock锁代替主从,每个节点的地位都是一样的,这把锁加锁的逻辑需要写入到每一个节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

image.png API:创建联锁:redissonClient.getMultiLock(锁1, 锁2, 锁3...)

三种分布式锁对比

  1. 手写分布式锁
  • 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断唯一value。
  • 缺点:不可重入,无法重试,锁超时失效。
  1. Redisson的分布式锁
  • 原理:利用hash结构,记录线程标识和重入次数;利用信号量和PubSub控制锁重试等待;利用watchDog延续锁时间。
  • 缺点:redis宕机引起锁失效问题。
  1. Redisson的multiLock
  • 原理:多个独立的Redis节点,必须在所有节点都获取锁,才算获取锁成功。

秒杀优化

先利用redis完成判断是否具有购买资格(代替锁),再把耗时较久的数据库写操作放入阻塞队列,用独立线程异步下单。

image.png