[系统设计] 高并发架构电商秒杀下单链路设计

经典场景-秒杀超卖

Posted by Penistrong on April 24, 2023

高并发架构电商秒杀下单链路设计

阿里大淘宝一面,经典的超高并发架构下电商秒杀场景题,当时答得不是很好,特地学习一波以待强化

原题三点需求:

  1. 并发量特别大,几十万甚至上百万级别
  2. 用户秒杀后要能够立即获得秒杀结果,对延迟很敏感
  3. 库存不能出现问题,即要解决秒杀超卖问题

由浅至深

先提出一个最简单的需求,减库存场景:

库存量stock存在Redis中,服务端通过RedisTemplate拿到该库存量,再判断其是否大于0,对库存进行减一操作后,再写回Redis中

@PostMapping("/deduct_stock")
public String deduct_stock() {
    int stock = Integer.parseInt(redisTemplate.optForValue().get("stock"));
    if (stock > 0) {
        int realStock = stock - 1;
        redisTemplate.optForValue().set("stock", realStock + "");
    }
    return "seckill_success";
}

如果是高并发情况,服务接到了这些请求后,同时去Redis中取出该库存值,再进行扣减库存操作,显然会出现超卖问题:

由于不能保证各个微服务实例的执行顺序,库存数量被重新写回后可能覆盖了其他请求的减库存写回的值,导致实际秒杀成功的数量远高于库存

加锁?

如果锁住相关对象,保证每次请求修改Redis中的库存值时都是互斥的,就可以避免超卖问题

本地锁是不能考虑了,毕竟JVM的锁也只能基于当前进程,而分布式架构下服务实例在不同的机器、不同的进程中运行,本地锁如synchronizedLock等完全无效

利用分布式锁可以初步解决超卖问题,利用Redis的SETNX设置互斥锁,保证每次只有一个请求可以修改库存值

@Autowired
private StringRedisTemplate redisTemplate;

@PostMapping("/deduct_stock")
public String deduct_stock() {
    // 先加分布式锁
    String lockKey = "lock:product_seckill";
    // SETNX,查看结果
    Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, "locking");
    if (!result) {
        return "seckill_fail";
    }

    int stock = Integer.parseInt(redisTemplate.optForValue().get("stock"));
    if (stock > 0) {
        int realStock = stock - 1;
        redisTemplate.optForValue().set("stock", realStock + "");
    }

    // 扣减成功后,再释放锁
    redisTemplate.delete(lockKey);

    return "seckill_success";
}

上面代码也存在问题:

  1. 抛出异常的话,持有的分布式锁无法被删除,导致死锁

    解决方式: 用一个try...catch...finally包裹,在finally块中释放锁

  2. 问题1还是不能避免JVM崩溃(实例宕机)后,finally块也无法被执行,还是会死锁:

    解决方式: 设置Redis键的过期时间

    ...
    Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, "locking");
    redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
    ...
    
  3. 问题2还是不能避免宕机崩溃问题,因为操作不是原子性的,刚设置完锁还没设置过期时间就宕机,还是会死锁:

    解决方式:利用原子性操作,设置键时同时设置过期时间setIfAbsent(String key, String Value, Int expireTime, TimeUnit.SECONDS)

上述只适用于低并发场景,在实际的高并发秒杀业务场景中,难道某个锁一直不被释放其他服务实例就要一直等待锁自动过期吗?同时,还有一个问题,加的分布式锁对应的值是通用的,可能会导致当前实例设置的锁被其他服务实例删除

首先分析以下第二个问题,直接的想法是,设置键的值用一个跟机器、服务相关的唯一ID(比如UUID、雪花算法),在删除时判定是否是当前实例上的锁,是的话才能执行删除:

...

clientId = UUID.randomUUID().toString();
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);

try{
    ...
} finally {
    if (clientId.equals(redisTemplate.opsForValue().get(lockKey))) {
        redisTemplate.delete(lockKey);
    }
}

锁续命问题

仔细想一下高并发场景下的加锁顺序,还是会存在一个大名鼎鼎的锁续命问题:

实际场景中,过期时间不能太长,如果在持有分布式锁时,业务代码还没执行完锁就过期了!这就不能保证接口的幂等性了,其他服务实例立马抢占到该分布式锁,导致扣减库存失败

对于该问题,解决方法也有很多种:

  1. 同一实例中(同个JVM中),线程1获取到分布式锁后,再开启一个线程监听实际执行业务的线程1是否执行完成,如果没有完成,则去延长对应分布式锁的过期时间

  2. 如果业务逻辑有BUG或者网络不稳定,锁一直不能释放,不可能一直续期,所以可以设置续命次数,超过设定的次数还是失败,就自动释放分布式锁然后回滚事务

利用Redisson提供的分布式锁,直接解决分布式锁+锁续命出现的问题:

@Autowired
private Redisson redisson;

@PostMapping("/deduct_stock")
public String deduct_stock() {
    Rlock redissonLock = redisson.getLock();
    redissonLock.lock();
    try {
        int stock = Integer.parseInt(redisTemplate.optForValue().get("stock"));
        if (stock > 0) {
            int realStock = stock - 1;
            redisTemplate.optForValue().set("stock", realStock + "");
        }
    } finally {
        redissonLock.unlock();
    }
    
    return "seckill_success";
}

RedissonLock::tryLockInnerAsync方法是Redisson提供的分布式锁的核心方法,该方法是异步执行的,底层采取Lua脚本,Redis在执行Lua脚本是原子性的,保证其他服务实例不会加到锁

加锁成功后,会设置一个后台线程(WatchDog)对锁进行续期,每隔10s检查业务是否执行完成,未完成则继续续期

Redis集群问题

超高并发场景下,Redis不可能只存在一个实例,必然要采取Redis集群的形式,如果采取的是主从架构,主从节点身份互换时就会出现锁丢失问题

锁丢失问题剖析

线程1对主节点加锁后,从节点还未来得及同步,主节点宕机了,从节点切换成主节点后丢失了加在主节点的分布式锁,其他线程又可以直接加到该锁

改用RedLock

RedLock是侧重CP架构的分布式锁框架,根据CP架构,它在加锁时,需要超过半数的Redis节点都加锁成功时才认为本次加锁成功

注意这里说的Redis节点,每个节点都有可能是个Redis主从集群

RedLock存在的问题

如果RedLock管理的Redis节点实际是个主从集群,那么还是会出现锁丢失问题:主节点锁丢了从节点还没同步就顶上

所以考虑增加多个独立的Redis节点,但是性能又受到了影响,节点越多加的锁越多查询锁状态需要的时间就越长

除此之外,最好采用奇数个Redis独立节点,为什么呢?

  1. 超过半数问题:如果奇数个节点超过一半加锁成功,仅仅多添加一个独立节点使总节点数量达到偶数个后,就又需要在多的一个节点上加锁,没有必要浪费资源

  2. 持久化问题:redis无论是采用RDB还是AOF持久化,至少都会遇到1s的数据丢失问题

并发问题分析

分布式锁只是串行化了各个业务执行过程,即使执行时间仅有几毫秒,当基数达到$10^6$甚至更大时,显然对于客户来说,秒杀的时间也是不可接受的,执行时间也非常长

阿里面试官最后灵魂发问:

Redis也扛不住这么多加锁的请求,你要怎么办呢? 虽然Redis性能好,QPS高,但是再高能挡得住几十上百万甚至上千万的并发请求吗?

没有考虑过这种问题的我,略微思考只能无奈说出不知道三个大字,自然面完也秒挂了TAT

分段缓存+分段加锁

将单一的库存量分开,按照地区分配秒杀库存量,设置多个库存字段,不同地区内的用户秒杀时,扣减的库存和加的锁是不同的,这样可以分流秒杀请求,提升分布式锁的性能

场景假设: 秒杀商品拥有300个库存,将这些库存分为6个初始库存量为50的分段缓存字段

key1 = stock-01, value = 50;
...
key6 = stock-06, value = 50;

用户下单时可以综合考虑用户所在地区、用户ID等相关特征,秒杀时分配到不同的分段库存字段上,有效分流并发压力

仍然存在的问题:当某段锁对印度各库存不足时,要更换下一个分段库存再次尝试加锁扣减库存,复杂度较高

预扣减+延迟消费

这个方案是我的回答,但是面试官对于Redis并发的问题不怎么满意TAT

  1. 秒杀活动开放前,将商品的库存数量预加载到Redis缓存中

  2. 用户在客户端进行秒杀,服务端接收到秒杀请求,去Redis中预减库存,利用decr操作的原子性或者利用分布式锁

    • 预扣减成功时,直接响应用户告知”秒杀成功”,客户端显示”正在加载订单状态”,这一步是为了保证需求2:”用户秒杀后立刻获得结果

    • 预扣减失败,说明库存不足,响应用户”已抢完,下次手速再快点哦~”,并对其后到达的所有请求执行降级甚至熔断,第一次扣减失败后,后续的秒杀请求直接返回失败

  3. 服务端生产一个扣减真实库存+生成订单的消息,发送到消息队列中,等候异步处理

    • 监听该队列的其他服务实例(或本体)去执行库存扣减并生成订单信息,由于消息队列中的实际库存扣减消息较少(最多也就是秒杀库存的总量),仅利用数据库的事务+锁就可以防止幻读,也不会产生超卖问题
    UPDATE seckill_stocks SET stock = stock - 1 where goods_id = :id and stock > 0
    
  4. 秒杀成功的用户进入”正在加载订单状态”的loading交互过渡状态时,向服务端发起一个长轮询(客户端不断轮询服务端也行,考虑到并发压力还是使用长轮询好一点),等待服务端返回订单信息

    • 如果秒杀成功,则拉取订单信息,进入订单详情页面
    • 如果秒杀失败(库存不足、逻辑失败等),则告知用户秒杀失败

最终方案总结

经过一波秒杀超卖的学习后,综合考虑各个方案的优点,最终处理对应需求的方案如下:

  1. 秒杀活动开放前,库存量字段按照地区、仓库货源、人为规则等方式分为粒度更小的库存分段,将这些分段库存量存储在不同的Redis集群中

  2. 用户进行秒杀,客户端发送请求,服务端接收请求后根据用户IP、所在地区等上下文从分段库存表中找到对应的Redis集群实例,利用Redisson加上分布式锁,然后预扣减库存

    • 预扣减成功,直接响应用户”秒杀成功”,进入加载动画等过渡性交互状态,客户端向服务端发起一个长轮询,等侯返回秒杀状态+实际订单信息

    • 预扣减失败,直接响应用户”秒杀失败”,同时该服务实例对之后指向相同分段库存的所有秒杀请求执行降级逻辑(直接熔断),直接返回”秒杀失败”,不将压力延续到Redis上

    这一步还可以强化,如果某个地区的分段库存被抢光,也可以换一个库存进行预扣减,但是也会增加其他地区中Redis集群的压力,我觉得这一点可以跟面试官讨论一下(或者不讨论免得自讨没趣233)

  3. 预扣减成功时,服务端生产一个扣减真实库存+生成订单的消息,发送到消息队列中。订阅该队列的服务实例收到消息后,执行真实的库存扣减并生成订单信息。如果还想进一步降低MySQL的压力,可以提前对实际的秒杀库存表进行分库,存到不同地区对应的MySQL库中,每个库单独负责其对应分段库存的实际扣减

  4. 接受长轮询请求的服务端会开启一个后台线程,利用快照读(仅通过MVCC而不用加锁,不会加锁而影响正在修改库存的事务)不断轮询对应的MySQL分库中的表,查看秒杀商品的状态,如果订单生成成功则对应商品的状态就会被更新,然后去订单表中拉取订单详情,返回给客户端。长轮询的时间可以设置地短一点,3~10秒都可以接受

  5. 长轮询请求正确返回时,对应的秒杀用户进入订单详情页面;如果超时或者返回失败信息,则告知用户”系统错误/Something Error/WTF“…

扩展-少卖问题: 预扣减库存成功后,业务系统最终却没有扣减真实库存成功或者生成具体的订单信息,导致用户即使秒杀确实成功但交易失败

解决方案: 这个问题是出现在后续的消息队列阶段,如果消息队列投递的消息没有被正确消费,就会出现这种情况,通常可以设置重试次数,超过重试次数还没有消费成功,则将该消息加入死信队列或者持久化到磁盘,让补偿服务进行处理,确保订单正确生成