写点什么

学以至用 - 从“0”到“1”设计千万级交易系统

用户头像
ninetyhe
关注
发布于: 2021 年 03 月 15 日
学以至用-从“0”到“1”设计千万级交易系统

在公司中单一项目做久了,容易聚焦于眼前和手上的的东西,而淡忘工作多年中所积累知识体系,以至于某个时候去挑战设计一套“秒杀场景”的系统时到了“无所是从”。此后心有不甘,辗转难眠,曾经也是经历过 “手 Q 春晚红包活动(半小时 9000W 请求活动)”、“联盟广告系统(每秒请求 35w+)”、“政府消费券项目(峰值每秒请求 2w+)”。一怒之下,挑战自我从“0”到“1”现场设计一套百万/千万级别的交易系统

分析背景

百万/千万级别的业务请求存在一以下共同点:

  • 交易流量存在突然激增(极端流量),如何做好服务的过载保护

  • 峰值流量衍生问题:用户页面加载 404 或请求失败

  • 请求的用户的数量远远超过库存/活动的奖品数量,如何保证库存数不会超卖

  • 交易中的安全问题,以及服务的安全问题(DDOS 攻击/爬虫)

  • 机器成本,如果成本没有任何预算的话,机器当然可以推起来,可惜现实不允许


业务流程

综合考虑一个完整的交易整个业务交互大致如下图:


于是针对业务对服务的一个划分:

我们大致可以把需要的模块拆分如下:

  • 用户系统(用户相关

  • 商品系统(商品相关)

  • 订单系统(订单相关)

  • 交易系统(交易支付/取消订单相关)

  • 运营后台(产品运营/客服人工介入订单管理)


系统架构设计

兜兜转转,回到核心:我们如果设计一个高可用的系统来扛住这高峰流量的交易系统?我参考了业界前辈的一个模型就是漏斗型,这样做法的好处就是大量的流量尽可能拦在最外层,如下图:


大致流程:

  • 产品交互,譬如友好的弹窗或者文案描述将实时的交互变成非实时的;另外对于个性化推荐的话,可以用户推送统一的缓存数据,不再调用推荐逻辑服务

  • 网关层 4 层于 7 层负载均衡,IP 频次控制,如果碰到黄牛或者爬虫可以使用验证码的方式拦截

  • 客户端缓存数据内容,减少对服务器的请求;峰值时候,可以采取随机概率重试,避免出现服务器负载过高而因为重试导致雪崩

  • 后端使用二级缓存:非常热点数据可以走本地内存,并设置 TTL,普通的热点数据走分布式缓存;数据变更可以通过异步更新来实现数据的最终一致性

接入层


设计思路(峰值流量场景):

  • 页面静态资源 CSS/JS/PNG 等图片信息存储在 CDN 服务器,减少静态资源从后端服务器拉去(通用做法)

  • 负载均衡,这里采用了 DNS 进行了一次域名解析后,指向了 LVS 的节点,这里做了一次正向代理,然后再通过 Nginx 集群做了一次反向代

  • 如果页面存在简单的交互数据,数据可以在后端存在缓存的方式,这里推荐物理内存即本地缓存,因为这类数据不会存在过大的情况。只要控制缓存的数据定时拉去后端即可,这样就控制了数据查询的频次。具体实现可考虑自己实现或者采用开源的 cache 譬如推荐谷歌的:Guava Cache

以下自己实现缓存是伪代码:


//伪代码var picResource = map[string]url{"pn1":"https://xxxx"}
// 伪代码go func { for { //随机10-25分钟更新本地内存的数据,因为随机时间可以避免固定时间更新,导致后端服务流量突发增大,这里用到了错峰的思想 timeAfterTrigger = time.After(time.Second * random(n)) curTime, _ := <-timeAfterTrigger update picResource }}
复制代码
  • 限流控制:由于接入层是首先接受请求的服务,所以这个时候我们需要做的是限流,也就是过载保护的基本手段,至于如果做这里可以使用谷歌的 RateLimit。当然也可以自己实现,这里稍微介绍以下实现的思想令牌桶的限流是模仿了一个虚拟桶,里面定时会丢入令牌,然后每次请求需要消耗一个令牌,如果拿不到令牌就无法请求这东西其实没多难,我们不需要真实的去构造一个桶然后存放令牌,如果这样的话,你的内存反而成了问题啊!其实这个不难,只需要通过计算时间差来计算桶李有多少令牌即可,以下是我自己实现

// 伪代码type token struct {	TokenNum *atomic.Int32 // 桶现在存桶的个数	MaxSize  *atomic.Int32 // 桶的容量  Recode *time.Time      // 上一次消费的时间}// 消费tokenfunc (t *token) ConsumeToken(num int32) int32 {	var realNum int32 // 真实能返回的令牌	// 如果获取的令牌超过可提供的  // 计算时间差,来判断这次桶李有多少令牌   capcity:=now-t.Recode  // t 更新桶里的令牌	if num > t.TokenNum.Load() {		//只返回能提供给的令牌数	} else { // 如果满足需求		// 则桶里消费请求的令牌,并减去当前桶的令牌	}	return realNum}
复制代码

当然上述伪代码是实现的一套单机限流,如果是想实现分布式限流的话,这里可以通过本地限流器的初始 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 来做;具体可以使用如下:

// 安装wget https://github.com/prometheus/prometheus/releases/download/v2.14.0/prometheus-2.14.0.linux-386.tar.gz
tar -xavf prometheus-2.14.0.linux-386.tar.gz
//下载grafanawget https://dl.grafana.com/oss/release/grafana-6.5.2-1.x86_64.rpmsudo yum localinstall grafana-6.5.2-1.x86_64.rpm
//启动systemctl daemon-reload systemctl start grafana-serversystemctl status grafana-server
//配置文件:/etc/sysconfig/grafana-serverGRAFANA_USER=grafanaGRAFANA_GROUP=grafanaGRAFANA_HOME=/usr/share/grafanaLOG_DIR=/var/log/grafanaDATA_DIR=/var/lib/grafanaMAX_OPEN_FILES=10000CONF_DIR=/etc/grafanaCONF_FILE=/etc/grafana/grafana.iniRESTART_ON_UPGRADE=truePLUGINS_DIR=/var/lib/grafana/pluginsPROVISIONING_CFG_DIR=/etc/grafana/provisioning# Only used on systemd systemsPID_FILE_DIR=/var/run/grafana

// 创建一个监控:// 时序类型://Counter:计数器,数据的值持续增加或持续减少。表示的是一个持续变化趋势值,用来记录当前的数量。一般用于记录当前请求数量,错误数// Gauge:计量器(类似仪表盘)。表示当前数据的一个瞬时值,改值可任意增加或减少。一般用来记录内存使用量,磁盘使用量,文件打开数量等// Histogram:柱状图。主要用于在一定范围内对数据进行采样,计算在一定范围内的分布情况,通常它采集的数据展示为直方图。一般用来记录请求时长或响应时长// Summary:摘要。主要用于表示一段时间内数据采样结果。总量,而不是根据统计区间计算出来//Create a new CounterVecrpcCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "rpc_counter", Help: "RPC counts", }, []string{"api"},) //registers the provided collector prometheus.MustRegister(rpcCounter)
//Add the given value to counterrpcCounter.WithLabelValues("api_bookcontent").Add(float64(rand.Int31n(50)))rpcCounter.WithLabelValues("api_chapterlist").Add(float64(rand.Int31n(10)))

//配置文件 - job_name: 'monitor_test' static_configs: - targets: ['localhost:xxx'] labels: group: 'group_test'// 重启./prometheus --config.file=prometheus.yml
复制代码

监控启动后,可以在 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 峰值;


总结

上面就是笔者对整个秒杀系统的大致设计,整体上的核心思想就是系统越往下游走, 并发能力越差, 锁冲突越严重. 关键是将请求尽量拦截在上游。另外就是缓存的多级使用,以及队列的削峰以及异步解耦合。另外在服务过载保护方面只用限流起以及熔断相应不重要的服务。


发布于: 2021 年 03 月 15 日阅读数: 208
用户头像

ninetyhe

关注

Technology Enthusiast 2019.08.20 加入

腾讯后端开发工程师

评论

发布
暂无评论
学以至用-从“0”到“1”设计千万级交易系统