“N 高 N 可”,高性能、高并发、高可用、高可靠、可扩展、可掩护、可用性等是后台开拓耳熟能详的词了,它们中有些词在大部分情形下表达附近意思。本序列文章旨在磋商和总结后台架构设计中常用的技能和方法,并归纳成一套方法论。
序言本文紧张磋商和总结做事架构设计中高性能的技能和方法,如下图的思维导图所示,左边部分紧张倾向于编程运用,右边部分倾向于组件运用,文章将按图中的内容展开。
高性能思维导图
1 无锁化
大多数情形下,多线程处理可以提高并发性能,但如果对共享资源的处理不当,严重的锁竞争也会导致性能的低落。面对这种情形,有些场景采取了无锁化设计,特殊是在底层框架上。无锁化紧张有两种实现,串行无锁和数据构造无锁。
1.1 串行无锁无锁串行最大略的实现办法可能便是单线程模型了,如 redis/Nginx 都采取了这种办法。在网络编程模型中,常规的办法是主线程卖力处理 I/O 事宜,并将读到的数据压入行列步队,事情线程则从行列步队中取出数据进行处理,这种半同步/半异步模型须要对行列步队进行加锁,如下图所示:
单Reactor多线程模型
上图的模式可以改成无锁串行的形式,当 MainReactor accept 一个新连接之后从浩瀚的 SubReactor 选取一个进行注册,通过创建一个 Channel 与 I/O 线程进行绑定,此后该连接的读写都在同一个线程实行,无需进行同步。
主从Reactor职责链模型
1.2 构造无锁利用硬件支持的原子操作可以实现无锁的数据构造,很多措辞都供应 CAS 原子操作(如 go 中的 atomic 包和 C++11 中的 atomic 库),可以用于实现无锁行列步队。我们以一个大略的线程安全单链表的插入操作来看下无锁编程和普通加锁的差异。
template<typename T>struct Node{ Node(const T &value) : data(value) { } T data; Node next = nullptr;};
有锁链表 WithLockList:
template<typename T>class WithLockList{ mutex mtx; Node<T> head;public: void pushFront(const T &value) { auto node = new Node<T>(value); lock_guard<mutex> lock(mtx); //① node->next = head; head = node; }};
无锁链表 LockFreeList:
template<typename T>class LockFreeList{ atomic<Node<T> > head;public: void pushFront(const T &value) { auto node = new Node<T>(value); node->next = head.load(); while(!head.compare_exchange_weak(node->next, node)); //② }};
从代码可以看出,在有锁版本中 ① 进行了加锁。在无锁版本中,② 利用了原子 CAS 操作 compare_exchange_weak,该函数如果存储成功则返回 true,同时为了防止伪失落败(即原始值即是期望值时也不一定存储成功,紧张发生在短缺单条比较交流指令的硬件机器上),常日将 CAS 放在循环中。
下面对有锁和无锁版本进行大略的性能比较,分别实行 1000,000 次 push 操作。测试代码如下:
int main(){ const int SIZE = 1000000; //有锁测试 auto start = chrono::steady_clock::now(); WithLockList<int> wlList; for(int i = 0; i < SIZE; ++i) { wlList.pushFront(i); } auto end = chrono::steady_clock::now(); chrono::duration<double, std::micro> micro = end - start; cout << "with lock list costs micro:" << micro.count() << endl; //无锁测试 start = chrono::steady_clock::now(); LockFreeList<int> lfList; for(int i = 0; i < SIZE; ++i) { lfList.pushFront(i); } end = chrono::steady_clock::now(); micro = end - start; cout << "free lock list costs micro:" << micro.count() << endl; return 0;}
三次输出如下,可以看出无锁版本有锁版本性能高一些。with lock list costs micro:548118 free lock list costs micro:491570 with lock list costs micro:556037 free lock list costs micro:476045 with lock list costs micro:557451 free lock list costs micro:481470
2 零拷贝这里的拷贝指的是数据在内核缓冲区和运用程序缓冲区直接的传输,并非指进程空间中的内存拷贝(当然这方面也可以实现零拷贝,如传引用和 C++中 move 操作)。现在假设我们有个做事,供应用户下载某个文件,当要求到来时,我们把做事器磁盘上的数据发送到网络中,这个流程伪代码如下:
filefd = open(...); //打开文件sockfd = socket(...); //打开socketbuffer = new buffer(...); //创建bufferread(filefd, buffer); //从文件内容读到buffer中write(sockfd, buffer); //将buffer中的内容发送到网络
数据拷贝流程如下图:
普通读写
上图中绿色箭头表示 DMA copy,DMA(Direct Memory Access)即直接存储器存取,是一种快速传送数据的机制,指外部设备不通过 CPU 而直接与系统内存交流数据的接口技能。赤色箭头表示 CPU copy。纵然在有 DMA 技能的情形下还是存在 4 次拷贝,DMA copy 和 CPU copy 各 2 次。
2.1 内存映射内存映射将用户空间的一段内存区域映射到内核空间,用户对这段内存区域的修正可以直接反响到内核空间,同样,内核空间对这段区域的修正也直接反响用户空间,大略来说便是用户空间共享这个内核缓冲区。
利用内存映射来改写后的伪代码如下:
filefd = open(...); //打开文件sockfd = socket(...); //打开socketbuffer = mmap(filefd); //将文件映射到进程空间write(sockfd, buffer); //将buffer中的内容发送到网络
利用内存映射后数据拷贝流如下图所示:
内存映射
从图中可以看出,采取内存映射后数据拷贝减少为 3 次,不再经由运用程序直接将内核缓冲区中的数据拷贝到 Socket 缓冲区中。RocketMQ 为了存储高性能,就利用了内存映射机制,将存储文件分割成多个大小固定的文件,基于内存映射实行顺序写。
2.2 零拷贝零拷贝便是一种避免 CPU 将数据从一块存储拷贝到其余一块存储,从而有效地提高数据传输效率的技能。Linux 内核 2.4 往后,支持带有 DMA 网络拷贝功能的传输,将内核页缓存中的数据直接打包发到网络上,伪代码如下:
filefd = open(...); //打开文件sockfd = socket(...); //打开socketsendfile(sockfd, filefd); //将文件内容发送到网络
利用零拷贝后流程如下图:
零拷贝
零拷贝的步骤为:1)DMA 将数据拷贝到 DMA 引擎的内核缓冲区中;2)将数据的位置和长度的信息的描述符加到套接字缓冲区;3)DMA 引擎直接将数据从内核缓冲区通报到协议引擎;
可以看出,零拷贝并非真正的没有拷贝,还是有 2 次内核缓冲区的 DMA 拷贝,只是肃清了内核缓冲区和用户缓冲区之间的 CPU 拷贝。Linux 中紧张的零拷贝函数有 sendfile、splice、tee 等。下图是来住 IBM 官网上普通传输和零拷贝传输的性能比拟,可以看出零拷贝比普通传输快了 3 倍旁边,Kafka 也利用零拷贝技能。
普通读写和零拷贝性能比拟
3 序列化当将数据写入文件、发送到网络、写入到存储时常日须要序列化(serialization)技能,从其读取时须要进行反序列化(deserialization),又称编码(encode)和解码(decode)。序列化作为传输数据的表示形式,与网络框架和通信协议是解耦的。如网络框架 taf 支持 jce、json 和自定义序列化,HTTP 协议支持 XML、JSON 和流媒体传输等。
序列化的办法很多,作为数据传输和存储的根本,如何选择得当的序列化办法尤其主要。
3.1 分类常日而言,序列化技能可以大致分为以下三种类型:
内置类型:指编程措辞内置支持的类型,如 java 的 java.io.Serializable。这种类型由于与措辞绑定,不具有通用性,而且一样平常性能不佳,一样平常只在局部范围内利用。文本类型:一样平常是标准化的文本格式,如 XML、JSON。这种类型可读性较好,且支持跨平台,具有广泛的运用。紧张缺陷是比较臃肿,网络传输占用带宽大。二进制类型:采取二进制编码,数据组织更加紧凑,支持多措辞和多平台。常见的有 Protocol Buffer/Thrift/MessagePack/FlatBuffer 等。3.2 性能指标衡量序列化/反序列化紧张有三个指标:1)序列化之后的字节大小;2)序列化/反序列化的速率;3)CPU 和内存花费;
下图是一些常见的序列化框架性能比拟:
序列化和反序列化速率比拟
序列化字节占用比拟
可以看出 Protobuf 无论是在序列化速率上还是字节占比上可以说是完爆同行。不过人外有人,天外有天,听说 FlatBuffer 比 Protobuf 更加无敌,下图是来自 Google 的 FlatBuffer 和其他序列化性能比拟,光看图中数据 FB 貌似秒杀 PB 的存在。
FlatBuffer性能比拟
3.3 选型考量在设计和选择序列化技能时,要进行多方面的考量,紧张有以下几个方面:1)性能:CPU 和字节占用大小是序列化的紧张开销。在根本的 RPC 通信、存储系统和高并发业务上该当选择高性能高压缩的二进制序列化。一些内部做事、要求较少 Web 的运用可以采取文本的 JSON,浏览器直接内置支持 JSON。2)易用性:丰富数据构造和赞助工具能提高易用性,减少业务代码的开拓量。现在很多序列化框架都支持 List、Map 等多种构造和可读的打印。3)通用性:当代的做事每每涉及多措辞、多平台,能否支持跨平台跨措辞的互通是序列化选型的基本条件。4)兼容性:当代的做事都是快速迭代和升级,一个好的序列化框架该当有良好的向前兼容性,支持字段的增减和修正等。5)扩展性:序列化框架能否低门槛的支持自定义的格式有时候也是一个比较主要的考虑成分。
4 池子化
池化恐怕是最常用的一种技能了,实在质便是通过创建池子来提高工具复用,减少重复创建、销毁的开销。常用的池化技能有内存池、线程池、连接池、工具池等。
4.1 内存池我们都知道,在 C/C++等分别利用 malloc/free 和 new/delete 进行内存的分配,其底层调用系统调用 sbrk/brk。频繁的调用系统调用分配开释内存不但影响性能还随意马虎造成内存碎片,内存池技能旨在办理这些问题。正是这些缘故原由,C/C++中的内存操作并不是直接调用系统调用,而是已经实现了自己的一套内存管理,malloc 的实现紧张有三大实现。
1)ptmalloc:glibc 的实现。
2)tcmalloc:Google 的实现。
3)jemalloc:Facebook 的实现。
下面是来自网上的三种 malloc 的比较图,tcmalloc 和 jemalloc 性能差不多,ptmalloc 的性能不如两者,我们可以根据须要选用更适宜的 malloc,如 redis 和 mysl 都可以指定利用哪个 malloc。至于三者的实现和差异,可以网上查阅。
内存分配器性能比拟
虽然标准库的实现在操作系统内存管理的根本上再加了一层内存管理,但运用程序常日也会实现自己特定的内存池,如为了引用计数或者专门用于小工具分配。以是看起来内存管理一样平常分为三个层次。
内存管理三个层次
4.2 线程池线程创建是须要分配资源的,这存在一定的开销,如果我们一个任务就创建一个线程去处理,这一定会影响系统的性能。线程池的可以限定线程的创建数量并重复利用,从而提高系统的性能。
线程池可以分类或者分组,不同的任务可以利用不同的线程组,可以进行隔离以免相互影响。对付分类,可以分为核心和非核心,核心线程池一贯存在不会被回收,非核心可能对空闲一段韶光后的线程进行回收,从而节省系统资源,等到须要时在按需创建放入池子中。
4.3 连接池常用的连接池有数据库连接池、redis 连接池、TCP 连接池等等,其紧张目的是通过复用来减少创建和开释连接的开销。连接池实现常日须要考虑以下几个问题:
1)初始化:启动即初始化和惰性初始化。启动初始化可以减少一些加锁操作和须要时可直策应用,缺陷是可能造成做事启动缓慢或者启动后没有任务处理,造成资源摧残浪费蹂躏。惰性初始化是真正有须要的时候再去创建,这种办法可能有助于减少资源占用,但是如果面对突发的任务要求,然后瞬间去创建一堆连接,可能会造成系统相应慢或者相应失落败,常日我们会采取启动即初始化的办法。
2)连接数目:权衡所需的连接数,连接数太少则可能造成任务处理缓慢,太多不但使任务处理慢还会过度花费系统资源。
3)连接取出:当连接池已经无可用连接时,是一贯等待直到有可用连接还是分配一个新的临时连接。
4)连接放入:当连接利用完毕且连接池未满时,将连接放入连接池(包括 3 中创建的临时连接),否则关闭。
5)连接检测:永劫光空闲连接和失落效连接须要关闭并从连接池移除。常用的检测方法有:利用时检测和定期检测。
4.4 工具池严格来说,各种池都是工具池模式的运用,包括前面的这三哥们。工具池跟各种池一样,也是缓存一些工具从而避免大量创建同一个类型的工具,同时限定了实例的个数。如 redis 中 0-9999 整数工具就通过采取工具池进行共享。在游戏开拓中工具池模式常常利用,如进入舆图时怪物和 NPC 的涌现并不是每次都是重新创建,而是从工具池中取出。
5 并发化5.1 要求并发
如果一个任务须要处理多个子任务,可以将没有依赖关系的子任务并发化,这种场景在后台开拓很常见。如一个要求须要查询 3 个数据,分别耗时 T1、T2、T3,如果串行调用总耗时 T=T1+T2+T3。对三个任务实行并发,总耗时 T=max(T1,T 2,T3)。同理,写操作也如此。对付同种要求,还可以同时进行批量合并,减少 RPC 调用次数。
5.2 冗余要求冗余要求指的是同时向后端做事发送多个同样的要求,谁相应快便是利用谁,其他的则丢弃。这种策略缩短了客户真个等待韶光,但也使全体系统调用量猛增,一样平常适用于初始化或者要求少的场景。公司 WNS 的跑马模块实在便是这种机制,跑马模块为了快速建立长连接同时向后台多个 ip/port 发起要求,谁快就用谁,这在弱网的移动设备上特殊有用,如果利用等待超时再重试的机制,无疑将大大增加用户的等待韶光。
6 异步化
对付处理耗时的任务,如果采取同步等待的办法,会严重降落系统的吞吐量,可以通过异步化进行办理。异步在不同层面观点是有一些差异的,在这里我们不谈论异步 I/O。
6.1 调用异步化在进行一个耗时的 RPC 调用或者任务处理时,常用的异步化办法如下:
Callback:异步回调通过注册一个回调函数,然后发起异步任务,当任务实行完毕时会回调用户注册的回调函数,从而减少调用端等待韶光。这种办法会造成代码分散难以掩护,定位问题也相对困难。Future:当用户提交一个任务时会急速先返回一个 Future,然后任务异步实行,后续可以通过 Future 获取实行结果。对 1.4.1 中要求并发,我们可以利用 Future 实现,伪代码如下: //异步并发任务Future<Response> f1 = Executor.submit(query1);Future<Response> f2 = Executor.submit(query2);Future<Response> f3 = Executor.submit(query3);//处理其他事情doSomething();//获取结果Response res1 = f1.getResult();Response res2 = f2.getResult();Response res3 = f3.getResult();
CPS(Continuation-passing style)可以对多个异步编程进行编排,组成更繁芜的异步处理,并以同步的代码调用形式实现异步效果。CPS 将后续的处理逻辑当作参数通报给 Then 并可以终极捕获非常,办理了异步回调代码散乱和非常跟踪难的问题。Java 中的 CompletableFuture 和 C++ PPL 基本支持这一特性。范例的调用形式如下:
void handleRequest(const Request &req){return req.Read().Then([](Buffer &inbuf){return handleData(inbuf);}).Then([](Buffer &outbuf){return handleWrite(outbuf);}).Finally(){return cleanUp();});}
6.2 流程异步化
一个业务流程每每伴随着调用链路长、后置依赖多等特点,这会同时降落系统的可用性和并发处理能力。可以采取对非关键依赖进行异步化办理。如企鹅电竞开播做事,除了开播写节目存储以外,还须要将节目信息同步到神盾推举平台、App 首页和二级页等。由于同步到外部都不是开播的关键逻辑且对同等性哀求不是很高,可以对这些后置的同步操作进行异步化,写完存储即向 App 返回相应,如下图所示:
企鹅电竞开播流程异步化
7 缓存
从单核 CPU 到分布式系统,从前端到后台,缓存无处不在。古有朱元璋“缓称王”而终得天下,今有不论是芯片制造商还是互联网公司都同样采纳了“缓称王”(缓存称王)的政策才能霸占一席之地。缓存是原始数据的一个复制集,实在质便是空间换韶光,紧张是为理解决高并发读。
7.1 缓存的利用场景缓存是空间换韶光的艺术,利用缓存能提高系统的性能。“劲酒虽好,可不要贪杯”,利用缓存的目的是为了提高性价比,而不是一上来就为了所谓的提高性能不计本钱的利用缓存,而是要看场景。
适宜利用缓存的场景,以之前参与过的项目企鹅电竞为例:
1)一旦天生后基本不会变革的数据:如企鹅电竞的游戏列表,在后台创建一个游戏之后基本很少变革,可直接缓存全体游戏列表;
2)读密集型或存在热点的数据:范例的便是各种 App 的首页,如企鹅电竞首页直播列表;
3)打算代价大的数据:如企鹅电竞的 Top 热榜视频,如 7 天榜在每天凌晨根据各种指标打算好之后缓存排序列表;
4)千人一壁的数据:同样是企鹅电竞的 Top 热榜视频,除了缓存的全体排序列表,同时直接在进程内按页缓存了前 N 页数据组装后的终极回包结果;
不适宜利用缓存的场景:
1)写多读少,更新频繁;
2)对数据同等性哀求严格;
7.2 缓存的分类进程级缓存:缓存的数据直接在进程地址空间内,这可能是访问速率最快利用最大略的缓存办法了。紧张缺陷是受制于进程空间大小,能缓存的数据量有限,进程重启缓存数据会丢失。一样平常常日用于缓存数据量不大的场景。集中式缓存:缓存的数据集中在一台机器上,如共享内存。这类缓存容量紧张受制于机器内存大小,而且进程重启后数据不丢失。常用的集中式缓存中间件有单机版 redis、memcache 等。分布式缓存:缓存的数据分布在多台机器上,常日须要采取特定算法(如 Hash)进行数据分片,将海量的缓存数据均匀的分布在每个机器节点上。常用的组件有:Memcache(客户端分片)、Codis(代理分片)、Redis Cluster(集群分片)。多级缓存:指在系统中的不同层级的进行数据缓存,以提高访问效率和减少对后端存储的冲击。以下图的企鹅电竞的一个多级缓存运用,根据我们的现网统计,在第一级缓存的命中率就已经达 94%,穿透到 grocery 的要求量很小。企鹅电竞首页多级缓存
整体事情流程如下:
1)要求到达首页或者直播间做事后,如果在本地缓存命中则直接返回,否则从下一级缓存核心存储进行查询并更新本地缓存;2)前端做事缓存没有命中穿透到核心存储做事,如果命中则直接返回给前端做事,没有则要求存储层 grocery 并更新缓存;3)前两级 Cache 都没有命中回源到存储层 grocery。7.3 缓存的模式关于缓存的利用,已经有人总结出了一些模式,紧张分为 Cache-Aside 和 Cache-As-SoR 两类。个中 SoR(system-of-record):表示记录系统,即数据源,而 Cache 正是 SoR 的复制集。
Cache-Aside:旁路缓存,这该当是最常见的缓存模式了。对付读,首先从缓存读取数据,如果没有命中则回源 SoR 读取并更新缓存。对付写操作,先写 SoR,再写缓存。这种模式架构图如下:
Cache-Aside构造图
逻辑代码:
//读操作data = Cache.get(key);if(data == NULL){ data = SoR.load(key); Cache.set(key, data);}//写操作if(SoR.save(key, data)){ Cache.set(key, data);}
这种模式用起来大略,但对运用层不透明,须要业务代码完成读写逻辑。同时对付写来说,写数据源和写缓存不是一个原子操作,可能涌现以下情形导致两者数据不一致:
1)在并发写时,可能涌现数据不一致。如下图所示,user1 和 user2 险些同时进行读写。在 t1 时候 user1 写 db,t2 时候 user2 写 db,紧接着在 t3 时候 user2 写缓存,t4 时候 user1 写缓存。这种情形导致 db 是 user2 的数据,缓存是 user1 的数据,两者不一致。
Cache-Aside并发读写
2)先写数据源成功,但是接着写缓存失落败,两者数据不一致。对付这两种情形如果业务不能忍受,可大略的通过先 delete 缓存然后再写 db 办理,其代价便是下一次读要求的 cache miss。
Cache-As-SoR:缓存即数据源,该模式把 Cache 当作 SoR,以是读写操作都是针对 Cache,然后 Cache 再将读写操作委托给 SoR,即 Cache 是一个代理。如下图所示:
Cache-As-SoR构造图
Cache-As-SoR 有三种实现:
1)Read-Through:发生读操作时,首先查询 Cache,如果不命中则再由 Cache 回源到 SoR 即存储端实现 Cache-Aside 而不是业务)。
2)Write-Through:称为穿透写模式,由业务先调用写操作,然后由 Cache 卖力写缓存和 SoR。
3)Write-Behind:称为回写模式,发生写操作时业务只更新缓存并立即返回,然后异步写 SoR,这样可以利用合并写/批量写提高性能。
7.4 缓存的回收策略在空间有限、低频热点访问或者无主动更新关照的情形下,须要对缓存数据进行回收,常用的回收策略有以下几种:
1)基于韶光:基于韶光的策略紧张可以分两种:
基于 TTL(Time To Live):即存活期,从缓存数据创建开始到指定的过期韶光段,不管有没有访问缓存都会过期。如 redis 的 EXPIRE。基于 TTI(Time To Idle):即空闲期,缓存在指定的韶光没有被访问将会被回收。2)基于空间:缓存设置了存储空间上限,当达到上限时按照一定的策略移除数据。
3)基于容量:缓存设置了存储条款上限,当达到上限时按照一定的策略移除数据。
4)基于引用:基于引用计数或者强弱引用的一些策略进行回收。
缓存的常见回收算法如下:
FIFO(First In First Out):前辈选出原则,前辈入缓存的数据先被移除。LRU(Least Recently Used):最基于局部性事理,即如果数据最近被利用,那么它在未来也极有可能被利用,反之,如果数据良久未利用,那么未来被利用的概率也较。LFU:(Least Frequently Used):最近最少被利用的数据最先被淘汰,即统计每个工具的利用次数,当须要淘汰时,选择被利用次数最少的淘汰。7.5 缓存的崩溃与修复由于在设计不敷、要求攻击(并不一定是恶意攻击)等会造成一些缓请安题,下面列出了常见的缓请安题和解决方案。
缓存穿透:大量利用不存在的 key 进行查询时,缓存没有命中,这些要求都穿透到后真个存储,终极导致后端存储压力过大乃至被压垮。这种情形缘故原由一样平常是存储中数据不存在,紧张有两个办理办法。
1)设置空置或默认值:如果存储中没有数据,则设置一个空置或者默认值缓存起来,这样下次要求时就不会穿透到后端存储。但这种情形如果碰着恶意攻击,不断的假造不同的 key 来查询时并不能很好的应对,这时候须要引入一些安全策略对要求进行过滤。2)布隆过滤器:采取布隆过滤器将,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层数据库的查询压力。缓存雪崩:指大量的缓存在某一段韶光内集体失落效,导致后端存储负载瞬间升高乃至被压垮。常日因此下缘故原由造成:
1)缓存失落效韶光集中在某段韶光,对付这种情形可以采纳对不同的 key 利用不同的过期韶光,在原来根本失落效韶光的根本上再加上不同的随机韶光;2)采取取模机制的某缓存实例宕机,这种情形移除故障实例后会导致大量的缓存不命中。有两种办理方案:① 采纳主从备份,主节点故障时直接将从实例更换主;② 利用同等性哈希替代取模,这样纵然有实例崩溃也只是少部分缓存不命中。缓存热点:虽然缓存系统本身性能很高,但也架不住某些热点数据的高并发访问从而造成缓存做事本身过载。假设一下微博以用户 id 作为哈希 key,溘然有一天志玲姐姐宣告结婚了,如果她的微博内容按照用户 id 缓存在某个节点上,当她的万千粉丝查看她的微博时一定会压垮这个缓存节点,由于这个 key 太热了。这种情形可以通过天生多份缓存到不同节点上,每份缓存的内容一样,减轻单个节点访问的压力。
7.6 缓存的一些好实践1)动静分离:对付一个缓存工具,可能分为很多种属性,这些属性中有的是静态的,有的是动态的。在缓存的时候最好采取动静分离的办法。如企鹅电竞的视频详情分为标题、时长、清晰度、封面 URL、点赞数、评论数等,个中标题、时长等属于静态属性,基本不会改变,而点赞数、评论数常常改变,在缓存时这两部分开,以免由于动态属性每次的变更要把全体视频缓存拉出来进行更新一遍,本钱很高。
2)慎用大工具:如果缓存工具过大,每次读写开销非常大并且可能会卡住其他要求,特殊是在 redis 这种单线程的架构中。范例的情形是将一堆列表挂在某个 value 的字段上或者存储一个没有边界的列表,这种情形下须要重新设计数据构造或者分割 value 再由客户端聚合。
3)过期设置:只管即便设置过期韶光减少脏数据和存储占用,但要把稳过期韶光不能集中在某个韶光段。
4)超时设置:缓存作为加速数据访问的手段,常日须要设置超时时间而且超时时间不能过长(如 100ms 旁边),否则会导致全体要求超时连回源访问的机会都没有。
5)缓存隔离:首先,不同的业务利用不同的 key,防止涌现冲突或者相互覆盖。其次,核心和非核心业务进行通过不同的缓存实例进行物理上的隔离。
6)失落败降级:利用缓存须要有一定的降级预案,缓存常日不是关键逻辑,特殊是对付核心做事,如果缓存部分失落效或者失落败,该当连续回源处理,不应该直接中断返回。
7)容量掌握:利用缓存要进行容量掌握,特殊是本地缓存,缓存数量太多内存紧张时会频繁的 swap 存储空间或 GC 操作,从而降落相应速率。
8)业务导向:以业务为导向,不要为了缓存而缓存。对性能哀求不高或要求量不大,分布式缓存乃至数据库都足以应对时,就不须要增加本地缓存,否则可能由于引入数据节点复制和幂等处理逻辑反而得不偿失落。
9)监控告警:跟妹纸永久是对的一样,总不会错。对大工具、慢查询、内存占用等进行监控。
8 分片
分片即将一个较大的部分分成多个较小的部分,在这里我们分为数据分片和任务分片。对付数据分片,在本文将不同系统的拆分技能术语(如 region、shard、vnode、partition)等统称为分片。分片可以说是一箭三雕的技能,将一个大数据集分散在更多节点上,单点的读写负载随之也分散到了多个节点上,同时还提高了扩展性和可用性。
数据分片,小到编程措辞标准库里的凑集,大到分布式中间件,无所不在。如我曾经写过一个线程安全的容器以放置各种工具时,为了减少锁争用,对容器进行了分段,每个分段一个锁,按照哈希或者取模将工具放置到某个分段中,如 Java 中的 ConcurrentHashMap 也采纳了分段的机制。分布式中间件 Kafka 中对 topic 也分成了多个 partition,每个 partition 相互独立可以比并发读写。
8.1 分片策略进行分片时,要只管即便均匀的将数据分布在所有节点上以平摊负载。如果分布不均,会导致倾斜使得全体系统性能的低落。常见的分片策略如下:
区间分片基于一段连续关键字的分片,保持了排序,适宜进行范围查找,减少了垮分片读写。区间分片的缺陷是随意马虎造成数据分布不屈均,导致热点。如直播平台,如果按 ID 进行区间分片,常日短位 ID 都是一些大主播,如在 100-1000 内 ID 的访问肯定比十位以上 ID 频繁。常见的还有按韶光范围分片,则最近韶光段的读写操作常日比良久之前的韶光段频繁。区间分片
随机分片按照一定的办法(如哈希取模)进行分片,这种办法数据分布比较均匀,不随意马虎涌现热点和并发瓶颈。缺陷便是失落去了有序相邻的特性,如进行范围查询时会向多个节点发起要求。随机分片
组合分片:对区间分片和随机分片的一种折中,采纳了两种办法的组合。通过多个键组成复合键,个中第一个键用于做哈希随机,别的键用于进行区间排序。如直播平台以主播 id+开播韶光(anchor_id,live_time)作为组合键,那么可以高效的查询某主播在某个韶光段内的开播记录。社交场景,如微信朋友圈、QQ 说说、微博等以用户 id+发布韶光(user_id,pub_time)的组合找到用户某段韶光的揭橥记录。8.2 二级索引二级索引常日用来加速特定值的查找,不能唯一标识一条记录,利用二级索引须要二次查找。关系型数据库和一些 K-V 数据库都支持二级索引,如 mysql 中的赞助索引(非聚簇索引),ES 倒排索引通过 term 找到文档。
本地索引索引存储在与关键字相同的分区中,即索引和记录在同一个分区,这样对付写操作时都在一个分区里进行,不须要跨分区操作。但是对付读操作,须要聚合其他分区上的数据。如以王者光彩短视频为例,以视频 vid 作为关键索引,视频标签(如五杀、三杀、李白、阿珂)作为二级索引,本地索引如下图所示:本地索引
全局索引按索引值本身进行分区,与关键字以是独立。这样对付读取某个索引的数据时,都在一个分区里进行,而对付写操作,须要跨多个分区。仍以上面的例子为例,全局索引如下图所示:全局索引
8.3 路由策略路由策略决定如何将数据要求发送到指定的节点,包括分片调度后的路由。常日有三种办法:客户端路由、代理路由和集群路由。
客户端路由客户端直接操作分片逻辑,感知分片和节点的分配关系并直接连接到目标节点。Memcache 便是采取这种办法实现的分布式,如下图所示。Memcache客户端路由
代理层路由客户真个要求到发送到代理层,由其将要求转发到对应的数据节点上。很多分布式系统都采纳了这种办法,如业界的基于 redis 实现的分布式存储 codis(codis-proxy 层),公司内如 CMEM(Access 接入层)、DCache(Proxy+Router)等。如下图所示 CMEM 架构图,赤色方框内的 Access 层便是路由代理层。CMEM接入层路由
集群路由由集群实现分片路由,客户端连接任意节点,如果该节点存在要求的分片,则处理;否则将要求转发到得当的节点或者见告客户端重定向到目标节点。如 redis cluster 和公司的 CKV+采取了这种办法,下图的 CKV+集群路由转发。CKV+集群路由
以上三种路由办法都各优缺陷,客户端路由实现相对大略但对业务入侵较强。代理层路由对业务透明,但增加了一层网络传输,对性能有一定影响,同时在支配掩护上也相对繁芜。集群路由对业务透明,且比代理路由少了一层构造,节约本钱,但实现更繁芜,且不合理的策略会增加多次网络传输。
8.4 动态平衡在学习平衡二叉树和红黑树的时候我们都知道,由于数据的插入删除会毁坏其平衡性。为了保持树的平衡,在插入删除后我们会通过左旋右旋动态调度树的高度以保持再平衡。在分布式数据存储也同样须要再平衡,只不过引起不平衡的成分更多了,紧张有以下几个方面:
1)读写负载增加,须要更多 CPU;
2)数据规模增加,须要更多磁盘和内存;
3)数据节点故障,须要其他节点接替;
业界和公司很多产品也都支持动态平衡调度,如 redis cluster 的 resharding,HDFS/kafka 的 rebalance。常见的办法如下:
固定分区创建远超节点数的分区数,为每个节点分配多个分区。如果新增节点,可从现有的节点上均匀移走几个分区从而达到平衡,删除节点反之,如下图所示。范例的便是同等性哈希,创建 2^32-1 个虚拟节点(vnode)分布到物理节点上。该模式比较大略,须要在创建的时候就确定分区数,如果设置太小,数据迅速膨胀的话再平衡的代价就很大。如果分区数设置很大,则会有一定的管理开销。固定分区再平衡
动态分区自动增减分区数,当分区数据增长到一定阀值时,则对分区进行拆分。当分区数据缩小到一定阀值时,对分区进行合并。类似于 B+树的分裂删除操作。很多存储组件都采取了这种办法,如 HBase Region 的拆分合并,TDSQL 的 Set Shard。这种办法的优点是自动适配数据量,扩展性好。利用这种分区须要把稳的一点,如果初始化分区为一个,刚上线要求量就很大的话会造成单点负载高,常日采纳预先初始化多个分区的办法办理,如 HBase 的预分裂。8.5 分库分表当数据库的单表/单机数据量很大时,会造成性能瓶颈,为了分散数据库的压力,提高读写性能,须要采纳分而治之的策略进行分库分表。常日,在以下情形下须要进行分库分表:
1)单表的数据量达到了一定的量级(如 mysql 一样平常为千万级),读写的性能会低落。这时索引也会很大,性能不佳,须要分解单表。
2)数据库吞吐量达到瓶颈,须要增加更多数据库实例来分担数据读写压力。
分库分表按照特定的条件将数据分散到多个数据库和表中,分为垂直切分和水平切分两种模式。
垂直切分:按照一定规则,如业务或模块类型,将一个数据库中的多个表分布到不同的数据库上。以直播平台为例,将直播节目数据、视频点播数据、用户关注数据分别存储在不同的数据库上,如下图所示:垂直切分
优点:
1)切分规则清晰,业务划分明确;
2)可以按照业务的类型、主要程度进行本钱管理,扩展也方便;
3)数据掩护大略;
缺陷:
1)不同表分到了不同的库中,无法利用表连接 Join。不过在实际的业务设计中,也基本不会用到 join 操作,一样平常都会建立映射表通过两次查询或者写时布局好数据存到性能更高的存储系统中。
2)事务处理繁芜,原来在事务中操作同一个库的不同表不再支持。如直播结束时更新直播节目同时天生一个直播的点播回放在分库之后就不能在一个事物中完成,这时可以采取柔性事务或者其他分布式事物方案。
水平切分:按照一定规则,如哈希或取模,将同一个表中的数据拆分到多个数据库上。可以大略理解为按行拆分,拆分后的表构造是一样的。如直播系统的开播记录,日积月累,表会越来越大,可以按照主播 id 或者开播日期进行水平切分,存储到不同的数据库实例中。优点:1)切分后表构造一样,业务代码不须要改动;2)能掌握单表数据量,有利于性能提升;缺陷:1)Join、count、记录合并、排序、分页等问题须要跨节点处理;2)相对繁芜,须要实现路由策略;综上所述,垂直切分和水平切分各有优缺陷,常日情形下这两种模式会一起利用。8.6 任务分片记得小时候发新书,老师抱了一堆堆的新书到教室,然后找几个同学一起分发下去,有的发语文,有的发数学,有的发自然,这便是一种任务分片。车间中的流水线,经由每道工序的并行后终极合成终极的产品,也是一种任务分片。
任务分片将一个任务分成多个子任务并行处理,加速任务的实行,常日涉及到数据分片,如归并排序首先将数据分成多个子序列,先对每个子序列排序,终极合成一个有序序列。在大数据处理中,Map/Reduce 便是数据分片和任务分片的经典结合。
9 存储
任何一个别系,从单核 CPU 到分布式,从前端到后台,要实现各式各样的功能和逻辑,只有读和写两种操作。而每个别系的业务特性可能都不一样,有的侧重读、有的侧重写,有的两者兼备,本节紧张磋商在不同业务场景下存储读写的一些方法论。
9.1 读写分离大多数业务都是读多写少,为了提高系统处理能力,可以采取读写分离的办法将主节点用于写,从节点用于读,如下图所示。
读写分离架构
读写分离架构有以下几个特点:1)数据库做事为主从架构,可以为一主一从或者一主多从;2)主节点卖力写操作,从节点卖力读操作;3)主节点将数据复制到从节点;基于基本架构,可以变种出多种读写分离的架构,如主-主-从、主-从-从。主从节点也可以是不同的存储,如 mysql+redis。
读写分离的主从架构一样平常采取异步复制,会存在数据复制延迟的问题,适用于对数据同等性哀求不高的业务。可采取以下几个办法只管即便避免复制滞后带来的问题。
1)写后读同等性:即读自己的写,适用于用户写操作后哀求实时看到更新。范例的场景是,用户注册账号或者修正账户密码后,紧接着登录,此时如果读要求发送到从节点,由于数据可能还没同步完成,用户登录失落败,这是不可接管的。针对这种情形,可以将自己的读要求发送到主节点上,查看其他用户信息的要求依然发送到从节点。
2)二次读取:优先读取从节点,如果读取失落败或者跟踪的更新韶光小于某个阀值,则再从主节点读取。
3)关键业务读写主节点,非关键业务读写分离。
4)单调读:担保用户的读要求都发到同一个从节点,避免涌现回滚的征象。如用户在 M 主节点更新信息后,数据很快同步到了从节点 S1,用户查询时要求发往 S1,看到了更新的信息。接着用户再一次查询,此时要求发到数据同步没有完成的从节点 S2,用户看到的征象是刚才的更新的信息又消逝了,即以为数据回滚了。
9.2 动静分离动静分离将常常更新的数据和更新频率低的数据进行分离。最常见于 CDN,一个网页常日分为静态资源(图片/js/css 等)和动态资源(JSP、PHP 等),采纳动静分离的办法将静态资源缓存在 CDN 边缘节点上,只必要求动态资源即可,减少网络传输和做事负载。
在数据库和 KV 存储上也可以采纳动态分离的办法,如 7.6 提到的点播视频缓存的动静分离。在数据库中,动静分离更像是一种垂直切分,将动态和静态的字段分别存储在不同的库表中,减小数据库锁的粒度,同时可以分配不同的数据库资源来合理提升利用率。
9.3 冷热分离冷热分离可以说是每个存储产品和海量业务的必备功能,Mysql、ElasticSearch、CMEM、Grocery 等都直接或间接支持冷热分离。将热数据放到性能更好的存储设备上,冷数据下沉到廉价的磁盘,从而节约本钱。企鹅电竞为了节省在腾讯云本钱,直播回放按照主播粉丝数和韶光等条件也采取了冷热分离,下图是 ES 冷热分离的一个实现架构图。
ES冷热分离架构图
9.4 重写轻读重写轻度个人理解可能有两个含义:1)关键写,降落读的关键性,如异步复制,担保主节点写成功即可,从节点的读可容忍同步延迟。2)写重逻辑,读轻逻辑,将打算的逻辑从读转移到写。适用于读要求的时候还要进行打算的场景,常见的如排行榜是在写的时候构建而不是在读要求的时候再构建。
在微博、朋友圈等社交产品场景中都有类似关注或朋友的功能。以朋友圈仿照为例(详细我也不知道朋友圈是怎么做的),如果用户进入朋友圈时看到的朋友列表是在要求的时候遍历其朋友的新再按韶光排序组装出来的,这显然很难知足朋友圈这么大的海量要求。可以采纳重写轻读的办法,在发朋友圈的时候就把列表布局好,然后直接读就可以了。
仿照 Actor 模型,为用户建立一个信箱,用户发朋友圈后写完自己的信箱就返回,然后异步的将推送到其朋友的信箱,这样朋友读取他的信箱时便是其朋友圈的列表,如下图所示:
重写轻读流程
上图仅仅是为了展示重写轻度的思路,在实际运用中还有些其他问题。如:1)写扩散:这是个写扩散的行为,如果一个大户的朋友很多,这写扩散的代价也是很大的,而且可能有些人万年不看朋友圈乃至屏蔽了朋友。须要采纳一些其他的策略,犹如伙数在某个范围内是才采纳这种办法,数量太多采纳推拉结合和剖析一些生动指标等。2)信箱容量:一样平常来说查看朋友圈不会不断的往下翻页查看,这时候该当限定信箱存储条款数,超出的条款从其他存储查询。
9.5 数据异构数据异构紧张是按照不同的维度建立索引关系以加速查询。如京东、天猫等网上商城,一样平常按照订单号进行了分库分表。由于订单号不在同一个表中,要查询一个买家或者商家的订单列表,就须要查询所有分库然后进行数据聚合。可以采纳构建异构索引,在天生订单的时同时创建买家和商家到订单的索引表,这个表可以按照用户 id 进行分库分表。
10 行列步队
在系统运用中,不是所有的任务和要求必须实时处理,很多时候数据也不须要强同等性而只需保持终极同等性,有时候我们也不须要知道系统模块间的依赖,在这些场景下行列步队技能大有可为。
10.1 运用处景行列步队的运用处景很广泛,总结起来紧张有以下几个方面:
异步处理:业务要求的处理流程常日很多,有些流程并不须要在本次要求中立即处理,这时就可以采取异步处理。如直播平台中,主播开播后须要给粉丝发送开播关照,可以将开播事宜写入到行列步队中,然后由专门的 daemon 来处理发送开播关照,从而提高开播的相应速率。流量削峰:高并发系统的性能瓶颈一样平常在 I/O 操作上,如读写数据库。面对突发的流量,可以利用行列步队进行排队缓冲。以企鹅电竞为例,每隔一段韶光就会有大主播入驻,如梦泪等。这个时候会有大量用户的订阅主播,订阅的流程须要进行多个写操作,这时先只写用户关注了哪个主播存储。然后在进入行列步队暂存,后续再写主播被谁关注和其他存储。系统解耦:有些根本做事被很多其他做事依赖,如企鹅电竞的搜索、推举等系统须要开播事宜。而开播做事本身并不关心谁须要这些数据,只需处理开播的事情就行了,依赖做事(包括第一点说的发送开播关照的 daemon)可以订阅开播事宜的行列步队进行解耦。数据同步:行列步队可以起到数据总线的浸染,特殊是在跨系统进行数据同步时。拿我以前参与过开拓的一个分布式缓存系统为例,通过 RabbitMQ 在写 Mysql 时将数据同步到 Redis,从而实现一个终极同等性的分布式缓存。柔性事务:传统的分布式事务采取两阶段协议或者其优化变种实现,当事务实行时都须要争抢锁资源和等待,在高并发场景下会严重降落系统的性能和吞吐量,乃至涌现去世锁。互联网的核心是高并发和高可用,一样平常将传统的事务问题转换为柔性事务。下图是阿里基于行列步队的一种分布式事务实现(详情查看:企业 IT 架构转型之道 阿里巴巴中台计策思想与架构实战,微信心书有电子版):基于MQ的分布式柔性事务
其核心事理和流程是:
1)分布式事务发起方在实行第一个本地事务前,向 MQ 发送一条事务并保存到做事端,MQ 消费者无法感知和消费该 ①②。
2)事务发送成功后开始进行单机事务操作 ③:
a)如果本地事务实行成功,则将 MQ 做事真个事务更新为正常状态 ④;
b)如果本地事务实行时由于宕机或者网络问题没有及时向 MQ 做事端反馈,则之前的事务会一贯保存在 MQ。MQ 做事端会对事务进行定期扫描,如果创造有保存韶光超过了一定的韶光阀值,则向 MQ 生产端发送检讨事务实行状态的要求 ⑤;
c)检讨本地事务结果后 ⑥,如果事务实行成功,则将之前保存的事务更新为正常状态,否则奉告 MQ 做事端进行丢弃;
3)消费者获取到事务设置为正常状态后,则实行第二个本地事务 ⑧。如果实行失落败则关照 MQ 发送方对第一个本地事务进行回滚或正向补偿。
10.2 运用分类缓冲行列步队:行列步队的基本功能便是缓冲排队,如 TCP 的发送缓冲区,网络框架常日还会再加上运用层的缓冲区。利用缓冲行列步队应对突发流量时,使处理更加平滑,从而保护系统,上过 12306 买票的都懂。缓冲行列步队
在大数据日志系统中,常日须要在日志采集系统和日志解析系统之间增加日志缓冲行列步队,以防止解析系统高负载时壅塞采集系统乃至造成日志丢弃,同时便于各自升级掩护。下图天机阁数据采集系统中,就采取 Kafka 作为日志缓冲行列步队。
天机阁数据采集系统
要求行列步队:对用户的要求进行排队,网络框架一样平常都有要求行列步队,如 spp 在 proxy 进程和 work 进程之间有共享内存行列步队,taf 在网络线程和 Servant 线程之间也有行列步队,紧张用于流量掌握、过载保护和超时丢弃等。TAF要求吸收行列步队
任务行列步队:将任务提交到行列步队中异步实行,最常见的便是线程池的任务行列步队。行列步队用于投递,紧张有点对点和发布订阅两种模式,常见的有 RabbitMQ、RocketMQ、Kafka 等,下图是常用行列步队的比拟:常用行列步队
总结本文磋商和总结了后台开拓设计高性能做事的常用方法和技能,并通过思维导图总结了成一套方法论。当然这不是高性能的全部,乃至只是凤毛菱角。每个详细的领域都有自己的高性能之道,如网络编程的 I/O 模型和 C10K 问题,业务逻辑的数据构造和算法设计,各种中间件的参数调头等。文中也描述了一些项目的实践,如有不合理的地方或者有更好的办理方案,请各位同仁见教。