8.软件架构设计:大型网站技术架构与业务架构融合之道 --- 高并发问题

Posted enlyhua

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了8.软件架构设计:大型网站技术架构与业务架构融合之道 --- 高并发问题相关的知识,希望对你有一定的参考价值。

第8章 高并发问题 
8.1 问题分类 
	8.1.1 侧重于“高并发读”的系统 
		1.场景一:搜索引擎
			读写的差异:
				a) 数量级
				b) 响应时间
				c) 频率

		2.场景二:电商的商品搜索
		3.场景三:电商系统的商品描述,图片和价格

	8.1.2 侧重于“高并发写”的系统 
		广告扣费系统:	
			1.用户每点击一次或浏览一次,都会对广告主的账号余额进行一次扣减;
			2.这种扣减要实时,如果慢了,广告主的账户明明没钱了,但广告仍然在线播放,对平台造成损失。

	8.1.3 同时侧重于“高并发读”和“高并发写”的系统 
		1.场景一:电商库存系统和秒杀系统
		2.场景二:支付系统和微信红包
		3.场景三:IM,微博和朋友圈

8.2 高并发读 
	8.2.1 策略1:加缓存 
		本质是空间换时间。

		案例1:本地缓存或者memcached/redis集中式缓存
			缓存的更新:
				a) 主动更新
				b) 被动更新

			对于缓存,考虑几个问题:
				a) 缓存雪崩
					即缓存的高可用问题。如果缓存宕机,是否会导致所有请求全部写入并压垮数据库。

				b) 缓存穿透
					虽然没有宕机,但是某些key发生了大量的查询,并且这些key都不在缓存里,导致短时间内大量请求写入并压垮数据库。

				c) 大量的热key过期
					和问题二一样,也是因为某些key失效,大量的请求在短时间内写入并压垮数据库。

			这些问题和缓存的回源策略有关:
				1.不回源,只查询缓存,缓存没有,直接返回给客户端为空,这种方式肯定是主动更新缓存,并且不设置过期时间,不会有缓存穿透,大量热key过期问题;
				2.回源,缓存没有,要再查询数据库更新缓存,这种需要考虑上述问题。

		案例2:mysql的master/slave

		案例3:cdn静态文件加速(动静分离)

	8.2.2 策略2:并发读 
		案例1:异步RPC
			异步RPC,如异步发出三个请求调用,总耗时 T=Max(T1,T2,T3);

		案例2:Google的"冗余请求"
			假设一个用户的请求需要100台服务器联合处理,每台服务器有1%的概率发生调用延迟(大于1s为延迟),那么对于C端用户来说大于1s的概率为63%。

			这个数字怎么计算出来的?如果用户的请求响应时间小于1s,意味着100台服务器的响应时间都小于1s,这个概念是 100个99%相乘,即99%^100。反过来,只要任意
		一台机器的响应时间大于1s,用户就会延迟,这个概率是 1 - 99%^100 = 63%。

			这意味着:虽然每台服务器的延迟率只有1%,但对于C端用户来说,延迟率却是63%。机器数越多,问题越严重。

			文中给出了解决办法:冗余请求。客户端同时向多台服务器发送请求,哪个返回得快用哪个,其他的丢弃,但这会让整个系统的调用翻倍。

			把这个方法调整一下,就变成了:客户端先给服务器发送一个请求,并等待服务器返回的响应;如果客户端在一定时间内没有收到服务器的响应,则马上给另外一台(多台)
		发送请求;客户端等待第一个响应到达后,终止其他请求的处理。上面的一定时间定义为:95%请求的响应时间。

			Google的测试数据:采用这种办法,可以仅用2%的额外请求的数据将系统 99.9%的请求响应时间从 1800ms 降低到 74ms。

	8.2.3 策略3:重写轻读 
		
		案例1:朋友圈/微博的Feeds流。

		改成重写轻读,不是查询的时候再聚合,而是提前为每个user_id准备一个feeds流,或者叫收件箱。

		每个用户都有一个收件箱。假设某个用户有1000个粉丝,发布1条微博后,只写入自己的发件箱就返回成功。然后后台异步的把这条微博推送到1000个粉丝的收件箱,也就是
	"写扩散"。这样每个用户读取feeds流的时候不再需要实时聚合了,直接读取各自的收件箱即可。这也就是"重写轻读",把计算逻辑从"读"的一端移到了"写"的一端。

		这里的关键问题是 收件箱 如何实现?从理论上说,这是个无限长的列表。

		很显然,这个列表必须在内存里,。假设用redis 的<key,list> 来实现,key 是user_id,list 是 msg_id。但这个list不能无限制的增长,假设一个上限是2000.
	那么用户一直往下翻,当翻到2000意外,分页怎么弄?

		最简单的办法是:限制条数。最多保存2000条,2000以外丢弃。按常识,手机屏幕一屏通常显示4~6条,2000条意味着可以翻500屏,一般用户翻不了这么多。而这实际上就是
	Twitter的做法,Twitter实际限制为800条。

		但用户发布的微博,希望全量的保存。所以还是用mysql来保存,这就涉及到如何分片了。一种按user_id,一种按时间。需要同时按这2个维度分。

		但分完之后,如何快速查看某个user_id从某个offset开始的微博呢?比如一页有100个,现在需要显示第50页,也就是offset=5000开始,如何快速定位到5000所属的库?
	这就需要一个二级索引:另外要有一张表,记录<user_id,月份,count>。也就是每个user_id在每个月发表的微博总数。基于这个索引表才能快速定位到 offset=5000的微博
	发生在哪个月,也就是哪个数据库的分片。

		解决了读的问题,但又带来一个新的问题:假设一个用户的粉丝很多,如有8000w,给每个粉丝的收件箱都复制一份,计算量和延迟都很大。这时候又回到最初的思路了,也就是
	读的时候 实时聚合,或者叫做 "拉"。

		具体怎么做?

		在写的一端,对于粉丝数量少的用户(假定阈值为5000,小于5000的用户),发布一条微博后推给5000个用户。

		对于粉丝多的,只推送给在线用户(系统要维护一个全局的,在线的用户列表)。

		有一点需要注意,实际上用户的粉丝数会波动,这里不一定是个阈值,可以设定一个范围,如[4500, 5500]。

		对于读的一端,一个用户的关注的人当中,有的人是推给他的(粉丝数少于5000),有的人是需要他去拉的,需要把两者结合起来,再按时间排序,然后分页显示,这就是
	"推拉结合"。


		案例2:多表的关联查询:宽表与搜索引擎
			在策略1中提到一个场景:后端需要对业务数据做多表关联,通过加slave解决,但这种方法只适合没有分库的场景。

			如果数据已经分库了,那么需要多从个库查询数据来聚合,无法使用原生的join功能,则只能在程序中分别从两个库读取数据,再做聚合。

			但存在一个问题:如何需要把聚合出来的数据按某个维度排序并分页显示,这个维度是临时计算的维度,而不是数据库本身就有的维度。

			由于无法使用数据库的排序和分页,也无法在内存中通过实时计算实现排序,分页(数据量太大),这如何处理?

			还是采用类似微博的重写轻读的思路:把要关联数据计算好,存在一个地方,读的时候直接去读聚合数据就好,而不是读取的时候再去做join。

			具体实现来说,可以准备另外一张宽表:把要关联的表的数据算好之后保存在宽表里面。依据实际情况,可以定时算,也可能任何一张原始表发生变化后就触发一次宽表数据
		的计算。也可以用es类的搜索引擎来实现:把多张表的join结果做成一个文档,放在搜索引擎里,也可以灵活的实现排序和分页查询功能。

	8.2.4 总结:读写分离(CQRS架构) 
		无论是 加缓存,动静分离,还是重写轻读,其本质都是读写分离。这也是微服务架构里面提到的CQRS(Command Query Responsibility Separation)。

		C端用户 => 写 => 写的数据结构 / 数据库(分库分表)  ===>>> 

			Binlog监听,Kafka或者消息队列 

		<<<=== 读的数据结构/本地缓存或集中缓存/宽表/es/自己实现倒排索引 <= C端用户

		读写分离的经典模型,该模型有几个典型的特征:
			1.分别为读写设计不同的数据结构
				在C端,当同时面临读和写的高并发压力的时候,把系统分成读和写两个视角来设计,各自设计适合高并发读和写的数据结构或者数据模型。

				可以看到,缓存其实是读写分离的一个简化,或者说是一个特例。

			2.写的一端,通常也就是在线业务的db,通过分库分表抵抗写的压力
				读的一端为了抵抗高并发压力,针对业务场景,可能是<k,v>缓存,也可能是提前做好的join宽表,又或者是es,如果es性能不足,则自己建立倒排索引和
			搜索引擎。

			3.读和写的串联
				定时任务定期把业务数据库中的数据转换成适合高并发读的数据结构,或者是写的一端把数据的变更发送到消息中间件,然后读的一端消费消息;或者直接监听
			数据库中的binlog,监听数据库的变化来更新读的一端数据。

			4.读比写有延迟
				因为左边写的数据是实时变化的,右边读的一端肯定是会有延迟的,读和写之间是最终一致性,而不是强一致性,但这并不影响业务的正常运行。


8.3 高并发写 
	8.3.1 策略1:数据分片 
		数据分片也就是对要处理的数据或者请求分成多份并行处理。

		案例1:数据库的分库分表
			为了应对高并发读的压力,可以加缓存,slave;应对高并发写的压力,就需要分库分表了。分表后,还是在一个数据库上,一台机器上,但可以更充分的利用cpu,
		内存等资源。分库后,可以利用更多的机器资源。

		案例2:JDK 的 ConcurrentHashMap 实现
			ConcurrentHashMap 在内部分成了若干个槽(个数是2的整数次方,默认16个),也就是若干个HashMap,这些槽可以并发的读写,槽与槽之间是独立的,不会发生
		数据互斥。

		案例3:Kafka 的 partition
			在kafka中,一个topic表示一个逻辑上的消息队列,具体到物理上,一个topic被分成了多个partition,每个partition对应磁盘中的一个日志文件。partition
		之间也是互相独立的,可以并发的读写,也就提高了一个topic的并发量。

		案例4:ES 的分布式索引
			在搜索引擎里有一个基本的策略是分布式索引。比如有10亿个网页或者商品,如果建在一个倒排索引里面,则索引很大,也不能并发的查询。

			可以把这10亿个网页或者商品分成n份,建成n个小的索引。一个查询请求来了之后,并行的在n个索引上查询,再把查询结果进行合并。

	8.3.2 策略2:任务分片 
		数据分片是对要处理的数据进行分片,任务分片是对处理程序本身进行分片。

		案例1:cpu的指令流水线
			类似于汽车的生产流水线,把一条指令的执行过程分成 "取指","译码","执行","回写"4个阶段。指令一条条的来,每条指令落在这4个阶段的其中一个阶段,4个阶段
		是并行的。

			工序拆的越多,每个阶段的时间T越小,并发度约高。但单个指令的处理时间却变长了,因为从上一个工序到下一个工序,有上下文切换的开销。

		案例2:Map/Reduce
			Google 的 Map/Reduce 是一种数据分片和任务分片相结合的经典案例。

		案例3:Tomcat的 1+N+M 网络模型
			把一个请求的处理分成3个工序:监听,IO,业务逻辑处理。1个监听线程负责监听客户端的socket连接;N个IO线程负责对socket进行读写,N通常约等于cpu核数;
		M个work线程负责对请求进行逻辑处理。

			进一步来讲,work线程还可能被拆分成解码,业务逻辑计算,编码等环节,进一步提高并发度。

	8.3.3 策略3:异步化 
		1.Linux 层面

		2.Java JDK 层面
			有三套API,最早是BIO,现在是Java NIO,AIO。有可能基于epoll,有可能基于 Linux AIO。

		3.接口层面
			当客户端在调用的时候,可以传入一个 callback 或返回一个 future 对象。

			对于redis,mysql,常用同步接口,尤其在Java 中,jdbc 没有异步接口。要想实现对mysql的异步调用,需要自己实现mysql的 C-S 协议。

			要说明的是,接口的异步有两种方式实现:
				1.假异步
					在接口内部做一个线程池,把异步接口调用转化为同步接口调用。

				2.真异步	
					在接口内部通过一个NIO实现真的异步,不需要很多的线程。

		4.业务层面
			客户端通过http,rpc 或者消息中间件把请求给服务器,服务器收到请求后不立即处理,落盘(存到数据库或者消息中间件),然后用后台任务定时处理,让客户端
		通过另外一个http 或者rpc 接口轮询结果,或者服务器通过接口或消息主动通知客户端。

			http 1.1的异步化,或者http/2 的二进制分帧都是"异步"的例子。

		案例1:短信验证码注册或登录
			改成异步调用,应用服务器收到客户端的请求后,放入消息队列,并立即返回。后台任务读取消息,去调用第三方短信平台。


		案例2:电商的订单系统
			在淘宝上购物,买了3个商品,且来自3个商家,虽然只下了一个订单,付了一次款,但在"我的订单"里查看,却发现编程3个订单,3个卖家发货,,对应3个包裹。
		从1个变成3个的过程,是电商系统的一个典型处理环节,叫做"拆单"。而这个环节就是通过异步处理的。

			对客户端来说,首先是创建了一个订单,写入订单系统的数据库,此时未支付。

			然后去支付,支付完成后,服务器会立即返回成功,而不是等1个拆成3个。

			当然,实际比较复杂,用户付完钱,除了拆单,还需要做很多事情:	
				1.风控进行审单(发现这个订单有风险,如果是刷单操作,会进行拦截)
				2.给用户发优惠券
				3.修改用户的属性(支付之前是新用户,付完钱就会变成老用户,新用户能享受的某些优惠没有了)

			总之,凡是不阻碍主流程的业务逻辑,都可以异步化,放到后台做。


		案例3:广告计费系统
			广告主向账户数据库充钱;C端用户每次浏览或者点击,扣除广告主的钱。

			如果每次点击都同步调用账户数据库进行扣钱,数据库肯定支持不住。

			同时,对于用户的点击来说,在扣费之前其实还有一些列的业务逻辑要处理,比如判断是否为机器人在刷单,这种点击要排除在外。

			所以,实际上用户的点击请求或浏览首先以日志的形式落盘。落盘之后,立即返回给客户端数据。后续的所有请求,当然也包括扣费,全部是异步的。队列中的
		每一条请求,都会被一系列的逻辑模块处理,其中包括扣费,这是一个典型的流式计算模型。


		案例4:LSM树(写内存+Write-Ahead日志)
			为了提高磁盘IO的写性能,可以使用 write-ahead 日志,也就是redo log。其实除了数据库的B+树外,LSM树也采用了同样的道理。

			LSM(Log Structured Merged Tree) 用到的一个核心思想就是"异步写"。LSM树支撑的是kv存储,当插入的时候,k是无需的;但是在磁盘上又需要按照
		k的大小顺序的存储,也就是说要在磁盘上实现一个 Sorted HashMap,按照k的大小存储是为了方便检索,但不可能在插入的同时对磁盘上的数据进行排序。

			LSM是怎么解决这个问题的呢?

			首先是,既然磁盘写入速度很慢,就不写从磁盘,而是在内存中维护一个 sorted hashmap,这样写的性能就提高了;但数据都在内存里,如果系统宕机了则
		数据丢失,于是再写一条日志,也就是write-ahead日志。日志有一个关键的优点是 顺序写入,即只会在日志尾部追加,而不会随机写入。

			有了日志的顺序写入,加上一个内存的sorted hashmap,再有一个后台任务定期的内存中的sorted hashmap合并到磁盘文件中。后台任务会执行磁盘数据的
		合并操作。这个思路和数据库的实现原理有异曲同工之妙。

			当然,因为是kv存储,所以使用了LSM树,而没有使用B+树。关系型数据库之所以用B+树,因为关系型数据库除做等值查询外,还要支持两个关键的特性:范围
		查询,还有前缀模糊查询,也是转换了的范围查询;排序和分页。

			写内存 + write-ahead 日志这种思路不仅在数据库和kv存储领域,在上层业务领域中同样可以适用。比如高并发减库存,或账户余额。如果直接在mysql中
		扣,则数据库会扛不住。可以在redis中扣,同时落一条日志(日志可以在一个高可用的消息中间件或者数据库中插入一条条日志,数据库可以分库分表)。当redis
		宕机,把所有的日志重放完毕,再用数据库中的数据初始化redis中的数据。当然,数据库中的数据不能比redis落后太多,否则积累大量日志未处理,宕机恢复时间
		比较长。

		案例5:kafka 的 PipeLine	
			kafka为了高可用,会为每个topic的每个partition准备多个副本。

			对于同步发送来说,客户端每发送一条消息,leader要把这个消息同步到follower1和follower2之后,才会对客户端返回成功。这种想法很直接,但显然效率
		不高。kafka用了一个典型的策略,也就是Pipeline,它也是一种异步化。

			leader并不会主动给两个follower同步数据,而是等follower主动拉取,并且是批量拉取。

			当leader收到客户端的消息msg1并把它存到本地文件后,就去做其他事情了。比如接收下一个消息msg2,此时客户端还处在阻塞状态,等待msg1返回。只有等
		两个follower把消息msg1拖过去后,leader才会返回客户端说msg1接收成功了。

			为什么叫做pipelien呢?因为leader并不是一个个的处理消息,而是一批批的处理。leader和follower1,follower2像是组成一个管道,消息像水一样
		流过管道。pipeline是异步化的一个典型例子,同时它也是策略2所讲的任务分片的典型例子,因为对于leader来说,它把两个任务分离了,一个是接受和存储客户端
		消息任务,一个是同步消息到2个follower任务,这2个任务并行了。


	8.3.4 策略4:批量

		案例1:kafka的百万QPS写入
			说到kafka就是快,比如其客户端的写入可以达到百万qps。为什么快呢?其中一个策略是Partition分片,另一个策略是磁盘的顺序写入(没有随机写入),下面介绍
		另外一个策略 --- "批量"。批量就是把多条合并成一条,一次性写入。

		kafka如何做到的呢?

		kafka的客户端在内存中为每个partition准备了一个队列,称为 RecordAccumulator。Producer 线程一条条的发送消息,这些消息都进入内存队列。然后通过
	Sender 线程从这些队列中批量的提取消息发送给kafka集群。

		如果是同步发送,Producer向队列中放入一条消息后会阻塞,等待Sender线程取走该条消息后发送,Producer才会返回,这时没有批量操作。

		如果是异步发送,Producer把消息放入队列后就返回了,Sender线程会把队列中的消息打包,一次性发送出去,这时就会用到批量操作。

		对于具体的批量策略,kafka提供了几种参数进行设置,可以按 Batch 的大小或等待时间来批量操作。


		案例2:广告计费系统的合并扣费
			在策略3(异步化)里提到广告系统使用率异步化的策略。在异步化的基础上,可以实现合并扣费。

			假设有10个用户,对同一个广告,每个用户点击了1次,也就意味着同一个广告主账号要扣10次钱,每次扣1块。如果改成合并扣款,就是1次扣10块。如,扣款模块
		一次性的从持久化消息队列中取多条消息,对这条消息按广告主的ID进行分组,同一个组内的消息和扣费金额累加合并,然后从数据库里扣。


		案例3:MySQL的小事务合并机制
			把案例2的策略应用到mysql的内核里,就成了mysql的小事务合并机制。

			比如扣库存,对于同一个sku,本来扣10次,每次扣1个,也就是10个事务;在mysql内核里合并成1次扣10个,也就是10个事务变成1个事务。

			同样,在多机房的数据库多活(跨数据中心的数据库复制)场景中,事务合并也是加速数据库复制的一个重要策略。


	8.3.5 策略5:串行化 多进程单线程 异步I/O 
		Java 中,为了提高并发度,经常使用多线程。但多线程有两个问题:锁竞争;线程切换开销比较大,导致线程数无法开的太多。

		然后看nginx,redis,它们都是单线程模型,因为有了异步IO后,可以把请求串行化处理。第一,没了锁竞争;第二,没有了IO阻塞,这样单线程也非常高效。既然要
	利用多核优势,那就开多个实例。

		再复杂一些,开多个进程,每个进程负责一个业务模块,进程之间通过各种IPC机制实现通信,在C++中广泛使用,这种做法综合了 任务分片,异步化,串行化三种思路。


8.4 容量规划 
	如果说高并发的读和高并发的写的策略是一种"定性分析",那么压力策略和容量规划就是"定量分析"。

	应对策略有了,系统模块也设计的差不多了,接下来就面临绕不开的问题:系统要部署多少台机器?具体来说,应用服务器要部署多少台机器?数据库要分多少个库?

	如果采用简单的办法,可以凭借过去的经验决定要多少台机器;如果采用更专业的做法,则需要进行各种压力测试,再结合对业务的容量预估,计算出要多少台机器。


	8.4.1 吞吐量、响应时间与并发数 
		吞吐量:单位时间内处理的请求数。通常说的,qps,tps,其实都是吞吐量的一种衡量方式。
		响应时间:处理每个请求所需的时间。
		并发数:服务器同时并行的处理请求的个数。

		1.三个指标的数学关系
			吞吐量 * 响应时间 = 并发数

		2.并发系统
			对于串行系统,吞吐量与响应时间成反比,这很容易理解:处理一个请求的时间越小,单位时间内能处理的请求数越多。

			对于并行系统,却不符合这规律:往往是qps越大,响应时间越长。

			对于计算机系统来说,请求的处理被分成了多个环节(任务分片),每个环节又是多线程(数据分片)的,请求与请求之间是并行处理的,多个环节之间也是并行处理的,
		在这种情况下,响应时间和吞吐量之间的关系不是一个简单的数学公式可以描述的,是个曲线关系。

		3.指标的测算方法
			现在的监控系统已经很成熟,可以看到每台机器的qps,平均响应时间,最大响应时间,95线,99线等指标。


	8.4.2 压力测试与容量评估 
		1.容量评估的基本思路
			容量评估是一个系统工程,但其思路也很简单:
				机器数 = 预估总流量 / 单机容量

			其中,分母是一个预估的值,分子通过压力测试得到。

			如何预估?一般是通过历史数据得到。在监控系统中很容易得到过去24小时的调用量分布,取其中的峰值再乘以一个余量系数(比如2倍或者3倍),就可以大概估算出
		服务的预估流量。


		2.压力测试的策略
			压力测试涉及的各种策略:
			1.线上压力测试 vs. 测试环境压力测试
				测试环境有个最大的问题是 搭建麻烦。

			2.读接口压力测试 vs. 写接口压力测试
				如果完全是读接口,可以对线上流量进行重放,这没有问题。如果是写接口,则会对线上数据库造成大量测试数据,怎么解决?

				一种是通过摘流量的方式,也就是不重放流量,只是把线上的真实流量划一部分出来集中导入集群中的几台机器中。需要说明的是:这种方法也只能压力测试
			应用服务器,对于redis或者数据库,只能大致估算。

				另外一种是在线上部署一个与真实数据库一样的"影子数据库",对测试数据打标签,测试数据不进入线上数据库,而是进入"影子数据库"。通常会由数据库
			中间件来实现,如果判断是测试数据库,则会进入"影子数据库"。

			3.单机压力测试 vs. 全链路压力测试	
				单机压力测试比较简单,比如一个服务没有调用其他服务,背后就是redis或者数据库,通过压力测试比较容易客观的得到服务的容量。

				但如果服务存在层层调用,整个调用链路像树状一样展开,即使测算出了每个单元服务的容量,也不能代表整个系统的容量,这时候就需要全链路压测。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

以上是关于8.软件架构设计:大型网站技术架构与业务架构融合之道 --- 高并发问题的主要内容,如果未能解决你的问题,请参考以下文章

《软件架构设计:大型网站技术架构与业务架构融合之道》思维导图

3.软件架构设计:大型网站技术架构与业务架构融合之道 --- 语言

14.软件架构设计:大型网站技术架构与业务架构融合之道 --- 业务架构思维

13.软件架构设计:大型网站技术架构与业务架构融合之道 --- 业务意识

7.软件架构设计:大型网站技术架构与业务架构融合之道 --- 框架软件与中间件

2.软件架构设计:大型网站技术架构与业务架构融合之道 --- 架构的道与术