高并发系统设计 -- 秒杀系统

文章探讨了高并发秒杀场景下的系统设计,包括页面静态化以减轻服务器压力,使用CDN加速页面加载,前端按钮控制防止无效请求,以及缓存策略如内存淘汰、超时剔除和主动更新。针对缓存问题,提到了缓存穿透的主动和被动防御措施,如布隆过滤器。缓存击穿和缓存雪崩的解决方案,如互斥锁、双key策略和后台更新。还讨论了数据库与缓存一致性,包括先更新数据库再删除缓存的方法,以及库存管理中的乐观锁和Redis扣减库存的原子性操作。最后提到了限流和验证码在防止恶意请求中的作用。
摘要由CSDN通过智能技术生成

代码在:https://github.com/sjmshsh/Seckill

高并发秒杀

秒杀问题相信大家都知道的,虽然是一个烂大街的项目,但是秒杀问题背后的知识是很值得学习的,很多高并发系统设计都可以参照秒杀系统来进行实现。而且顺着这个问题,我会教给大家如何进行高并发的系统设计。

我们先来看一下脑图,了解一下我们可以获得的知识。

在这里插入图片描述

页面静态化

我们知道,我们要进行秒杀的时候,必定是要进入一个页面的,这个页面可能会有很多图片,商品信息等等,我们在此页面的时候,用户很可能在秒杀活动开始之前,去查看一些信息,看一些图片,或者秒杀成功之后,不退出页面,继续查看页面的一些信息。而每查看一次信息,都会增加服务的压力。因此我们需要将页面做静态化处理。用户游览商品等常规操作,并不会请求到服务端,这样可以过滤大多数无效的请求。

CDN

页面静态化仍然是不够的,为了让用户更好的加载秒杀页面,提高用户的体验感,我们需要使用CDN,由于用户之间地理位置差异可能较大,因此应该使用CDN让用户就近获取秒杀页面,提高响应速度和命中率。

秒杀按钮

用户进行秒杀操作的时候,可能会持续点击按钮,以最快速度进行秒杀。这样会产生很多无效请求,给服务器带来巨大的压力,因此在秒杀活动开始之前,前端让按钮变灰,不可点击。当秒杀时间到达的时候,才可以进行点击。

缓存问题

在秒杀场景中,并发量巨大,因此我们必定会使用缓存,例如Redis,而且我们不仅要使用Redis,还得搭建Redis集群,那么就产生了使用缓存的一系列问题。

缓存更新策略

  • 内存淘汰
  • 超时剔除
  • 主动更新

对于低一致性的需求,我们一般使用内存淘汰或者超时剔除。但是对于高一致性的需求,我们需要使用主动更新策略。

主动更新有3种策略:

  • 由缓存的调用者,在更新数据库的同时更新缓存
  • 缓存与数据库整合为一个服务,由服务来维护一致性,调用者只需要调用服务,不需要关心缓存一致性问题
  • 调用者只操作缓存,由其他线程异步将缓存数据持久化到数据库,保证最终一致性。

缓存穿透

例如,我们把需要秒杀的商品做了缓存,但是有些用户会恶意的请求不存在的商品id,这样缓存无法命中,就会直接打在数据库上,数据库无法承受并发压力,从而最终宕机。

图片

这就是典型的缓存穿透问题。

解决方案如下:

主动防御
  • 在前端处对请求进行合法性校验,把恶意的请求直接过滤掉,例如请求参数不合理,请求字段不存在等等
  • 加强用户的权限校验,不是所有的用户都有资格发送这样的请求
  • 做好热点参数的限流
被动防御
  • 缓存空值或者缺省值。缺点是会有格外的内存消耗
  • 布隆过滤器。缺点是我需要考虑布隆过滤器和Redis不一致的问题,MySQL和Redis数据不一致的问题就已经够呛了,你再来个布隆过滤器和Redis之间的数据不一致问题,这是无法接受的。因此布隆过滤器这个解决方案只能用于缓存数据更新很少的场景中。
布隆过滤器

布隆过滤器由一个初值都为0的bit数组和N和哈希函数组成。当我们想要标记某一个数据存在的时候会做以下操作:

  • 使用N个哈希函数分别计算这个数据的哈希值,得到N个哈希值。
  • 把这N个哈希值对bit数组的长度取模,得到每个哈希值在数组种对应的位置
  • 把对应位置的bit位设置为1,完成标记操作

当查询某一个数据的时候,就执行刚才的计算过程,得到N个位置,如果发现有一个位置不为1,那么就说明查询的数据没有在Redis中保存。

缓存击穿

缓存击穿也叫热点key问题,被一个高并发访问并且缓存重建业务比较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大冲击。

图片

解决方案如下:

  • 互斥锁+逻辑过期时间。具体实现为:

在这里插入图片描述

  • 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

缓存雪崩

缓存雪崩是指大量缓存同时失效。

导致Redis缓存雪崩一般来说有两个可能性:

  • 大量缓存在同一时间失效
  • Redis服务宕机

图片

缓存同时失效

解决方案如下:

  • 均匀设置过期时间,给数据的过期时间加上一个随机数

  • 互斥锁。当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

    实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。因此最好使用set命令加上过期时间(保证原子性)。

  • 双key策略。我们对缓存数据可以使用两个 key,一个是主 key,会设置过期时间,一个是备 key,不会设置过期,它们只是 key 不一样,但是 value 值是一样的,相当于给缓存数据做了个副本。

    当业务线程访问不到「主 key 」的缓存数据时,就直接返回「备 key 」的缓存数据,然后在更新缓存的时候,同时更新「主 key 」和「备 key 」的数据。

    双 key 策略的好处是,当主 key 过期了,有大量请求获取缓存数据的时候,直接返回备 key 的数据,这样可以快速响应请求。而不用因为 key 失效而导致大量请求被锁阻塞住(采用了互斥锁,仅一个请求来构建缓存),后续再通知后台线程,重新构建主 key 的数据。

  • 后台线程更新。业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新

Redis宕机
  • 搭建Redis高可用集群
  • 服务熔断或者限流

缓存与数据库之间的一致性

我们一般有两种方式:

  • 先更新缓存再更新数据库
  • 先更新数据库再更新缓存

这两种方法都会导致缓存和数据库之前不一致的问题,只是先更新缓存再更新数据库出现问题的概率稍微小一点而已。那么接下来介绍如何尽可能的让它们保持一致。

先更新缓存再更新数据库

使用延迟双删。

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。

所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。

但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。

因此,还是比较建议用「先更新数据库,再删除缓存」的方案。

先更新数据库再更新缓存
  • 重试机制
  • 订阅MySQL的binlog
重试机制

image.png

如果删除缓存失败了,那么就把需要删除的key加入到消息队列,进行消息的补偿重试。

如果补偿成功,就把这个消息删了,否则继续进入队列一直补偿,直到成功为止。

订阅MySQL的binlog

先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

下图是 Canal 的工作原理:

图片

所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。

库存问题

数据库扣减库存

使用数据库扣减库存,是最简单的实现方案了,假设扣减库存的sql如下:

update product set stock=stock-1 where id=123;

这种写法对于扣减库存是没有问题的,但如何控制库存不足的情况下,不让用户操作呢?

这就需要在update之前,先查一下库存是否足够了。

伪代码如下:

int stock = mapper.getStockById(123);
if(stock > 0) {
  int count = mapper.updateStock(123);
  if(count > 0) {
    addOrder(123);
  }
}

大家有没有发现这段代码的问题?

没错,查询操作和更新操作不是原子性的,会导致在并发的场景下,出现库存超卖的情况。

有人可能会说,这样好办,加把锁,不就搞定了,比如使用synchronized关键字。

确实,可以,但是性能不够好。

还有更优雅的处理方案,即基于数据库的乐观锁,这样会少一次数据库查询,而且能够天然的保证数据操作的原子性。

只需将上面的sql稍微调整一下:

update product set stock=stock-1 where id=product and stock > 0;

在sql最后加上:stock > 0,就能保证不会出现超卖的情况。

但需要频繁访问数据库,我们都知道数据库连接是非常昂贵的资源。在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现死锁的问题。

但是实际上,我们不可能用MySQL去扣减库存的,这样效率太低了,我们会使用Redis来扣减库存。

Redis扣减库存

redis的incr方法是原子性的,可以用该方法扣减库存。伪代码如下:

 boolean exist = redisClient.query(productId,userId);
  if(exist) {
    return -1;
  }
  int stock = redisClient.queryStock(productId);
  if(stock <=0) {
    return 0;
  }
  redisClient.incrby(productId, -1);
  redisClient.add(productId,userId);
return 1;
复制代码

代码流程如下:

  1. 先判断该用户有没有秒杀过该商品,如果已经秒杀过,则直接返回-1。
  2. 查询库存,如果库存小于等于0,则直接返回0,表示库存不足。
  3. 如果库存充足,则扣减库存,然后将本次秒杀记录保存起来。然后返回1,表示成功。

估计很多小伙伴,一开始都会按这样的思路写代码。但如果仔细想想会发现,这段代码有问题。

有什么问题呢?

如果在高并发下,有多个请求同时查询库存,当时都大于0。由于查询库存和更新库存非原则操作,则会出现库存为负数的情况,即库存超卖。

当然有人可能会说,加个synchronized不就解决问题?

调整后代码如下:

   boolean exist = redisClient.query(productId,userId);
   if(exist) {
    return -1;
   }
   synchronized(this) {
       int stock = redisClient.queryStock(productId);
       if(stock <=0) {
         return 0;
       }
       redisClient.incrby(productId, -1);
       redisClient.add(productId,userId);
   }

return 1;

加synchronized确实能解决库存为负数问题,但是这样会导致接口性能急剧下降,每次查询都需要竞争同一把锁,显然不太合理。

那么,有没有更好的方案呢?

lua脚本

我们都知道lua脚本,是能够保证原子性的,它跟redis一起配合使用,能够完美解决上面的问题。

lua脚本有段非常经典的代码:

  StringBuilder lua = new StringBuilder();
  lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
  lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
  lua.append("    if (stock == -1) then");
  lua.append("        return 1;");
  lua.append("    end;");
  lua.append("    if (stock > 0) then");
  lua.append("        redis.call('incrby', KEYS[1], -1);");
  lua.append("        return stock;");
  lua.append("    end;");
  lua.append("    return 0;");
  lua.append("end;");
  lua.append("return -1;");

该代码的主要流程如下:

  1. 先判断商品id是否存在,如果不存在则直接返回。
  2. 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
  3. 如果库存大于0,则扣减库存。
  4. 如果库存等于0,是直接返回,表示库存不足。

一人一单

我们可以使用分布式锁实现一人一单问题。

并且我们为了让分布式锁能够释放,要添加过期时间,这个过期时间需要能够比秒杀时间长。

因此我们可以用Redis的这个命令:

# 添加锁,利用setnx的互斥特性
SETNX lock thread1

# 添加锁过期时间,避免服务器宕机的死锁
EXPIRE lock 10

setnx命令和expire命令必须是分开使用的,这就造成了一个问题,如果我在这两个命令之间添加失败了,那么这个锁就没有过期时间了,导致永不过期。因此我们需要保证原子性,可以使用lua脚本,但是其实Redis的set命令是可以做到的。

SET lock thread1 NX EX 10

在这里插入图片描述

消息队列异步处理

在这里插入图片描述

  • 我们可以基于Lua判断秒杀库存呢,一人一单,决定用户是否秒杀成功
  • 如果秒杀成功了,将产品ID和用户ID封装后存入消息队列
  • 开启线程任务,不断从消息队列中获取消息,实现异步下单

Lua脚本如下:

-- 1. 参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.2 用户id
local userId = ARGV[2]

-- 2. 数据key
-- 2.1 库存key
local stockKey = 'seckill:stock' .. voucherId
-- 2.2 订单key
local stockKey = 'seckill:order' .. voucherId

-- 3. 脚本业务
-- 3.1 判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.2 判断用户是否下单 SISMEMBER orderKey userId
if redis.call('sismember', orderKey, userId) == 1 then
    -- 3.3 存在说明是重复下单
    return 2
end
-- 3.4 扣库存(incrby stockKey - 1)
redis.call('incrby', stockKey, -1)
-- 3.5 下单(保存用户) sadd orderKey userId
redis.call('sadd', orderKey, userId)

消息队列引发的其他问题可以看看我的这一篇文章:

https://blog.csdn.net/qq_61039408/article/details/128071331?spm=1001.2014.3001.5501

限流问题

对同一个用户进行限流

限制同一个用户id,比如每分钟只能请求5次接口。

但是,有的人可以模拟不同用户进行恶意请求,因此我们要限制同一个IP地址

对同一个IP进行限流

限制同一个ip,比如每分钟只能请求5次接口。

但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。

别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次都请求都换一个ip。

因此我们对接口进行限流

对接口进行限流

在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。

加入验证码

通常情况下,用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。

此外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。

普通验证码,由于生成的数字或者图案比较简单,可能会被破解。优点是生成速度比较快,缺点是有安全隐患。

还有一个验证码叫做:移动滑块,它生成速度比较慢,但比较安全,是目前各大互联网公司的首选。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

胡桃姓胡,蝴蝶也姓胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值