一文搞懂后台高性能服务器设计的常见套路, BAT 高频面试系列
微信搜索🔍「编程指北」,关注这个写干货的程序员,回复「资源」,即可获取后台开发学习路线和书籍
先赞后看,养成习惯~
前言
金九银十,又是一年校招季。
经历过,才深知不易。最近,和作为校招面试官的同事聊了聊,问他们是如何去考察一个学生的,我简单归为以下几点:
- 聪明、反应快,这点自不必说,聪明意味着学习能力、适应力强,能够快速胜任工作。
- 算法不错,代码基本功好,这点其实考察的是算法能力和代码是否写得优雅。
- 基础过硬,技术岗面试最核心的还是考察「技术储备」,包括了语言基本功,操作系统、网络、体系结构、系统设计。
- 语言组织和表达能力,这点很重要,很多同学懂得某个知识点,却很难用简洁准确的语言表述出来。
想必有很多同学在刷题、刷面经,不过我想说“面经虽好,不要贪杯哦~”,面经可以刷,看看面试官都是怎么提问的,但不要寄希望于原题。
因为面试过程中的问题往往是一环扣一环的,这意味着你需要有足够的技术深度,将知识由点连接成面,而不是停留在相互孤立的知识点上。
所以还是建议系统性的看书,如果觉得时间不够,可以关注书里的重点章节。至于看哪些书?后面也会列一个我的书单和阅读建议。在【编程指北】后台回复【书单】即可获取。
那么回到技术面试上,除了算法和网络、操作系统这种基础之外,还有一类系统设计和优化的问题。这类问题需要你有一个全局的技术视野,以及熟悉一些常用的系统优化方法论,也就是工程上的一些 Best Practice,而不至于自己临时拍脑袋瞎设计。
在互联网公司,经常面临一个“三高”问题:
- 高并发
- 高性能
- 高可用
这篇文章将总结一下后台服务器开发中有哪些常用的解决“三高”问题的方法和思想。
希望这些知识,能够给你一丝启发和帮助,助力你收割 各大公司 Offer~
先上本文思维导图:
正文
一、缓存
什么是缓存?看看维基百科怎么说:
In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.
在计算机中,缓存是存储数据的硬件或软件组件,以便可以更快地满足将来对该数据的请求。 存储在缓存中的数据可能是之前计算结果,也可能是存储在其他位置的数据副本。
缓存本质来说是使用空间换时间的思想,它在计算机世界中无处不在, 比如 CPU 就自带 L1、L2、L3 Cache,这个一般应用开发可能关注较少。但是在一些实时系统、大规模计算模拟、图像处理等追求极致性能的领域,就特别注重编写缓存友好的代码。
什么是缓存友好?简单来说,就是代码在访问数据的时候,尽量使用缓存命中率高的方式。这个后面可以单独写一篇 CPU 缓存系统以及如何编写缓存友好代码的文章。
1.1 缓存为什么有效?
缓存之所以能够大幅提高系统的性能,关键在于数据的访问具有局部性,也就是二八定律:「百分之八十的数据访问是集中在 20% 的数据上」。这部分数据也被叫做热点数据。
缓存一般使用内存作为存储,内存读写速度快于磁盘,但容量有限,十分宝贵,不可能将所有数据都缓存起来。
如果应用访问数据没有热点,不遵循二八定律,即大部分数据访问并没有集中在小部分数据上,那么缓存就没有意义,因为大部分数据还没有被再次访问就已经被挤出缓存了。每次访问都会回源到数据库查询,那么反而会降低数据访问效率。
1.2 缓存分类
-
1. 本地缓存:
使用进程内成员变量或者静态变量,适合简单的场景,不需要考虑缓存一致性、过期时间、清空策略等问题。
可以直接使用语言标准库内的容器来做存储。例如:
-
2. 分布式缓存:
当缓存的数据量增大以后,单机不足以承载缓存服务时,就要考虑对缓存服务做水平扩展,引入缓存集群。
将数据分片后分散存储在不同机器中,如何决定每个数据分片存放在哪台机器呢?一般是采用一致性 Hash 算法,它能够保证在缓存集群动态调整,不断增加或者减少机器后,客户端访问时依然能够根据 key 访问到数据。
一致性 Hash 算法也是值得用一篇文章来讲的,如果暂时还不懂的话可以去搜一下。
常用的组件有 Memcache、 Redis Cluster 等,第二个是在高性能内存存储 Redis 的基础上,提供分布式存储的解决方案。
1.3 缓存使用指南
1. 适合缓存的场景:
-
读多写少:
比如电商里的商品详情页面,访问频率很高,但是一般写入只在店家上架商品和修改信息的时候发生。如果把热点商品的信息缓存起来,这将拦截掉很多对数据库的访问,提高系统整体的吞吐量。
因为一般数据库的 QPS 由于有「ACID」约束、并且数据是持久化在硬盘的,所以比 Redis 这类基于内存的 NoSQL 存储低不少。常常是一个系统的瓶颈,如果我们把大部分的查询都在 Redis 缓存中命中了,那么系统整体的 QPS 也就上去了。
-
计算耗时大,且实时性不高:
比如王者荣耀里的全区排行榜,一般一周更新一次,并且计算的数据量也比较大,所以计算后缓存起来,请求排行榜直接从缓存中取出,就不用实时计算了。
2. 不适合缓存的场景:
- 写多读少,频繁更新。
- 对数据一致性要求严格: 因为缓存会有更新策略,所以很难做到和数据库实时同步。
- 数据访问完全随机: 因为这样会导致缓存的命中率极低。
1.4 缓存更新的策略
如何更新缓存其实已经有总结得非常好的「最佳实践」,我们按照套路来,大概率不会犯错。
主要分为两类 Cache-Aside 和 Cache-As-SoR。 SoR 即「System Of Record,记录系统」,表示数据源,一般就是指数据库。
1、Cache-Aside:
这应该是最容易想到的模式了,获取数据时先从缓存读,如果 cache hit 则直接返回,没命中就从数据源获取,然后更新缓存。
写数据的时候则先更新数据源,然后设置缓存失效,下一次获取数据的时候必然 cache miss,然后触发回源。
直接看伪代码:
可以看到这种方式对于缓存的使用者是不透明的,需要使用者手动维护缓存。
2、Cache-As-SoR:
从字面上来看,就是把 Cache 当作 SoR,也就是数据源,所以一切读写操作都是针对 Cache 的,由 Cache 内部自己维护和数据源的一致性。
这样对于使用者来说就和直接操作 SoR 没有区别了,完全感知不到 Cache 的存在。
CPU 内部的 L1、L2、L3 Cache 就是这种方式,作为数据的使用方应用程序,是完全感知不到在内存和我们之间还存在几层的 Cache,但是我们之前又提到编写 “缓存友好”的代码,不是透明的吗?这是不是冲突呢?
其实不然,缓存友好是指我们通过学习了解缓存内部实现、更新策略之后,通过调整数据访问顺序提高缓存的命中率。
Cache-As-SoR 又分为以下三种方式:
- Read Through:这种方式和 Cache-Aside 非常相似,都是在查询时发生 cache miss 去更新缓存,但是区别在于 Cache-Aside 需要调用方手动更新缓存,而 Cache-As-SoR 则是由缓存内部实现自己负责,对应用层透明。
- Write Through: 直写式,就是在将数据写入缓存的同时,缓存也去更新后面的数据源,并且必须等到数据源被更新成功后才可返回。这样保证了缓存和数据库里的数据一致性。
- Write Back:回写式,数据写入缓存即可返回,缓存内部会异步的去更新数据源,这样好处是写操作特别快,因为只需要更新缓存。并且缓存内部可以合并对相同数据项的多次更新,但是带来的问题就是数据不一致,可能发生写丢失。
二、预处理和延后处理
预先延后,这其实是一个事物的两面,不管是预先还是延后核心思想都是将本来该在实时链路上处理的事情剥离,要么提前要么延后处理。降低实时链路的路径长度, 这样能有效提高系统性能。
2.1 预处理
举个我们团队实际中遇到的问题:
前两个月支付宝联合杭州市政府发放消费劵,但是要求只有杭州市常驻居民才能领取,那么需要在抢卷请求进入后台的时候就判断一下用户是否是杭州常驻居民。
而判断用户是否是常驻居民这个是另外一个微服务接口,如果直接实时的去调用那个接口,短时的高并发很有可能把这个服务也拖挂,最终导致整个系统不可用,并且 RPC 本身也是比较耗时的,所以就考虑在这里进行优化。
那么该怎么做呢?很简单的一个思路,提前将杭州所有常驻居民的 user_id 存到缓存中, 比如可以直接存到 Redis。大概就是千万量级,这样,当请求到来的时候我们直接通过缓存可以快速判断是否来自杭州常驻居民。如果不是则直接在这里返回前端。
这里通过预先处理减少了实时链路上的 RPC 调用,既减少了系统的外部依赖,也极大的提高了系统的吞吐量。
预处理在 CPU 和操作系统中也广泛使用,比如 CPU 基于历史访存信息,将内存中的指令和数据预取到 Cache 中,这样可以大大提高Cache 命中率。 还比如在 Linux 文件系统中,预读算法会预测即将访问的 page,然后批量加载比当前读请求更多的数据缓存在 page cache 中,这样当下次读请求到来时可以直接从 cache 中返回,大大减少了访问磁盘的时间。
2.2 延后处理
还是支付宝,上栗子:
这是支付宝春节集五福活动开奖当晚,不过,作为非酋的我一般是不屑于参与这种活动的。
大家发现没有,这类活动中奖奖金一般会显示 「稍后到账」,为什么呢?那当然是到账这个操作不简单!
到账即转账,A 账户给 B 账户转钱,A 减钱, B 就必须要同时加上钱,也就是说不能 A 减了钱但 B 没有加上,这就会导致资金损失。资金安全是支付业务的生命线,这可不行。
这两个动作必须一起成功或是一起都不成功,不能只成功一半,这是保证数据一致性。 保证两个操作同时成功或者失败就需要用到事务。
如果去实时的做到账,那么大概率数据库的 TPS(每秒处理的事务数) 会是瓶颈。通过产品提示,将到账操作延后处理,解决了数据库 TPS 瓶颈。
延后处理还有一个非常著名的例子,COW(Copy On Write,写时复制)。 Linux 创建进程的系统调用 fork,fork 产生的子进程只会创建虚拟地址空间,而不会分配真正的物理内存,子进程共享父进程的物理空间,只有当某个进程需要写入的时候,才会真正分配物理页,拷贝该物理页,通过 COW 减少了很多不必要的数据拷贝。
三、池化
后台开发过程中你一定离不开各种 「池子」: 内存池、连接池、线程池、对象池......
内存、连接、线程这些都是资源,创建线程、分配内存、数据库连接这些操作都有一个特征, 那就是创建和销毁过程都会涉及到很多系统调用或者网络 IO。 每次都在请求中去申请创建这些资源,就会增加请求处理耗时,但是如果我们用一个 容器(池) 把它们保存起来,下次需要的时候,直接拿出来使用,避免重复创建和销毁浪费的时间。
3.1 内存池
在 C/C++ 中,经常使用 malloc、new 等 API 动态申请内存。由于申请的内存块大小不一,如果频繁的申请、释放会导致大量的内存碎片,并且这些 API 底层依赖系统调用,会有额外的开销。
内存池就是在使用内存前,先向系统申请一块空间留做备用,使用者需要内池时向内存池申请,用完后还回来。
内存池的思想非常简单,实现却不简单,难点在于以下几点:
- 如何快速分配内存
- 降低内存碎片率
- 维护内存池所需的额外空间尽量少
如果不考虑效率,我们完全可以将内存分为不同大小的块,然后用链表连接起来,分配的时候找到大小最合适的返回,释放的时候直接添加进链表。如:
当然这只是玩具级别的实现,业界有性能非常好的实现了,我们可以直接拿来学习和使用。
比如 Google 的 「tcmalloc」 和 Facebook 的 「jemalloc」。
限于篇幅我们不在这里详细讲解它们的实现原理,如果感兴趣可以搜来看看,也推荐去看看被誉为神书的 CSAPP(《深入理解计算机系统》)第 10 章,那里也讲到了动态内存分配算法。
3.2 线程池
线程是干嘛的?线程就是我们程序执行的实体。在服务器开发领域,我们经常会为每个请求分配一个线程去处理,但是线程的创建销毁、调度都会带来额外的开销,线程太多也会导致系统整体性能下降。在这种场景下,我们通常会提前创建若干个线程,通过线程池来进行管理。当请求到来时,只需从线程池选一个线程去执行处理任务即可。
线程池常常和队列一起使用来实现任务调度,主线程收到请求后将创建对应的任务,然后放到队列里,线程池中的工作线程等待队列里的任务。
线程池实现上一般有四个核心组成部分:
- 管理器(Manager): 用于创建并管理线程池。
- 工作线程(Worker): 执行任务的线程。
- 任务接口(Task): 每个具体的任务必须实现任务接口,工作线程将调用该接口来完成具体的任务。
- 任务队列(TaskQueue): 存放还未执行的任务。
线程池在 C、C++ 中没有具体的实现,需要应用开发者手动实现上诉几个部分。
在 Java 中 「ThreadPoolExecutor」 类就是线程池的实现。后续我也会写文章分析 C++ 如何写一个简单的线程池以及 Java 中线程池是如何实现的。
3.3 连接池
顾名思义,连接池是创建和管理连接的。
大家最熟悉的莫过于数据库连接池,这里我们简单分析下如果不用数据库连接池,一次 SQL 查询请求会经过哪些步骤:
- 和 MySQL server 建立 TCP 连接:
- 三次握手
- MySQL 权限认证:
- Server 向 Client 发送 密钥
- Client 使用密钥加密用户名、密码等信息,将加密后的报文发送给 Server
- Server 根据 Client 请求包,验证是否是合法用户,然后给 Client 发送认证结果
- Client 发送 SQL 语句
- Server 返回语句执行结果
- MySQL 关闭
- TCP 连接断开
- 四次挥手
可以看出不使用连接池的话,为了执行一条 SQL,会花很多时间在安全认证、网络IO上。
如果使用连接池,执行一条 SQL 就省去了建立连接和断开连接所需的额外开销。
还能想起哪里用到了连接池的思想吗?我认为 HTTP 长链接也算一个变相的链接池,虽然它本质上只有一个连接,但是思想却和连接池不谋而合,都是为了复用同一个连接发送多个 HTTP 请求,避免建立和断开连接的开销。
池化实际上是预处理和延后处理的一种应用场景,通过池子将各类资源的创建提前和销毁延后。
四、同步变异步
对于处理耗时的任务,如果采用同步的方式,那么会增加任务耗时,降低系统并发度。
可以通过将同步任务变为异步进行优化。
举个例子,比如我们去 KFC 点餐,遇到排队的人很多,当点完餐后,大多情况下我们会隔几分钟就去问好了没,反复去问了好几次才拿到,在这期间我们也没法干活了,这时候我们是这样的:
这个就叫同步轮训, 这样效率显然太低了。
服务员被问烦了,就在点完餐后给我们一个号码牌,每次准备好了就会在服务台叫号,这样我们就可以在被叫到的时候再去取餐,中途可以继续干自己的事。
这就叫异步,在很多编程语言中有异步编程的库,比如 C++ std::future、Python asyncio 等,但是异步编程往往需要回调函数(Callback function),如果回调函数的层级太深,这就是回调地狱(Callback hell)。回调地狱如何优化又是一个庞大的话题。。。。
这个例子相当于函数调用的异步化,还有的是情况是处理流程异步化,这个会在接下来消息队列中讲到。
五、消息队列
这是一个非常简化的消息队列模型,上游生产者将消息通过队列发送给下游消费者。在这之间,消息队列可以发挥很多作用,比如:
5.1 服务解耦
有些服务被其它很多服务依赖,比如一个论坛网站,当用户成功发布一条帖子有一系列的流程要做,有积分服务计算积分,推送服务向发布者的粉丝推送一条消息..... 对于这类需求,常见的实现方式是直接调用:
这样如果需要新增一个数据分析的服务,那么又得改动发布服务,这违背了依赖倒置原则,即上层服务不应该依赖下层服务,那么怎么办呢?
引入消息队列作为中间层,当帖子发布完成后,发送一个事件到消息队列里,而关心帖子发布成功这件事的下游服务就可以订阅这个事件,这样即使后续继续增加新的下游服务,只需要订阅该事件即可,完全不用改动发布服务,完成系统解耦。
5.2 异步处理
有些业务涉及到的处理流程非常多,但是很多步骤并不要求实时性。那么我们就可以通过消息队列异步处理。比如淘宝下单,一般包括了风控、锁库存、生成订单、短信/邮件通知等步骤。但是核心的就风控和锁库存, 只要风控和扣减库存成功,那么就可以返回结果通知用户成功下单了。后续的生成订单,短信通知都可以通过消息队列发送给下游服务异步处理。大大提高了系统响应速度。
这就是处理流程异步化。
5.3 流量削峰
一般像秒杀、抽奖、抢卷这种活动都伴随着短时间海量的请求, 一般超过后端的处理能力,那么我们就可以在接入层将请求放到消息队列里,后端根据自己的处理能力不断从队列里取出请求进行业务处理。
就像最近长江汛期,上游短时间大量的洪水汇聚直奔下游,但是通过三峡大坝将这些水缓存起来,然后匀速的向下游释放,起到了很好的削峰作用。
起到了平均流量的作用。
5.4 总结
消息队列的核心思想就是把同步的操作变成异步处理,异步处理会带来相应的好处,比如:
- 服务解耦
- 提高系统的并发度,将非核心操作异步处理,不会阻塞住主流程
但是软件开发没有银弹,所有的方案选择都是一种 trade-off。 同样,异步处理也不全是好处,也会导致一些问题:
- 降低了数据一致性,从强一致性变为最终一致性
- 有消息丢失的风险,比如宕机,需要有容灾机制
六、批量处理
在涉及到网络连接、IO等情况时,将操作批量进行处理能够有效提高系统的传输速率和吞吐量。
在前后端通信中,通过合并一些频繁请求的小资源可以获得更快的加载速度。
比如我们后台 RPC 框架,经常有更新数据的需求,而有的数据更新的接口往往只接受一项,这个时候我们往往会优化下更新接口,
使其能够接受批量更新的请求,这样可以将批量的数据一次性发送,大大缩短网络 RPC 调用耗时。
七、数据库
我们常把后台开发调侃为「CRUD」,数据库在整个应用开发过程中的重要性不言而喻。
而且很多时候系统的瓶颈也往往处在数据库这里,慢的原因也有很多,比如可能是没用索引、没用对索引、读写锁冲突等等。
那么如何使用数据才能又快又好呢?下面这几点需要重点关注:
7.1 索引
索引可能是我们平时在使用数据库过程中接触得最多的优化方式。索引好比图书馆里的书籍索引号,想象一下,如果我让你去一个没有书籍索引号的图书馆找《人生》这本书,你是什么样的感受?当然是怀疑人生,同理,你应该可以理解当你查询数据,却不用索引的时候数据库该有多崩溃了吧。
数据库表的索引就像图书馆里的书籍索引号一样,可以提高我们检索数据的效率。索引能提高查找效率,可是你有没有想过为什么呢?这是因为索引一般而言是一个排序列表,排序意味着可以基于二分思想进行查找,将查询时间复杂度做到 O(log(N)),快速的支持等值查询和范围查询。
二叉搜索树查询效率无疑是最高的,因为平均来说每次比较都能缩小一半的搜索范围,但是一般在数据库索引的实现上却会选择 B 树或 B+ 树而不用二叉搜索树,为什么呢?
这就涉及到数据库的存储介质了,数据库的数据和索引都是存放在磁盘,并且是 InnoDB 引擎是以页为基本单位管理磁盘的,一页一般为 16 KB。AVL 或红黑树搜索效率虽然非常高,但是同样数据项,它也会比 B、B+ 树更高,高就意味着平均来说会访问更多的节点,即磁盘IO次数!
根据 Google 工程师 Jeff Dean 的统计,访问内存数据耗时大概在 100 ns,访问磁盘则是 10,000,000 ns。
所以表面上来看我们使用 B、B+ 树没有 二叉查找树效率高,但是实际上由于 B、B+ 树降低了树高,减少了磁盘 IO 次数,反而大大提升了速度。
这也告诉我们,没有绝对的快和慢,系统分析要抓主要矛盾,先分析出决定系统瓶颈的到底是什么,然后才是针对瓶颈的优化。
其实关于索引想写的也还有很多,但还是受限于篇幅,以后再单独写。
先把我认为索引必知必会的知识列出来,大家可以查漏补缺:
- 主键索引和普通索引,以及它们之间的区别
- 最左前缀匹配原则
- 索引下推
- 覆盖索引、联合索引
7.2 读写分离
一般业务刚上线的时候,直接使用单机数据库就够了,但是随着用户量上来之后,系统就面临着大量的写操作和读操作,单机数据库处理能力有限,容易成为系统瓶颈。
由于存在读写锁冲突,并且很多大型互联网业务往往读多写少,读操作会首先成为数据库瓶颈,我们希望消除读写锁冲突从而提升数据库整体的读写能力。
那么就需要采用读写分离的数据库集群方式,一主多从,主库会同步数据到从库。写操作都到主库,读操作都去从库。
读写分离到之后就避免了读写锁争用,这里解释一下,什么叫读写锁争用:
MySQL 中有两种锁:
- 排它锁( X 锁): 事务 T 对数据 A 加上 X 锁时,只允许事务 T 读取和修改数据 A。
- 共享锁( S 锁): 事务 T 对数据 A 加上 S 锁时,其他事务只能再对数据 A 加 S 锁,而不能加 X 锁,直到 T 释放 A 上的 S 锁。
读写分离解决问题的同时也会带来新问题,比如主库和从库数据不一致
MySQL 的主从同步依赖于 binlog,binlog(二进制日志)是 MySQL Server 层维护的一种二进制日志,是独立于具体的存储引擎。它主要存储对数据库更新(insert、delete、update)的 SQL 语句,由于记录了完整的 SQL 更新信息,所以 binlog 是可以用来数据恢复和主从同步复制的。
从库从主库拉取 binlog 然后依次执行其中的 SQL 即可达到复制主库的目的,由于从库拉取 binlog 存在网络延迟等,所以主从数据存在延迟问题。
那么这里就要看业务是否允许短时间内的数据不一致,如果不能容忍,那么可以通过如果读从库没获取到数据就去主库读一次来解决。
7.3 分库分表
如果用户越来越多,写请求暴涨,对于上面的单 Master 节点肯定扛不住,那么该怎么办呢?多加几个 Master?不行,这样会带来更多的数据不一致的问题,增加系统的复杂度。那该怎么办?就只能对库表进行拆分了。
常见的拆分类型有垂直拆分和水平拆分。
考虑拼夕夕电商系统,一般有 订单表、用户表、支付表、商品表、商家表等, 最初这些表都在一个数据库里。
后来随着砍一刀带来的海量用户,拼夕夕后台扛不住了! 于是紧急从阿狸粑粑那里挖来了几个 P8、P9 大佬对系统进行重构。
-
P9 大佬第一步先对数据库进行垂直分库,
根据业务关联性强弱,将它们分到不同的数据库, 比如订单库,商家库、支付库、用户库。 -
第二步是对一些大表进行垂直分表,将一个表按照字段分成多表,每个表存储其中一部分字段。 比如商品详情表可能最初包含了几十个字段,但是往往最多访问的是商品名称、价格、产地、图片、介绍等信息,所以我们将不常访问的字段单独拆成一个表。
- 由于垂直分库已经按照业务关联切分到了最小粒度,数据量任然非常大,P9 大佬开始水平分库,比如可以把订单库分为订单1库、订单2库、订单3库...... 那么如何决定某个订单放在哪个订单库呢?可以考虑对主键通过哈希算法计算放在哪个库。
- 分完库,单表数据量任然很大,查询起来非常慢,P9 大佬决定按日或者按月将订单分表,叫做日表、月表。
分库分表同时会带来一些问题,比如平时单库单表使用的主键自增特性将作废,因为某个分区库表生成的主键无法保证全局唯一,这就需要引入全局 UUID 服务了。
经过一番大刀阔斧的重构,拼夕夕恢复了往日的活力,大家又可以愉快的在上面互相砍一刀了。
(分库分表会引入很多问题,并没有一一介绍,这里只是为了讲解什么是分库分表)
八、具体技法
8.1 零拷贝
高性能的服务器应当避免不必要数据复制,特别是在用户空间和内核空间之间的数据复制。 比如 HTTP 静态服务器发送静态文件的时候,一般我们会这样写:
如果了解 Linux IO 的话就知道这个过程包含了内核空间和用户空间之间的多次拷贝:
内核空间和用户空间之间数据拷贝需要 CPU 亲自完成,但是对于这类数据不需要在用户空间进行处理的程序来说,这样的两次拷贝显然是浪费。什么叫 「不需要在用户空间进行处理」?
比如 FTP 或者 HTTP 静态服务器,它们的作用只是将文件从磁盘发送到网络,不需要在中途对数据进行编解码之类的计算操作。
如果能够直接将数据在内核缓存之间移动,那么除了减少拷贝次数以外,还能避免内核态和用户态之间的上下文切换。
而这正是零拷贝(Zero copy)干的事,主要就是利用各种零拷贝技术,减少不必要的数据拷贝,将 CPU 从数据拷贝这样简单的任务解脱出来,让 CPU 专注于别的任务。
常用的零拷贝技术:
-
mmap
mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。
-
sendfile
sendfile 是 Linux2.1 版本提供的,数据不经过用户态,直接从页缓存拷贝到 socket 缓存,同时由于和用户态完全无关,就减少了一次上下文切换。
在 Linux 2.4 版本,对 sendfile 进行了优化,直接通过 DMA 将磁盘文件数据读取到 socket 缓存,真正实现了 ”0” 拷贝。前面 mmap 和 2.1 版本的 sendfile 实际上只是消除了用户空间和内核空间之间拷贝,而页缓存和 socket 缓存之间的拷贝依然存在。
8.2 无锁化
在多线程环境下,为了避免 竞态条件(race condition), 我们通常会采用加锁来进行并发控制,锁的代价也是比较高的,锁会导致上线文切换,甚至被挂起直到锁被释放。
基于硬件提供的原子操作 CAS(Compare And Swap) 实现一些高性能无锁的数据结构,比如无锁队列,可以在保证并发安全的情况下,提供更高的性能。
首先需要理解什么是 CAS,CAS 有三个操作数,内存里当前值M,预期值 E,修改的新值 N,CAS 的语义就是:
如果当前值等于预期值,则将内存修改为新值,否则不做任何操作。
用 C 语言来表达就是:
注意,上面 CAS 函数实际上是一条原子指令,那么是如何用的呢?
假设我需要实现这样一个功能:
对一个全局变量 global 在两个不同线程分别对它加 100 次,这里多线程访问一个全局变量存在 race condition,所以我们需要采用线程同步操作,下面我分别用锁和CAS的方法来实现这个功能。
通过使用原子操作大大降低了锁冲突的可能性,提高了程序的性能。
除了 CAS,还有一些硬件原子指令:
- Fetch-And-Add,对变量原子性 + 1
- Test-And-Set,这是各种锁算法的核心,在 AT&T/GNU 汇编语法下,叫 xchg 指令,我会单独写一篇如何使用 xchg 实现各种锁。
8.3 序列化与反序列化
先看看维基百科怎么定义的序列化:
In computing, serialization (US spelling) or serialisation (UK spelling) is the process of translating a data structure or object state into a format that can be stored (for example, in a file or memory data buffer) or transmitted (for example, across a computer network) and reconstructed later (possibly in a different computer environment). When the resulting series of bits is reread according to the serialization format, it can be used to create a semantically identical clone of the original object. For many complex objects, such as those that make extensive use of references, this process is not straightforward. Serialization of object-oriented objects does not include any of their associated methods with which they were previously linked.
我相信你大概率没有看完上面的英文描述,其实我也不爱看英文资料,总觉得很慢,但是计算机领域一手的学习资料都是美帝那边的,所以没办法,必须逼自己去试着读一些英文的资料。
实际上也没有那么难,熟悉常用的几百个专业名词,句子都是非常简单的一些从句。没看的话,再倒回去看看?
这里我就不做翻译了,主要是水平太低,估计做到「信达雅」的信都很难。
扯远了,还是回到序列化来。
所有的编程一定是围绕数据展开的,而数据呈现形式往往是结构化的,比如结构体(Struct)、类(Class)。 但是当我们 通过网络、磁盘等传输、存储数据的时候却要求是二进制流。 比如 TCP 连接,它提供给上层应用的是面向连接的可靠字节流服务。那么如何将这些结构体和类转化为可存储和可传输的字节流呢?这就是序列化要干的事情,反之,从字节流如何恢复为结构化的数据就是反序列化。
序列化解决了对象持久化和跨网络数据交换的问题。
序列化一般按照序列化后的结果是否可读,可分为以下两类:
-
文本类型:
如 JSON、XML,这些类型可读性非常好,是自解释的。也常常用在前后端数据交互上,因为接口调试,可读性高非常方便。但是缺点就是信息密度低,序列化后占用空间大。
-
二进制类型
如 Protocol Buffer、Thrift等,这些类型采用二进制编码,数据组织得更加紧凑,信息密度高,占用空间小,但是带来的问题就是基本不可读。
还有 Java 、Go 这类语言内置了序列化方式,比如在 Java 里实现了 Serializable 接口即表示该对象可序列化。
说到这让我想起了大一写的的两个程序,一个是用刚 C 语言写的公交管理系统,当时需要将公交线路、站点信息持久化保存,当时的方案就是每个公交线路写在一行,用 "|"分割信息,比如:
5|6:00-22:00|大学城|南山站|北京站
123|6:30-23:00|南湖大道|茶山刘|世界
第一列就是线路编号、第二项是发车时间、后面就是途径的站点。是不是非常原始?实际上这也是一种序列化方式,只是效率很低,也不通用。而且存在一个问题就是如果信息中包含 “|”怎么办?当然是用转义。
第二个程序是用 Java 写的网络五子棋,当时需要通过网络传输表示棋子位置的对象,查了一圈最后发现只需要实现 Serializable 接口,自己什么都不用干,就能自己完成对象的序列化,然后通过网络传输后反序列化。当时哪懂得这就叫序列化,只觉得牛逼、神奇!
最后完成了一个可以网络五子棋,拉着隔壁室友一起玩。。。真的是成就感满满哈哈哈。
说来在编程方面,已经很久没有这样的成就感了。
总结
这篇文章主要是粗浅的介绍了一些系统设计、系统优化的套路和最佳实践。
不知道你发现没有,从缓存到消息队列、CAS......,很多看起来很牛逼的架构设计其实都来源于操作系统、体系结构。
所以我非常热衷学习一些底层的基础知识,这些看似古老的技术是经过时间洗礼留下来的好东西。现在很多的新技术、框架看似非常厉害,实则不少都是新瓶装旧酒,每几年又会被淘汰一批。
最后说一句(求关注)
这篇文章写了挺久的,从写文章、画图,调格式每一步都很花时间。如果觉得对你有帮助的话,可以点个关注或者在看鼓励下~
文章持续更新,微信搜索「 编程指北 」第一时间获取,回复【资料】有我准备的一线 BAT 大厂面试资料和简历模板。
我是小北,期待你的关注~
有任何问题,欢迎留言~