读书笔记-超大流量分布式系统架构解决方案
- 秒杀的主要问题是热点数据的大并发读/写操作
尽管我们可以通过分布式缓存来提升系统的QPS,但是缓存系统的单点容量还是存在上限的,一旦超过临界水位,分布式缓存容易被瞬间击穿。
而热点数据的大并发写操作,势必会下潜至数据库,那么这就会引起大量的线程相互竞争InnoDB的行锁,并发越大时,等待的线程就越多,
这会严重影响数据库的TPS,导致RT线性上升,最终导致系统发生雪崩
大系统小做——大规模服务化架构
互联网场景下应对大流量、高并发,以及海量数据,服务化改造似乎是必经之路
- 通常来说,网站由小变大的过程,几乎都需要经历单机架构、集群架构、分布式架构、分布式多活数据中心架构
伴随着业务系统架构一同演变的还有各种外围系统和存储系统,比如关系数据库的分库分表改造、从本地缓存过渡到分布式缓存等。 - 刚开始起步的业务,适合使用单体架构
如果业务逐渐增长,则可以对架构进行一些调整
● 独立部署,避免不同的系统相互之间争夺共享资源(比如CPU、内存、磁盘等);
● WebServer集群,提高容错性;
● 部署分布式缓存系统,使查询操作尽可能在缓存命中;
● 数据库实施读/写分离改造,实现HA(High Availability,高可用性)架构。 - 流量上去后,在这个阶段主要需要解决的问题就是提升业务系统的并行处理能力,降低单机系统负载,以便支撑更多用户访问
对于无状态的WebServer节点来说,通常我们会使用Nginx来实现负载均衡调度,但是在线上环境中,Nginx也应该具备高可用性,
这可以依靠DNS轮询来实现,或者如果你所在企业使用的是云主机,则可以使用云服务商提供的SLB服务
查询操作尽量命中缓存,降低数据库压力,但是写入的压力还是有的,因此可以考虑进行读写分离 - 如果流量继续上涨:
● 利用CDN加速系统响应: 静态资源放在CDN上
● 业务垂直化,降低耦合,从而实现分而治之的管理: 降低业务耦合,实现高内聚低耦合,提升系统容错性,避免牵一发而动全身的风险
业务垂直化改造可以防止一些非核心模块的问题导致核心模块出现问题 - 进行服务化改造的前提条件: 用户规模逐渐扩大,多元化的业务出现,技术人员扩充导致多人维护一个模块时,就可以进行服务化的改造,否则都不太适合
- 微服务架构,从宏观上来看,无非就是细化了服务拆分过程中的粒度,粒度越细,业务耦合越小,容错性就越好,并且后期扩展也会越容易。
- 集群和分布式的概念: 集群是指将多台服务器集中在一起,目的是实现同一业务; 分布式是指将不同的业务分布在不同的地方,目的是实现不同的业务
分布式架构中的每一个子节点都允许构成一个集群,但集群却并不一定就是分布式的 - 假设你厨艺高超,声名远播,周末盛情邀约了几个小伙伴来你家聚餐,你一个人负责买菜、切菜、炒菜、上菜,这便是单机架构;而某一天更多朋友来你家做客时,你发现似乎有些力不从心,这时你
需要几个人一起来协作帮忙,以便提升效率,这就是集群架构;假设你家大业大,有上百位朋友都相约你家吃饭时,你会需要更多的人来协作帮忙,并且相互之间需要明确职责分工,A组负责买菜,B
组负责洗菜,C组负责炒菜,D组负责上菜,这就是分布式+集群架构 - API网关服务
所有的前端请求都需要通过它来访问后端服务,并由它统一负责处理一些公共逻辑,比如:鉴权、流控、日志记录、安全防护、负载均衡、灰度发布等
Zuul组件则用于支持开发人员快速构建一个健壮的、高性能的,且具备良好伸缩性的API网关服务
如果业务不多,就不需要引入网关,如果业务繁多
在没有引入网关层的情况下,那些耦合在WebServer中的公共逻辑的维护成本就会变得非常高,甚至一个小功能的升级,都将耗费大量的时间和人力成本,
但如果引入了API网关,事情就会变得简单起来,因为网关逻辑和业务逻辑是完全独立的,架构团队只需要统一对网关层进行升级即可 - 灰度发布: 盗墓时先放金丝雀去探测是否有有毒气体
通过一系列的规则和策略,先将一小部分的用户作为“金丝雀”,让其请求路由到新版本应用上进行观察,待运行正常后,再逐步导流更多的用户到灰度环境中
当所有的用户都顺利切换到新版本应用上后,再停机之前的老版本应用,即可完成灰度发布
就算灰度期新版本应用存在问题,我们也能够迅速将流量切换回老版本上
从产品的角度来看,灰度发布的好处是,能够通过收集用户的使用反馈来更好地完善和改进当前产品。
然而从研发的角度来看,从预发布环境到灰度环境,都是在不停地试错,以确保最终上线的稳定,哪怕灰度期出现问题,也能够做到快速止损,缩小问题的影响范围,从而保证绝大多数用户可用。 - 嵌入在Nginx中的Lua脚本会首先从Header中获取出Token并解析出用户的UserId,再请求Redis验证当前的UserId是否包含在白名单中,只有那些包含在白名单中的用户,才允许访问灰度环境中的新版本应用
- 分布式多活数据中心
- 多活主要问题:
● 多数据中心之间需要打通内网专线通道;
● RPC调用需要做到就近调用;
● 数据同步问题
削峰填谷——流控方案
大型电商网站的制胜法宝无非就是通过扩容、静态化、限流、缓存,以及队列5种常规手段来保护系统的稳定运行
- 只要我们能够采用合理且有效的方式管制住峰值流量,使其井然有序地对系统进行访问,那么无论任何情况下,系统都能够稳定运行
- 数据库连接池配置的连接数相当于实现了限流的功能
常见限流算法:
1.令牌桶算法
2.漏桶算法
3.计数器算法
接入层限流方案: 开启Nginx的限流功能
- 基于容错性考虑,每一个Nginx节点都会对应着一个独立的Redis节点,由Redis来负责存储与限流相关的配置信息,
比如:限流名单、错误码、提示信息、限流阈值、限流开关,以及单位时间等。
当请求来临时,Nginx会向Redis发起Evalsha命令执行Lua限流脚本验证目标URL在单位时间内的请求次数是否已达限流阈值,如果未达阈值,则将请求转发给下游系统,反之限流。 - 限流Lua脚本
- 首先对限流开关进行了验证,只有在开关打开的情况下才会执行限流逻辑。然后验证当前URL是否包含在限流名单中,如果不在则直接放行,反之将目标URL作为key键,通过调用Redis的INCRBY命令递增单位时间内的访问次数;当计数值为1时,则表示首次访问,需要通过命令EXPIRE对其设置过期时间(即单位时间),最后判断计数值是否大于限流阈值来决定请求走向。如果单位时间内已达限流阈值,直至key过期后才会自动重置计数值
应用层限流——限时抢购限流方案
- 限制好目标SKU在单位时间内允许的抢购次数,一旦超出所设定的阈值,系统便会拒绝后续用户的抢购请求(如果希望拥有较好的用户体验,客户端可以配合实施抢购失败时的排队等待页面效果)。比如,目标SKU的限流阈值被设定为1000/s,当超出阈值后,后续请求只能是拒绝或排队,直至到了时间临界点对计数器进行重置后,之前那些被拒用户才可以继续参与抢购
- 两个基于消息队列的典型案例: 1.监控数据采集上报削峰 2.分库分表场景下订单冗余表的数据最终一致性。
简单来说,拦截器中的逻辑非常简单,当一次请求结束后,仅仅只需负责将执行耗时的计算结果异步写入消息队列即可,对业务系统的性能影响几乎可以忽略不计,
然后由Flink负责拉取消息进行流式计算,得出相关接口在单位时间内的成功/失败请求次数、平均耗时等数据,井然有序地落盘到存储系统中,
最后再由监控系统从存储系统中获取结果集进行可视化展示,以便开发人员能够实时获悉异常接口,做好限流保护
大促抢购核心技术难题——读/写优化方案
- 生产环境中一般使用 GuavaCache+Redis 组合实现的多级缓存架构
- 本地缓存的痛点: 占用应用系统的内存资源;数据一致性问题。
- 分布式缓存虽然是以牺牲一定的性能(存在网络I/O开销)为代价,但却可以换取无限延伸的存储容量,以及数据一致性,
因此在大部分分布式应用场景下,笔者都优先推荐使用分布式缓存作为应用层的缓存方案,只有当分布式缓存存在单点问题时,笔者才建议结合本地缓存组合使用。 - MapDB: 类似于Ehcache, 是一个轻量级的本地缓存的框架,它既可以使用对外存储,也可以使用磁盘存储(重启时数据不丢失)。它还提供事务的功能。
- 基于RedisCluster模式实现Sharding
- 分布式场景下三种最常见的路由算法:
Hash算法;
Consistent Hash算法;
分槽算法; - hash算法:
缺点: 假设从原先的32个库水平扩容到64个库后,路由目标肯定也会随之发生变化,也就是说,节点产生的任何变动,几乎都需要对所有的历史数据进行全量迁移,否则将无法命中。 - Consistent Hash算法:
首先需要抽象出一个0~232的圆,然后hash(节点)将其映射到圆的各个位置上,接着再hash(routekey)采用顺时针方式查找圆上最近的节点即可
(如果超过232仍然找不到节点,则映射到圆的第一个节点上)
假设后续我们需要对节点进行相应的扩容调整,那么只有在圆上增加节点的地点逆时针方向的第一个节点上的相关数据会受到影响,这和Hash算法相比是存在本质区别的,至少能够确保大部分历史数据还是可以正常命中的,无须全量迁移,影响面相对较小
缺点: 在节点数太少时,容易因为节点分布不均匀,导致数据产生倾斜
由于节点分布不均,将会导致大量的数据都在node1节点上命中,如果在并发较高的情况下,node1节点极有可能会因为负载较大而产生宕机。因此为了避免数据倾斜,我们可以引入虚拟节点机制,即对同一个节点计算多次(个)Hash,使其映射到圆的各个位置上,具体做法可以通过在节点IP地址或主机名的后面增加编号来实现(比如:192.168.1.1-01,192.168.1.1-02,192.168.1.1-03,192.168.1.1-0n),这样一来,即可使数据均匀分布到各个节点上。 - 分槽算法
分槽算法则是介于Hash算法与Consistent Hash算法之间,算是取得了很好的折中
分槽算法是以Slot为维度的,当节点伸缩需要对历史数据进行迁移时,仅仅只需要移动相关的Slot即可,无须关心具体的数据 - 解决分布式的单点瓶颈的方案:
- 多级缓存方案;
2.RedisCluster模式一主多从读/写分离方案;
为了避免本地缓存占用过多的内存资源从而导致程序在运行的过程中抛出java.lang.OutOfMemoryError异常,所以笔者不建议把所有的商品信息都缓存在本地缓存中。
例如,访问热度不高的商品可以直接访问分布式缓存,而本地缓存中存储更多的是访问热度较高的热卖商品
- 本地缓存中理论上只需要缓存以下两类数据
1.商品详情数据
2.商品库存
像商品详情这类变化频率较低的数据,一般在限时抢购活动开始之前就可以全量推送到所有参与限时抢购WebServer节点的本地缓存中,直至活动结束
由于我们不可能保证商品详情数据自始至终都不会发生变化,所以针对这类数据可以设置较长的缓存过期时间。但是像商品库存这类数据却变化得非常频繁,自然也就需要将缓存的过期时间设置得相对短一些,一般几秒后就可以从分布式缓存中获取最新的库存数据。当大家看到这里时是否会产生一个疑问,本地缓存中存储的商品库存与实际商品库存之间可能会因为时差而造成数据的不一致,这样是否会导致超卖?对于读场景而言,其实完全可以接受在一定程度上出现数据脏读,因为这只会导致一些原本已经没有库存的少量下单请求误以为还有库存而已,等到最终扣减库存时再提示用户所购买的商品已经售罄即可(实际上,接入层Nginx中也可以再缓存一份下游商品详情接口的数据,不过其过期时间务必要小于本地缓存所设置的过期时间,尽可能将流量挡在系统上游 - 本地缓存的更新策略
1.被动更新
2.主动更新
GuavaCache为开发人员提供了三种缓存被动更新机制
● expireAfterAccess:缓存项在单位时间内未发生过读/写操作即被回收;
● expireAfterWrite:缓存项在单位时间内未被更新即被回收;
● refreshAfterWrite:缓存项离上一次更新操作多久之后会被更新
- RedisCluster模式下的读/写分离方案,可以防止单点压力过大导致的缓存穿透
Lettuce作为目前Spring Boot 2.x缺省的Redis客户端
Lettuce在Cluster模式下为开发人员提供了一套开箱即用的读/写分离方案,只需通过简单的设置即可
- 同一热卖商品高并发写难题
在关系数据库中(以MySQL为例)扣减库存时避免超卖的方法:
一般是用乐观锁
简单来说,就是在我们的商品表中构建一个version字段,在并发环境下,多个用户拿到的stock和version必然是相同的,因此在第1个用户成功扣减库存后,需要对version进行加1操作;当第2个
用户扣减库存时,由于version不匹配,为了提升库存扣减的成功率,可以适当进行重试,如果库存不足,则说明商品已经售罄,反之继续对version进行加1操作。使用乐观锁方案扣减库存的伪代码
在高并发的情况下,扣减库存最好是从数据库移到redis - 两种避免超卖的解决方案
1.分布式锁 缺点:太重
2.基于乐观锁实现库存扣减
缺点: 并发量越大,watch失败的几率越大,同时扣减库存成功的几率也低,所以在实际开发中,需要增加重试次数提升库存扣减成功率
3.结合Lua脚本实现库存扣减
基于乐观锁方式的库存扣减方案而言,通过嵌入Lua脚本来实现库存扣减在性能上会更加出色
当从Redis中成功扣减目标商品的实时库存后,我们可以将其写入到消息队列中,通过消息队列来实现削峰,确保写入数据库时的流量可控,
那么数据库的负载压力就会始终保持在一个比较均衡的水位,不会因为针对同一行数据的并发写流量过大而导致性能下降。
分库分表方案
数据库升级路径: 单表单库->一主多从读写分离->垂直分库,水平分库
- 垂直分库: 企业根据自身业务的垂直划分,将原本冗余在单库中的数据表拆分到不同的业务库中,实现分而治之的数据管理和读/写操作
- 水平分库与水平分表: 水平分表就是将原本冗余在单库中的单个业务表拆分为n个“逻辑相关”的业务子表(如tab_0000、tab_0001、tab_0002...),
不同的业务子表各自负责存储不同区间的数据,对外形成一个整体,这就是大家常说的Sharding操作
- 全局唯一SequenceID解决方案: 分库分表后不能再使用数据库的自增主键了
Shark中间件内部已经提供了生成SequenceID的API - 数据库主要负责存储当前ID序列的最大值,每次每个ID生成器从数据库中申请ID时,都会通过行锁机制来确保并发环境下数据的一致性
- 分布式事务:常见3种分布式事务
两阶段提交协议2PC
三阶段提交协议3PC
Paxos协议
以两阶段提交协议为例,首先提交操作会涉及多次节点之间的网络通信;其次由于事务时间、资源锁定时间延长,会导致资源等待时间变长。因此在某些场景下,能够不引入分布式事务的还是尽量不要引入,有些无关紧要的数据丢失就丢失了,可以无须理会,但是一些比较重要的数据,如果一定需要保证一致性,也不要刻意去追求强一致性,可以考虑采用基于消息中间件保证数据最终一致性方案,让之前执行失败的操作继续向前执行下去。 - 举个生活中的小例子,我们与家人去餐厅吃饭,如果遇上周末或节假日,在餐厅门口一定会有很多人等待,那么餐厅为了提升自己的接待能力,就会为排队就餐的人发放一张等待叫号的排队码,等有空位的时候,服务员便会通知人们进去用餐。当然,在这段时间内,我们可以自行选择去逛逛商场。手中的这个排队码就可以被理解为消息队列中的一条消息(凭证),只要不丢失,最终我们还是可以顺利进入餐厅用餐的。这就是生活中常见的最终一致性案例
- 数据库的HA方案:三种成熟的方案
● 基于配置中心实现主备切换;
● 基于Keepalived实现主备切换;
● 基于MHA实现主备切换。 - 基于配置中心实现主备切换
数据库实现主备切换通常有两种形式比较常见,一种是当监控告警系统发出告警后,运维人员手动修改数据源信息,另一种则是Master实例发生故障后自动切换到Slave库上。如果采用手动切换主
备的方式实现数据库的HA,那么基于配置中心是一个非常不错的选择,但是这往往需要配置中心客户端的支持才能够实现。值得庆幸的是,Shark目前已经提供了基于ZooKeeper的配置中心客户
端,方便开发人员将数据源信息统一配置在ZooKeeper中。 - 基于Keepalived实现主备切换
在程序运行的过程中,Master机器和Slave机器上的Keepalived程序会相互发送心跳信号,确认对方状态是否存活 - 保障主备切换过程中的数据一致性
为了避免数据库读/写分离后应用层无法从Slave拉到实时数据,通常的做法是在写入Master之前也将同一份数据落到缓存中,以避免高并发情况下,从Slave中获取不到指定数据的情况发生 - mysql半同步复制: 当事务提交到Master后,Master会等待Slave的回应,待Slave回应收到Binlog(二进制日志)后,Master才会响应请求方已经完成了事务。
但是mysql半同步只能在不宕机的情况下保证数据一致性,但master宕机后,slaster编程master过程中一定会出现数据不一致的问题
可以使用MySQL在5.6版本开始提供的GTID(全局事务ID)特性。由于新Master是之前的Slave,而宕机后的Master在重启后可以作为Slave存在,
可以依靠GTID特性来保证主备之间数据的最终一致性