概述
秒杀系统需要解决两个核心问题,一是并发读,一是并发写,对应到架构设计,就是高可用、一致性和高性能的要求。
1 秒杀页面瞬时高并发访问的问题
1.1 秒杀页面动静分离
动静分离的首要目的是将动态页面改造成适合缓存的静态页面。动静分离三步走:1、数据拆分;2、静态缓存;3、数据整合
1.1.1 数据拆分
主要从以下 2 个方面进行:
- 用户身份信息
- 用户身份信息包括登录状态以及登录画像等,相关要素可以单独拆分出来,通过动态请求进行获取;
- 与之相关的广平推荐,如用户偏好、地域偏好等,同样可以通过异步方式进行加载
- 秒杀时间:秒杀时间是由服务端统一管控的,可以通过动态请求进行获取
1.1.2 静态缓存
静态化改造的一个特点是直接缓存整个 HTTP 连接而不是仅仅缓存静态数据,如此一来,Web 代理服务器根据请求 URL,可以直接取出对应的响应体然后直接返回,响应过程无需重组 HTTP 协议,也无需解析 HTTP 请求头。
而作为缓存键,URL唯一化是必不可少的,只是对于商品系统,URL 天然是可以基于商品 ID 来进行唯一标识的,比如淘宝的https://item.taobao.com/item.htm?id=xxxx
静态数据缓存到哪里呢?可以有三种方式:1、浏览器;2、CDN ;3、代理服务端。
1.1.3 数据整合
分离出动静态数据之后,前端如何组织数据页就是一个新的问题,主要在于动态数据的加载处理,通常有两种方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。
- ESI 方案:Web 代理服务器上请求动态数据,并将动态数据插入到静态页面中,用户看到页面时已经是一个完整的页面。这种方式对服务端性能要求高,但用户体验较好
- CSI 方案:Web 代理服务器上只返回静态页面,前端单独发起一个异步 JS 请求动态数据。这种方式对服务端性能友好,但用户体验稍差
1.2 流量过滤
本质上,参与秒杀的用户很多,但是商品的数量是有限的,真正能抢到的用户并不多,那么第一步就是要过滤掉大部分无效的秒杀请求流量
- 秒杀前秒杀按钮置灰:防止秒杀前用户刷新产生的无效请求
- 添加验证码或者答题:防止瞬间产生超高的流量,起到错峰的效果
- 活动校验:对参与活动的资格进行校验,包括针对用户白名单、用户终端、IP地址、参与活动次数、黑名单用户等的校验
- 非法请求拦截:比如验证码+活动校验至少需要0.5秒,则将0.5秒以内的请求就可以完全拦截掉。。
- 限流: 假设秒杀10000件商品,有5台服务器,单机的QPS在1000,那么理论上2秒就可以抢完,针对微服务就可以做限流配置,避免后续无效的流量打到数据库造成不必要的压力。
常用的限流方式是: 基于nginx限流、基于redis限流,限流手段如下:- 栅栏方式限流:在系统约定的请求开始的时间内随机偏移一段时间,针对每个请求的偏移量不同,如果在偏移时间之内就会被拦截,反之通过。
- 对同一用户限流: 为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制
- 对同一ip限流: 可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的
- 对接口限流:对于系统的稳定性是非常有必要的,可能由于有些非法请求次数太多,达到了该接口的请求上限
熔断限流降级,根据压测情况进行限流,可以使用sentinel或者hystrix。
2 商品缓存问题
2.1 缓存击穿问题
某一个热点key,在不停地扛着高并发,当这个热点key在失效的一瞬间,持续的高并发访问就击破缓存直接访问数据库,导致数据库宕机。
解决思路如下:
- 设置热点数据"永不过期"
- 加上互斥锁:多个线程同时去查询数据库中这个热点key的数据,可以在第一个查询数据的请求上使用一个互斥锁来锁住它
其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后将数据放到redis缓存起来。后面的线程进来发现已经有缓存了,就直接走缓存
2.1 缓存穿透问题
缓存穿透说简单点就是⼤量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这⼀层。举个例⼦:某个⿊客故意制造我们缓存中不存在的 key 发起⼤量请求,导致⼤量请求落到数据库。
解决思路:将秒杀的商品ID列表放在布隆过滤器中,如果不存在,则直接返回失败。
更多缓存穿透问题的解决思路参见:redis缓存雪崩、穿透、击穿的概念及解决思路
3 解决不超卖问题
3.1 分布式锁
3.1.1 Redis分布式锁
使用redis set加锁,不要使用setnx,因为setnx加锁和后面的设置超时时间是分开的,并非一步完成,无法保证原子操作
使用redis的set命令,它可以指定多个参数
private static final String OK="OK";
//加锁
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if (OK.equals(result)) {
return true;
}
return false;
//释放锁
if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;
参数说明,详见 redis set命令
lockKey:锁的标识
requestId:请求id
NX:只在键不存在时,才对键进行设置操作。
PX:设置键的过期时间为 millisecond 毫秒。
expireTime:过期时间
3.1.2 避免均匀分布的秒杀
使用自旋锁
在规定的时间,比如500毫秒内,自旋不断尝试加锁
- 如果成功则直接返回。
- 如果失败,则休眠50毫秒,再发起新一轮的尝试。
- 如果到了超时时间,还未加锁成功,则直接返回失败。
private static final String OK="OK";
private static final int timeoutInMs = 500;
try {
Long start = System.currentTimeMillis();
while(true) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if (OK.equals(result)) {
return true;
}
if ((System.currentTimeMillis() - start) >= timeoutInMs) {
return false;
}
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException e) {
log.error(e.getMessage(),e);
}
}
} finally{
unlock(lockKey,requestId);
}
return false;
3.2 扣减库存
秒杀系统中,库存是个关键数据,卖不出去是个问题,超卖更是个问题。秒杀场景下的一致性问题,主要就是库存扣减的准确性问题。
扣减库存又分为:下单减库存、付款减库存、预扣库存,通常使用预扣库存。
秒杀商品和普通商品的减库存是有差异的,核心区别在数据量级小、交易时间短。
- 如果减库存逻辑非常单一的话,比如没有复杂的 SKU 库存和总库存这种联动关系的话,秒杀减库存可直接放在缓存系统中实现。
- 如果有比较复杂的减库存逻辑,或者需要使用到事务,那就必须在数据库中完成减库存操作
3.2.1 redis中的实现
lua脚本扣减库存: lua脚本跟redis一起配合使用,能保证库存数量查询、判断是否为0、减1操作的原子性
lua脚本有段非常经典的代码,该代码的主要流程如下:
- 先判断商品id是否存在,如果不存在则直接返回。
- 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
- 如果库存大于0,则扣减库存。
- 如果库存等于0,是直接返回,表示库存不足。
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;");
3.2.2 数据库的实现
悲观锁实现: 主要利用select … where … for update 排他锁实现,注意:不要锁表,尽量行锁。 用于写多读少的场景
乐观锁实现:在表中增加一个表示数据版本的字段version, 每次更新将verion+1, 更新时判断数据版本是否发生变更,发生变更则不能更新。
详见 并发编程–分布式锁的四种实现方式深度剖析
4 下单、支付异步处理问题
在真实的秒杀场景中,有三个核心流程:秒杀、下单、支付
真正并发量大的是秒杀,下单和支付实际上并发量很小。
所以在设计秒杀系统时,需要把下单和支付从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能比如支付宝支付,是业务场景本身保证的异步
4.1 消息丢失问题
秒杀成功后,发消息到MQ时可能会失败,原因有很多,比如:网络问题、broker挂了、mq服务端磁盘问题等。这些情况,都可能会造成消息丢失。
解决方案: 增加一张消息表
- 发送消息前,先把这条消息写入消息表,初始状态是待处理,然后再发送mq消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已发送。
- 如果消息写入消息表成功,但发送MQ失败,则需要针对这些记录进行重试N次,超过N次则不再处理此消息。
- 发送消息到mq时,启用ack机制
4.2 消息重复处理问题
本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。
解决方案:
消费者读到消息之后,先判断一下消息表中此消息是否已经处理,如果已经处理表示是重复消费,则直接返回。如果还未处理,则进行下单操作,接着将该消息状态修改为已处理,再返回。
关键点是:下单和写消息处理表,要放在同一个事务中,保证原子操作
4.3 延迟消费问题
通常情况下,用户秒杀成功且下单之后,在30分钟之内还未完成支付的话,该订单会被自动取消,回退库存。
那么在30分钟内未完成支付,订单被自动取消的功能,要如何实现呢?
rocketmq或rabbitmq自带了延迟队列的功能:
下单时会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。
最后
以上就是潇洒汉堡为你收集整理的从要解决的问题思考秒杀系统的设计1 秒杀页面瞬时高并发访问的问题2 商品缓存问题3 解决不超卖问题4 下单、支付异步处理问题的全部内容,希望文章能够帮你解决从要解决的问题思考秒杀系统的设计1 秒杀页面瞬时高并发访问的问题2 商品缓存问题3 解决不超卖问题4 下单、支付异步处理问题所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复