秒杀项目
▐ 技术选型
秒杀用到的基础组件,主要有框架、KV 存储、关系型数据库、MQ。
框架主要有 Web 框架和 RPC 框架。
其中,Web 框架主要用于提供 HTTP 接口给浏览器访问,所以 Web 框架的选型在秒杀服务中非常重要。在这里,我推荐Gin,它的性能和易用性都不错,在 GitHub 上的 Star 达到了 44k。对比性能最好的 fasthttp,虽然 fasthttp 在请求延迟低于 10ms 时性能优势明显,但其底层使用的对象池容易让人踩坑,导致其易用性较差,所以没必要过于追求性能而忽略了稳定性。
至于 RPC 框架,我推荐选用 gRPC,因为它的扩展性和性能都非常不错。在秒杀系统中,Redis 中的数据主要是给秒杀接口服务使用,以便将配置从管理后台同步到 Redis 缓存中。
KV 存储方面,秒杀系统中主要是用 Redis 缓存活动配置,用 etcd 存储集群信息。
关系型数据库中,MySQL 技术成熟且稳定可靠,秒杀系统用它存储活动配置数据很合适。主要 原因还是秒杀活动信息和库存数据都缓存在 Redis 中,活动过程中秒杀服务不操作数据库, 使用 MySQL 完全能够满足需求。
MQ 有很多种,其中 Kafka 在业界认可度最高,技术也非常成熟,性能很不错,非常适合用在秒杀系统中。Kafka 支持自动创建队列,秒杀服务各个节点可以用它自动创建属于自己的队列。
▐ 方案设计
背景
- 秒杀业务简单,每个秒杀活动的商品是事先定义好的,商品有明确的类型和数量,卖完即止;
- 秒杀活动定时上架,消费者可以在活动开始后,通过秒杀入口进行抢购秒杀活动;
- 秒杀活动由于商品物美价廉,开始售卖后,会被快速抢购一空;
现象
- 秒杀活动持续时间短,访问冲击量大,秒杀系统需要应对这种爆发性的访问模型;
- 业务的请求量远远大于售卖量,大部分是陪跑的请求,秒杀系统需要提前规划好处理策略;
- 前端访问量巨大,系统对后端数据的访问量也会短时间爆增,对数据存储资源进行良好设计;
- 活动期间会给整个业务系统带来超大负荷,需要制定各种策略,避免系统过载而宕机;
- 售卖活动商品价格低廉,存在套利空间,各种非法作弊手段层出,需要提前规划预防策略;
秒杀系统设计首先,要尽力将请求拦截在系统上游,层层设阻拦截,过滤掉无效或超量的请求。因为访问量远远大于商品数量,所有的请求打到后端服务的最后一步,其实并没有必要,反而会严重拖慢真正能成交的请求,降低用户体验。秒杀系统专为秒杀活动服务,售卖商品确定,因此可以在设计秒杀商品页面时,将商品信息提前设计为静态信息,将静态的商品信息以及常规的 CSS、JS、宣传图片等静态资源,一起独立存放到 CDN 节点,加速访问,且降低系统访问压力,在访问前端也可以制定种种限制策略,比如活动没开始时,抢购按钮置灰,避免抢先访问,用户抢购一次后,也将按钮置灰,让用户排队等待,避免反复刷新。
其次,要充分利用缓存,提升系统的性能和可用性。用户所有的请求进入秒杀系统前,通过负载均衡策略均匀分发到不同 Web 服务器,避免节点过载。在 Web 服务器中,首先检查用户的访问权限,识别并发刷订单的行为。如果发现售出数量已经达到秒杀数量,则直接返回结束,要将秒杀业务系统和其他业务系统进行功能分拆,尽量将秒杀系统及依赖服务独立分拆部署,避免影响其他核心业务系统。秒杀系统需要构建访问记录缓存,记录访问 IP、用户的访问行为,发现异常访问,提前进行阻断及返回。同时还需要构建用户缓存,并针对历史数据分析,提前缓存僵尸强刷专业户,方便在秒杀期间对其进行策略限制。这些访问记录、用户数据,通过缓存进行存储,可以加速访问,另外,对用户数据还进行缓存预热,避免活动期间大量穿透。
- 如何解决超卖?
mysql乐观锁+redis预减库存+redis缓存卖完标记 :
- 第一是基于数据库乐观锁的方式保证数据并发扣减的强一致性;
- 第二是基于数据库的事务实现批量扣减部分失败时的数据回滚。
在扣减指定数量前应先做一次前置数量校验的读请求(参考读写分离 + 全缓存方案)。
纯数据库乐观锁+事务的方式性能比较差,但是如果不计成本和考虑场景的话也完全够用,因为任何没有机器配置的指标,都是耍流氓。如果我采用 Oracle 的数据库、100 多核的刀锋服务器、SSD 的硬盘,即使是纯数据库的扣减方案,也是可以达到单机上万的 TPS 的。
单线程Redis 的 lua 脚本实现批量扣减。
当用户调用扣减接口时,将扣减的 对应数量 + 脚本标示传递至 Redis 即可,所有的扣减判断逻辑均在 Redis 中的 lua 脚本中执行,lua 脚本执行完成之后返还是否成功给客户端。
Redis 中的 lua 脚本执行时,首先会使用 get 命令查询 uuid 进行查重。当防重通过后,会批量获取对应的剩余库存状态并进行判断,如果一个扣减的数量大于剩余数量,则返回错误并提示数量不足。
Redis 的单线程模型,确保不会出现当所有扣减数量在判断均满足后,在实际扣减时却数量不够。同时,单线程保证判断数量的步骤和后续扣减步骤之间,没有其他任何线程出现并发的执行。
当 Redis 扣减成功后,扣减接口会异步的将此次扣减内容保存至数据库。异步保存数据库的目的是防止出现极端情况—— Redis 宕机后数据未持久化到磁盘,此时我们可以使用数据库恢复或者校准数据。
最后,运营后台直连数据库,是运营和商家修改库存的入口。商家在运营后台进货物进行补充。同时,运营后台的实现需要将此数量同步的增加至 Redis,因为当前方案的所有实际扣减都在 Redis 中。
纯缓存方案虽不会导致超卖,但因缓存不具备事务特性,极端情况下会存在缓存里的数据无法回滚,导致出现少卖的情况。且架构中的异步写库,也可能发生失败,导致多扣的数据丢失。
可以借助顺序写的特性,将扣减任务同步插入任务表,发现异常时,将任务表作为undolog进行回滚。
可以解决由于网络不通、调用缓存扣减超时、在扣减到一半时缓存突然宕机(故障 failover)了。针对上述请求,都有相应的异常抛出,根据异常进行数据库回滚即可,最终任务库里的数据都是准的。
更进一步:由于任务库是无状态的,可以进行水平分库,提升整体性能。
- 如何解决重复下单?
mysql唯一索引+分布式锁
- 如何防刷?
IP限流 | 验证码 | 单用户 | 单设备 | IMEI | 源IP |均设置规则
- 热key问题如何解决?
redis集群+本地缓存+限流+key加随机值分布在多个实例中 :
- 缓存集群可以单节点进行主从复制和垂直扩容;
- 利用应用内的前置缓存,但是需注意需要设置上限;
- 延迟不敏感,定时刷新,实时感知用主动刷新;
- 和缓存穿透一样,限制逃逸流量,单请求进行数据回源并刷新前置;
- 无论如何设计,最后都要写一个兜底逻辑,千万级流量说来就来;
- 应对高并发的读请求
使用缓存策略将请求挡在上层中的缓存中使用CDN,能静态化的数据尽量做到静态化加入限流(比如对短时间之内来自某一个用户,某一个IP、某个设备的重复请求做丢弃处理)。
资源隔离限流会将对应的资源按照指定的类型进行隔离,比如线程池和信号量。
- 计数器限流,例如5秒内技术1000请求,超数后限流,未超数重新计数;
- 滑动窗口限流,解决计数器不够精确的问题,把一个窗口拆分多滚动窗口;
- 令牌桶限流,类似景区售票,售票的速度是固定的,拿到令牌才能去处理请求;
- 漏桶限流,生产者消费者模型,实现了恒定速度处理请求,能够绝对防止突发流量;
流量控制效果从好到差依次是:漏桶限流 > 令牌桶限流 > 滑动窗口限流 > 计数器限流;
其中,只有漏桶算法真正实现了恒定速度处理请求,能够绝对防止突发流量超过下游系统承载能力。
不过,漏桶限流也有个不足,就是需要分配内存资源缓存请求,这会增加内存的使用率。而令牌桶限流算法中的“桶”可以用一个整数表示,资源占用相对较小,这也让它成为最常用的限流算法。正是因为这些特点,漏桶限流和令牌桶限流经常在一些大流量系统中结合使用。
- 应对高并发的写请求
- 削峰:恶意用户拦截对于单用户多次点击、单设备、IMEI、源IP均设置规则;
- 采用比较成熟的漏桶算法、令牌桶算法,也可以使用guava开箱即用的限流算法;可以集群限流,但单机限流更加简洁和稳定;
- 当前层直接过滤一定比例的请求,最大承载值前需要加上兜底逻辑;
- 对于已经无货的产品,本地缓存直接返回;
- 单独部署,减少对系统正常服务的影响,方便扩缩容;
对于一段时间内的秒杀活动,需要保证写成功,我们可以使用 消息队列。
- 削去秒杀场景下的峰值写流量——流量削峰
- 通过异步处理简化秒杀请求中的业务流程——异步处理
- 解耦,实现秒杀系统模块之间松耦合——解耦
削去秒杀场景下的峰值写流量
- 将秒杀请求暂存于消息队列,业务服务器响应用户“秒杀结果正在处理中......”,释放系统资源去处理其它用户的请求。
- 削峰填谷,削平短暂的流量高峰,消息堆积会造成请求延迟处理,但秒杀用户对于短暂延迟有一定容忍度。秒杀商品有 1000 件,处理一次购买请求的时间是 500ms,那么总共就需要 500s 的时间。这时你部署 10 个队列处理程序,那么秒杀请求的处理时间就是 50s,也就是说用户需要等待 50s 才可以看到秒杀的结果,这是可以接受的。这时会并发 10 个请求到达数据库,并不会对数据库造成很大的压力。
通过异步处理简化秒杀请求中的业务流程先处理主要的业务,异步处理次要的业务。
- 如主要流程是生成订单、扣减库存;
- 次要流程比如购买成功之后会给用户发优惠券,增加用户的积****分。
- 此时秒杀只要处理生成订单,扣减库存的耗时,发放优惠券、增加用户积分异步去处理了。
解耦实现秒杀系统模块之间松耦合将秒杀数据同步给数据团队,有两种思路:
- 使用 HTTP 或者 RPC 同步调用,即提供一个接口,实时将数据推送给数据服务。系统的耦合度高,如果其中一个服务有问题,可能会导致另一个服务不可用。
- 使用消息队列将数据全部发送给消息队列,然后数据服务订阅这个消息队列,接收数据进行处理。
- 如何保证数据一致性
CacheAside旁路缓存读请求不命中查询数据库,查询完成写入缓存,写请求更新数据库后删除缓存数据。
// 延迟双删,用以保证最终一致性,防止小概率旧数据读请求在第一次删除后更新数据库public void write(String key,Object data){ redis.delKey(key); db.updateData(data); Thread.sleep(1000); redis.delKey(key);}
为防缓存失效这一信息丢失,可用消息队列确保。
- 更新数据库数据;
- 数据库会将操作信息写入binlog日志当中;
- 另起一段非业务代码,程序订阅提取出所需要的数据以及key;
- 尝试删除缓存操作,若删除失败,将这些信息发送至消息队列;
- 重新从消息队列中获得该数据,重试操作;
订阅binlog程序在mysql中有现成的中间件叫canal,重试机制,主要采用的是消息队列的方式。
终极方案:请求串行化真正靠谱非秒杀的方案:将访问操作串行化
- 先删缓存,将更新数据库的写操作放进有序队列中
- 从缓存查不到的读操作也进入有序队列
需要解决的问题:
- 读请求积压,大量超时,导致数据库的压力:限流、熔断
- 如何避免大量请求积压:将队列水平拆分,提高并行度。
- 可靠性如何保障
由一个或多个sentinel实例组成sentinel集群可以监视一个或多个主服务器和多个从服务器。哨兵模式适合读请求远多于写请求的业务场景,比如在秒杀系统中用来缓存活动信息。如果写请求较多,当集群 Slave 节点数量多了后,Master 节点同步数据的压力会非常大。
当主服务器进入下线状态时,sentinel可以将该主服务器下的某一从服务器升级为主服务器继续提供服务,从而保证redis的高可用性。
- 秒杀系统瓶颈-日志
秒杀服务单节点需要处理的请求 QPS 可能达到 10 万以上。一个请求从进入秒杀服务到处理失败或者成功,至少会产生两条日志。也就是说,高峰期间,一个秒杀节点每秒产生的日志可能达到 30 万条以上。
一块性能比较好的固态硬盘,每秒写的IOPS 大概在 3 万左右。也就是说,一个秒杀节点的每秒日志条数是固态硬盘 IOPS 的 10 倍,磁盘都扛不住,更别说通过网络写入到监控系统中。
- 每秒日志量远高于磁盘 IOPS,直接写磁盘会影响服务性能和稳定性
- 大量日志导致服务频繁分配,频繁释放内存,影响服务性能。
- 服务异常退出丢失大量日志的问题
解决方案
- Tmpfs,即临时文件系统,它是一种基于内存的文件系统。我们可以将秒杀服务写日志的文件放在临时文件系统中。相比直接写磁盘,在临时文件系统中写日志的性能至少能提升 100 倍,每当日志文件达到 20MB 的时候,就将日志文件转移到磁盘上,并将临时文件系统中的日志文件清空;
- 可以参考内存池设计,将给logger分配缓冲区,每一次的新写可以复用Logger对象;
- 参考kafka的缓冲池设计,当缓冲区达到大小和间隔时长临界值时,调用Flush函数,减少丢失的风险;
- 池化技术