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

2023年 8月 9日 26.5k 0

在公司中单一项目做久了,容易聚焦于眼前和手上的的东西,而淡忘工作多年中所积累知识体系,以至于某个时候去挑战设计一套“秒杀场景”的系统时到了“无所是从”。此后心有不甘,辗转难眠,曾经也是经历过 “手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, _ :=  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;打可广告,具体可以参考作者在简书里的另一篇文章 www.jianshu.com/p/3dd1bda20… ;不过在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

//下载grafana
wget https://dl.grafana.com/oss/release/grafana-6.5.2-1.x86_64.rpm
sudo yum localinstall grafana-6.5.2-1.x86_64.rpm

//启动
systemctl daemon-reload 
systemctl start grafana-server
systemctl status grafana-server

//配置文件:/etc/sysconfig/grafana-server
GRAFANA_USER=grafana
GRAFANA_GROUP=grafana
GRAFANA_HOME=/usr/share/grafana
LOG_DIR=/var/log/grafana
DATA_DIR=/var/lib/grafana
MAX_OPEN_FILES=10000
CONF_DIR=/etc/grafana
CONF_FILE=/etc/grafana/grafana.ini
RESTART_ON_UPGRADE=true
PLUGINS_DIR=/var/lib/grafana/plugins
PROVISIONING_CFG_DIR=/etc/grafana/provisioning
# Only used on systemd systems
PID_FILE_DIR=/var/run/grafana


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

//Add the given value to counter
rpcCounter.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 =(Total50%)/(6060*2)*5



事物量级预估

以订单为例,我们按照一般峰值的20%的流量才会进入真正的购买阶段,所以我们可以预估TPS的请求峰值在是:

峰值TPS = 峰值QPS*20%



压测链路

提到链路压测,也许很多人有疑问,压测不是都是从客户端请求到服务的入口这个链路全程链路压测,为什么还要做非全链路压测。

笔者认为一个服务的水位往往取决于你系统服务最短的那块短板。为了更好的找到目前整个系统的服务的瓶颈,我们更需要对于微服务的PRC接口做单链路压测,在单链路压测的提供数据,我们能更好的对当前瓶颈做优化;譬如如果依赖的下游服务存在瓶颈,我们是否可以同步转异步的方式调用,或者如果数据的强一致性不高,可以采用缓存的方式;



压测工具选型

由于笔者本身仅仅接触了2类压力测试工具:AB和wrk;具体如何使用可以参考作者的另一篇文章:

xie.infoq.cn/article/6b3…

压力测试策略:

寻找最大线程数:
  • 固定连接数,持续时间,调高线程数;:以wrk为例,即固定-c 提高 -t
  • 当线程数增加QPS不再明显变化,且平均响应时间变高时,即为最佳线程数;



寻找QPS峰值:
  • 固定线程数为最大线程数,固定持续时间,调整连接数:即固定-t 提高 -c
  • 当连接数增加QPS不再明显变化,且平均响应时间变高时,这个时候即使再加线程数,也不会有多大的提升效果,最多就是增加了线程的上下文切换,即为QPS峰值;

总结

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

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论