学以至用 - 从“0”到“1”设计千万级交易系统
在公司中单一项目做久了,容易聚焦于眼前和手上的的东西,而淡忘工作多年中所积累知识体系,以至于某个时候去挑战设计一套“秒杀场景”的系统时到了“无所是从”。此后心有不甘,辗转难眠,曾经也是经历过 “手 Q 春晚红包活动(半小时 9000W 请求活动)”、“联盟广告系统(每秒请求 35w+)”、“政府消费券项目(峰值每秒请求 2w+)”。一怒之下,挑战自我从“0”到“1”现场设计一套百万/千万级别的交易系统
分析背景
百万/千万级别的业务请求存在一以下共同点:
交易流量存在突然激增(极端流量),如何做好服务的过载保护
峰值流量衍生问题:用户页面加载 404 或请求失败
请求的用户的数量远远超过库存/活动的奖品数量,如何保证库存数不会超卖
交易中的安全问题,以及服务的安全问题(DDOS 攻击/爬虫)
机器成本,如果成本没有任何预算的话,机器当然可以推起来,可惜现实不允许
业务流程
综合考虑一个完整的交易整个业务交互大致如下图:
于是针对业务对服务的一个划分:
我们大致可以把需要的模块拆分如下:
用户系统(用户相关)
商品系统(商品相关)
订单系统(订单相关)
交易系统(交易支付/取消订单相关)
运营后台(产品运营/客服人工介入订单管理)
系统架构设计
兜兜转转,回到核心:我们如果设计一个高可用的系统来扛住这高峰流量的交易系统?我参考了业界前辈的一个模型就是漏斗型,这样做法的好处就是大量的流量尽可能拦在最外层,如下图:
大致流程:
产品交互,譬如友好的弹窗或者文案描述将实时的交互变成非实时的;另外对于个性化推荐的话,可以用户推送统一的缓存数据,不再调用推荐逻辑服务
网关层 4 层于 7 层负载均衡,IP 频次控制,如果碰到黄牛或者爬虫可以使用验证码的方式拦截
客户端缓存数据内容,减少对服务器的请求;峰值时候,可以采取随机概率重试,避免出现服务器负载过高而因为重试导致雪崩
后端使用二级缓存:非常热点数据可以走本地内存,并设置 TTL,普通的热点数据走分布式缓存;数据变更可以通过异步更新来实现数据的最终一致性
接入层
设计思路(峰值流量场景):
页面静态资源 CSS/JS/PNG 等图片信息存储在 CDN 服务器,减少静态资源从后端服务器拉去(通用做法)
负载均衡,这里采用了 DNS 进行了一次域名解析后,指向了 LVS 的节点,这里做了一次正向代理,然后再通过 Nginx 集群做了一次反向代
如果页面存在简单的交互数据,数据可以在后端存在缓存的方式,这里推荐物理内存即本地缓存,因为这类数据不会存在过大的情况。只要控制缓存的数据定时拉去后端即可,这样就控制了数据查询的频次。具体实现可考虑自己实现或者采用开源的 cache 譬如推荐谷歌的:Guava Cache
以下自己实现缓存是伪代码:
限流控制:由于接入层是首先接受请求的服务,所以这个时候我们需要做的是限流,也就是过载保护的基本手段,至于如果做这里可以使用谷歌的 RateLimit。当然也可以自己实现,这里稍微介绍以下实现的思想令牌桶的限流是模仿了一个虚拟桶,里面定时会丢入令牌,然后每次请求需要消耗一个令牌,如果拿不到令牌就无法请求。这东西其实没多难,我们不需要真实的去构造一个桶然后存放令牌,如果这样的话,你的内存反而成了问题啊!其实这个不难,只需要通过计算时间差来计算桶李有多少令牌即可,以下是我自己实现:
当然上述伪代码是实现的一套单机限流,如果是想实现分布式限流的话,这里可以通过本地限流器的初始 MaxSize 通过 Read 远程的 Redis 节点来获取,而 Redis 里面每次被消费了令牌的话,通过 DECR 命令来实现,另外每次消耗的时候,一样计算上一秒的时间差来计算此刻需要往 Redis 添加多少令牌。
防刷请求,页面如果在峰值的时候仍然有爬虫或者脚本请求的话,这里的解决方案我个人用两种:
针对恶意脚本请求的话,可以做强登陆校验,譬如非登陆态的用户,直接返回一个 CDN 里面静态页面,而页面的个别交互是在前端 JS 写死的默认数据
如果针对爬虫可以对个别 IP 做一个限制:如果预先捕获到 IP 可以直接通过 IPtables 设置黑名单,如果对方的 IP 存在动态变换的话,我目前能想到的就是在 GateWay 层维护一个本地 IP 内存池,考虑到存储 IP 的成本,这里建议使用位的方式存储,譬如:高 N 位是和低 N 位这样存储的方式,可以节省一半的空间
业务层
用户服务-商品
设计思想:
针对秒杀场景,用户信息提前写入缓存,对于非个性化差异的信息,可以直接只用本地内存,而且可以每个机器节点缓存一份(一级缓存)
对于个性话的信息比如:用户个人访问记录,购买记录以及历史订单可以控制缓存最长一个月的数据到 redis
在活动秒杀期间的用户变更信息可以采取异步方式写入数据,对于用户的信息不需要实时更新,写入数据库后,直接删除缓存中的数据,不要主动写缓存,因为秒杀场景,会极少情况再去变更个人形象,而这个时候,如果查询缓存没有话,可以先给请求返回一个空值,并缓存这个空值,防止因为缓存击穿而导致后端数据库崩溃。而这个空值的缓存数据可以设置到 TTL 为后台写入缓存的时间。
对于商品的信息,由于列表页的信息,大量都是倒排检索 ES,这里我们可以空置凌晨用户访问量最少的时间段来刷入 ES 的索引,避免在秒杀时候出现更新 ES 索引而导致上游服务崩溃
订单服务-支付服务-数据读写
设计思想:
用户创建的订单不直接调用订单服务,而是写入队列,订单服务消费队列。这样做的好处就是避免高峰下单人数过多,直接压垮订单服务。另外对于写队列的操作比较简单,中间起到了解耦的作用,不需要关心订单创建的复杂逻辑。
创建订单写入队列,如果失败的话,就进行指数避让,这样的做法就是避免重试频次过大,导致队列服务无法处理的情况下,队列服务仍接受大量的重试请求
订单服务通过消费队列中的创建订单任务,当用户发起购买的动作时候,首先需要查询一下库存,这里库存通过预先将 DB 里面数据同步到 Redis 里面,然后每次购买库存数就 DECR 来减去,如果返回值 return false,则标示库存已空,返回上游失败。如果扣去库存成功,则唤起用户支付逻辑。如果用户限制时间内未支付/或支付是失败,则返回库存。
库存中因为是 Redis 集群,所以主从同步存在一个问题就是如主挂了从又未同步,这样的情况也会出现超发,针对这个问题我个人的解决方案是:在 Redis 中也可以设置参数来强行让从库数据同步后,主库才能继续写入。最终缓存的数据异步更新 DB。
服务治理
上述大致的介绍了一下模块之间的简要的设计,接下来需要对整个服务进行治理和规划;
服务监控
当然业界还有其他监控的方式的譬如:Metrics+Flume+kafka+ES;打可广告,具体可以参考作者在简书里的另一篇文章 https://www.jianshu.com/p/3dd1bda20cac ;不过在 golang 中,大部分是 prometheus 来做;具体可以使用如下:
监控启动后,可以在 grafana 看到:
压力测试
流量存储预估
在压测中,我们需要对服务的最高流量进行预估,方可知道大概需要多少节点资源,这样也尽可能的节约成本,避免盲目的扩容;
请求量级预估
一般我们按照一天流量的峰值一般会 50%的流量集中在 2 小时区间,最高的峰值按 5 倍冗余,所以我们峰值的每秒请求是:
峰值 QPS =(Total*50%)/(60*60*2)*5
事物量级预估
以订单为例,我们按照一般峰值的 20%的流量才会进入真正的购买阶段,所以我们可以预估 TPS 的请求峰值在是:
峰值 TPS = 峰值 QPS*20%
压测链路
提到链路压测,也许很多人有疑问,压测不是都是从客户端请求到服务的入口这个链路全程链路压测,为什么还要做非全链路压测。
笔者认为一个服务的水位往往取决于你系统服务最短的那块短板。为了更好的找到目前整个系统的服务的瓶颈,我们更需要对于微服务的 PRC 接口做单链路压测,在单链路压测的提供数据,我们能更好的对当前瓶颈做优化;譬如如果依赖的下游服务存在瓶颈,我们是否可以同步转异步的方式调用,或者如果数据的强一致性不高,可以采用缓存的方式;
压测工具选型
由于笔者本身仅仅接触了 2 类压力测试工具:AB 和 wrk;具体如何使用可以参考作者的另一篇文章:
https://xie.infoq.cn/article/6b363d5b512306354b46213f5
压力测试策略:
寻找最大线程数:
固定连接数,持续时间,调高线程数;:以 wrk 为例,即固定-c 提高 -t
当线程数增加 QPS 不再明显变化,且平均响应时间变高时,即为最佳线程数;
寻找 QPS 峰值:
固定线程数为最大线程数,固定持续时间,调整连接数:即固定-t 提高 -c
当连接数增加 QPS 不再明显变化,且平均响应时间变高时,这个时候即使再加线程数,也不会有多大的提升效果,最多就是增加了线程的上下文切换,即为 QPS 峰值;
总结
上面就是笔者对整个秒杀系统的大致设计,整体上的核心思想就是系统越往下游走, 并发能力越差, 锁冲突越严重. 关键是将请求尽量拦截在上游。另外就是缓存的多级使用,以及队列的削峰以及异步解耦合。另外在服务过载保护方面只用限流起以及熔断相应不重要的服务。
版权声明: 本文为 InfoQ 作者【ninetyhe】的原创文章。
原文链接:【http://xie.infoq.cn/article/016a09281004be0de51f6bcdc】。文章转载请联系作者。
评论