本文主要关注程序性能优化。专注于这个主题是偶然的。它以玩笑开始,以心脏结束。本来想找个高端的,叫人牛逼的,但是能力有限,只能给大家一些便宜好用简单的普通东西。不知道你会不会喜欢。它分为五个主题,分别是“pool”、“order”、“divide”、“reduce”和“merge”:1.“pool”这个词是池化的,目的是减少创建和回收可重用对象的成本。不知道大家有没有发现,不管是电影还是游戏,主角永远都是孤独的英雄,顶多三四成群。但是老板不一样。Boss一挥手,一群小怪就要冲上前去。毕竟能帮主角赚点经验还是不错的。小怪的特点是:数量多、容易死、可重复使用。一部电影不可能请太多群众演员,所以我们经常可以看到一个人扮演多个角色。在游戏中,不可能每个生物都完全不同,因为创建它们会消耗时间和内存。哈哈,现在你知道了吧,你打的怪很可能就是那个死前掉金币的怪。在代码中,如果某些对象具有重用价值,创建时会消耗大量的CPU或IO资源。那么当出现性能瓶颈时,一个合理的优化方向就是pooling。刚才的例子中,游戏中的小怪是池化的,通常称为对象池,类似的还有线程池、连接池等。灰太狼:我一定会回来的~2.顺序读写“order”字,减少随机IO,减少cachemiss。“内存顺序读写的性能比随机读写好很多”,“磁盘顺序读写的性能比随机读写好很多”。类似的话相信很多程序员都不陌生。不过,我也相信很多程序员在写代码的时候,从来没有认真考虑过这个事件。我在做游戏的时候,看到很多人用hash表来存储场景中的各种物体:花鸟鱼虫白云狗,每一帧遍历hash表来判断位置或者攻击信息。我建议他们改成遍历有序表的快照(MVCC理解),一个原因是为了提高遍历性能,还有一个更重要的原因是在期间可以修改表结构(插入和删除对象)遍历过程。有序列表是基于有序数组的字典结构。C#中有一个名为SortList的标准库。多年来,CPU一直是电脑中运行速度最快的部件,所以一直被用来多吃多占。过错。无论是读内存还是磁盘,我们从来不讲究按需分配,而是大块取数据。当一大块连续的数据拿在手里的时候,CPU自己也知道自己一次吞不下,但总感觉要多吞几次。但是这个功能其实需要程序员的配合。如果代码中使用了连续的内存数据结构,比如数组,那么遍历的时候就相当于为所欲为;而如果代码中使用哈希表,遍历时cachemiss的可能性会大大增加。鉴于java在服务器领域的成功,一些公司使用java开发游戏服务器,我建议他们换一种语言。原因是游戏服务器很可能需要处理大量点、向量等三维空间相关的计算。在java中,默认情况下一切都是对象。所以在一个Vertex(顶点)数组中,看似连续的Vertex对象在物理内存中实际上是离散的,这样遍历效果会差很多。对于C++、Golang,甚至C#等语言,都支持struct。当Vertex定义为struct时,一个Vertex数组占用的内存是连续的,遍历效果会比Java好很多。在后端技术栈中,Kafka绝对是一个截然不同的存在。懂kafka的人觉得没有它不开心,不懂kafka的人觉得可有可无。关于Kafka为何如此之快的讨论有很多。无法绕过的原因之一是:Kafka顺序读写磁盘。我们通常认为磁盘的读写性能远低于内存,但实际上,在关闭fsync的前提下,SSD固态硬盘的顺序读写速度相当于随机的内存的读写速度,大约1GiB/s,如果是随机读取,SSD固态硬盘的Seek速度会下降到70MiB/s,速度会下降到1/15。3、“奋”字开头,民智不悟。为了教育人们,神器电脑应运而生。匆忙下地,零件都没有弄到手。电脑天生就有残疾,CPU和其他IO部件的速度相差太大。为了平衡这种速度上的差异,世间有智者布局方方面面,分词公式应运而生。分别是batch、frame、page、time、slice、partition、database、table、separation。1、批处理≈cache+buffer在系统设计之初,每次修改对应一个IO可能是最简单直接的设计。但是,随着系统流量的增加,IO可能会很快成为系统瓶颈。与内存操作相比,磁盘IO吞吐量小,网络IO延迟大。为了减小IO和内存之间的速度差异,力求每个IO的最佳使用是最好的选择。将多个IO合并为一个,减少磁盘IO(尤其是fsync)的次数和网络IO的往返次数。所谓读优化靠缓存,写优化靠缓冲,批处理≈缓存+缓冲。说的小一点,cache就是一块用来读的内存,buffer就是一块用来写的内存。更大的规模,缓存是Redis,缓存是Kafka。这些都是微服务系统中常用的tricks,就不用我多说了。批量优化可谓无孔不入,就连最流行的数据中间件都提供了批量IO操作的API:MySQL的insert语句支持一次向value中插入多行数据。Redis的Pipeline操作可以批量执行多个操作。ElasticSearch有一个专用的_bulkapi,支持在一次调用中索引或删除多条数据。Kafka将批处理操作做到极致:它根本不提供发送单条消息的功能,使用send()发送的单条消息实际上会被Kafka偷偷保存在内存中。是的,你被骗了。对于包括MySQL、Redis在内的各种类型的数据中间件,在WAL日志刷盘时机相关的配置中,一定有只写入PageCache,然后后台线程周期性刷盘的策略。(注意:Redis严格来说不能叫WAL,因为它是post-writelog)在游戏引擎中,提交顶点数据的显卡的DrawCall也要分批合并,不然会卡死.批处理的缺点是数据一致性差,无论是缓存还是缓冲,都存在同样的问题。在缓存的情况下,如果没有严格的保证一致性的手段,往往只是推荐用于展示,而不是作为数据修改的依据。参考文章《缓存就像showgirl,看看就行了》。在内存中缓冲期间,如果进程崩溃,就会丢失一些数据。因此,在选择批量优化时,架构师需要仔细考虑数据的一致性问题,比如是否可以接受偶尔的数据丢失,或者是否需要在数据输入端提供重试策略。但无论如何,批处理可能是最重要的IO优化方式:逻辑简单,不涉及多线程并发,对数据结构没有特殊要求,系统改造成本低。可以说,每当遇到IO性能瓶颈时,批量转换应该是第一候选。2、分帧、分页、分时1)分帧是一种专用于单线程+阻塞IO的平滑技术。很多时候,由于框架的限制(比如游戏或者网页渲染),我们不得不在单线程中同时处理用户逻辑用IO操作时,如果IO耗时过长,会阻塞用户逻辑代码,这会让用户感到卡住。由于其单线程的特性,很多耗时较长的操作也需要分配到多个帧中执行。比如哈希表的rehash操作,当表中的key-value对过多时,rehash会产生巨大的计算量,如果一次性完成,服务器可能会暂停对外服务。Redis选择将rehash分发给多次执行,称为progressiverehash。另外,对于一个比较大的哈希表,hgetall可能会一次获取所有数据,这可能会导致服务器进程卡死甚至宕机。推荐使用hscan批量获取hash表中的数据。Redis中有四个命令支持迭代扫描,分别是:scan、sscan、hscan和zscan。2)分页可以看作是另一种分框操作:在运营项目中,由于后台数据量巨大,往往无法在单个网页上展示。通过分页展示,可以避免一次性数据采集带来的DB负载压力和网络传输压力。3)时分复用是指多个对象轮流使用同一个硬件的技术,多见于与硬件打交道的底层软件。例如,分时操作系统:利用时间片轮换,同时为数个甚至数百个用户服务的操作系统;时分复用网络:是指利用同一物理连接的不同周期来传输不同的信号。用于多路复用目的的网络基础设施。但是有一种分时复用技术,虽然它的名字没有分时二字,但是和后端开发息息相关,那就是IO复用(外文名称:Reactor):单个线程监听多个同时文件句柄,哪个句柄就绪,通知应用程序线程哪个句柄读写的技术。3、分片、分区、分库、分表随着1977年恢复高考,这些年年轻人目光短浅越来越多,人们越来越分不清那些换了外套又出来玩的混蛋。就像洗发水,飘柔、海飞丝、潘婷,你看一眼,都是百花齐放,仔细一看,都是宝洁的。没错,这么土的名字又不是国货,让我觉得自己爱国了这么多年。分库、分库、分库、分表也是如此。名字很好听,效果也一样。都是横向扩展方案,突破单机性能限制。外文名称:scaleout。因为方案大同小异,所以大家遇到的问题自然也大同小异。他们首先要解决的是路由问题,即数据拆分后某个key存放在哪个shard/zone/library/table的问题。路由方案可以简单分为两类:一类是非确定性路由,即同一个key可以映射到不同的计算单元进行多条路由。常见的方案包括:循环和随机。非确定性路由主要用于无状态节点之间的任务分配。例如,nginx将请求随机分配给无状态的微服务节点。另一种是确定性路由,即具有相同key的多条路由必须映射到同一个存储单元。常见的解决方案有:区间、哈希、配置表。确定性路由主要用于有状态节点之间的任务分配。例如Kafka根据user_id将来自同一个用户的请求映射到同一个存储分区。以MySQL为例,它支持四种分区类型,分别是Range、List、Hash和Key。因为与存储密切相关,所以都是确定性路由算法,其中Range对应一个范围,List是配置表,Hash和Key都是Hash类型。分片方案除了应用于多机水平扩展,在单机内存方面也有应用。比如在JDK1.8之前,ConcurrentHashMap将整个Map分成了N(默认16)个段,每个段持有一个独立的锁,从而从整体上减少并发冲突。4.分离分离设计是一种架构模式。通过单元功能的简化、提纯和专业化,可以降低开发和维护成本,同时提高功能单元的复用性。在设计模式中,我们通常称其为单一职责。目前常见的分离架构设计包括读写分离和存储计算分离。中文文章中经常用读写分离来指代MySQL从主库写,从从库读,有些狭隘。广义上,读写分离的重点是:读路径不关心写,写路径不关心读。双方都专注于自己的功能实现,不为对方做出任何牺牲或让步。更多的细节,我在《23.kafka心中的事件溯源》一文中有更详细的介绍,有兴趣的读者可以点击查看。这里要强调的是,读写分离是separateDB(UnbundingDatabases)的雏形。我们应该认识到,没有一种数据模型可以满足所有访问模式。MySQL线上业务、Redis加速查询、ES全文索引、DW离线分析,每一个衍生数据系统都有其不可替代的作用。如上图所示,通过统一写端和导出读端,可以形成一个遵循Unix传统的架构模型:单个任务做单个事情,内部通过底层API(管道)进行通信,以及通过高级语言(shell)在外部进行组合。在分离DB架构中,目前看来最合适的,可以起到胶水作用的就是EventStream(事件日志)。希望未来的某一天,我们可以写出mysql|这样的代码elasticsearch就像写ps|shell中grepjava,接下来就是分离DB夺冠的光辉时刻了。如果说读写分离是一种分裂功能,那么存储计算分离就是资源分裂:计算资源(CPU、内存)和存储资源(磁盘)分离。早期的云DB其实就是把单体DB搬到云端。人们很快发现了云DB和单机DB的区别:一是随着企业数字化转型的深入,总量猛增,单机存储捉襟见肘;波动大,使得云DB对弹性伸缩性有极高的要求。问题1可以通过分库分表等技巧来缓解,但问题2对原有的单DB“存算一体”架构提出了挑战。于是,存储计算分离架构应运而生,也就是云原生数据库架构。存储和计算分离听上去很云里雾里,似乎和我们平时的工作关系不大。但实际上,有一种架构就在我们身边,只是我们可能没有意识到,它也是一种存储计算分离架构,即微服务架构。计算节点是无状态的,存储节点是非计算的;计算节点水平扩展,存储节点垂直扩展。正如鸭子测试所说:如果它看起来像鸭子,游起来像鸭子,叫声也像鸭子,那么它很可能是鸭子。4、“减”的战术很多时候我们说不要过早优化,因为在大多数业务的初期,数据量都非常小,任何设计都不太可能出现性能问题。反之,业务上线后,由于进度未达到预期,调整业务逻辑的可能性更大。所以,怎么简单,怎么来,才是最好的选择。更快的业务实现比更快的业务运行更重要。再者,你不觉得我们一万八千的薪水,在很合理的情况下,不应该写一个支持千万级并发的系统吧?如果有一天系统开始遇到性能瓶颈怎么办?该怎么办?恭喜,这是好事,说明公司赚钱了,更重要的是通过你的系统赚钱了。首先,你要做的第一件事就是向老板要资金,拿到了资金,??你就高枕无忧了。那么,想办法把系统恢复到数据量小的时候,这不就是一种顺理成章的支持方式吗?那么,钱就没地方花了吧?你得找个地方放,我个人觉得你的钱包是个好地方。如何再次减少数据量?劝阻用户是一种方式,但我估计老板可能太不高兴了。一个更可行的选择是优化数据结构和算法。例如,为数据库建立索引就是一种方式。同样的查询也包含数千万条数据。有无索引的查询速度差别很大,因为索引会大大减少扫描的数据行数。另一种选择是裁剪数据。就像Java的GC定时清理垃圾一样,如果你发现DB中的大部分数据已经过期或者失效,或者基本不再查询数据,将过期数据归档,减小在线数据集的大小会是一个很好的选择.操作系统改造成本极低,对线上业务无影响,但数据后台可根据心情逐步改造。所有相关人员都没有太大的压力,但是效果就是秒开在线系统,肯定会闹得沸沸扬扬。事实上,上面提到的分库、分库、分表都是变相减少单位处理单元上的数据量,但改造成本高,实施难度大。特别是,在决定使用分片之前,您应该仔细考虑归档是否是一个更合理的选择。当年做手游的时候,发现游戏在4k屏的手机上跑的特别慢,手机的GPU根本扛不住。我尝试了各种优化方法,但都没有用。谁曾想,最终解决问题的办法竟然是降低游戏的输出分辨率。算法减量,体力减量,现在即使国家不提倡65岁退休,我们的老制度也肯定能维持几年。5、“组合”二字终于到了并发,一个大多数人认为应该有用的优化方式,但同时大多数人又觉得用起来很可怕。如果前面的“Pool”、“Sequence”、“Score”、“Minus”这一项顶多是工程技能,如果我们用聪明的大脑反复思考就能搞定的话,那么“Concurrency”这一项就是一门学问问题。也就是说,即使经过几年的系统研究,也很少有人敢说自己的并发代码没有bug。并发真的那么难吗?从编程语言的角度来看,并发不就是多线程和死锁吗?圣贤大神的文章都已经说的很清楚了,比如?,《线程安全,唯快不破》,只要避免死锁的必要条件,怎么写的顺畅呢?而且,运气好的话,不用锁就可以解决并发问题,不仅准确,而且速度快。然而,事实远非如此简单。我们知道DB是解决数据安全和数据一致性问题的高手,下面我们试着从DB的角度来观察一下。DB事务具有ACID的四个属性,其中我指的是Isolation,其研究的核心是并发问题。毛爷爷说,事物是运动的,我觉得他说得对。两年前,还没有新冠肺炎。今天,它已经像吃喝一样融入了我们每个人的生活。在早期的ANSISQL92标准中,涉及的并发异常只有4种。然而,时至今日,常见的并发异常多达7种,分别是:脏读、脏写、读偏斜(不可重复读)、写偏斜、不可重复读、幻读和丢失更新。每种类型的并发异常都有不同的原因和不同的解决方案,而且还不止这些。其实我很想给大家科普一下这些异常,但是我发现细节太多了。盲目猜测,后端知识体系有一半可能和并发有关。没有人否认,定稿SQL92标准的人应该算是DB专家吧?连现在的ANSI专家都搞不清楚,谁敢告诉我他能轻松搞定?所谓单机并发压榨硬件,多机并发扩大上限(scaleout)。当单线程服务遇到性能瓶颈,对应的机器硬件尚可时,进行多线程改造,充分压榨硬件性能,或许是更好的选择。相应地,当单机性能已经不能满足服务需求时,就需要进行分布式改造,通过横向扩展来提升整体服务能力。这两个思路,对应“分”字,恰恰是分表和分库的区别:如果数据量只是增加,对CPU和内存的压力不高,那么分表就可以了。挤出;否则的话,如果流量大增,单机负载已经承受不住,可以考虑选择分库。“和”字好用,但不容易掌握。并发的引入会大大增加代码的复杂度,增加维护数据一致性的难度。就像分库分表一样,它的痛苦只有用过的人才知道,所以往往只作为最终的优化手段。如果你不使用它,你需要有面对困难的勇气和决心。六、优化就是替换作为程序员,你一定听过这样一句话:好的架构不是设计出来的,而是进化出来的。想要得到什么,就得付出代价,就像想要娶到老婆,就得努力挣钱一样。优化将使代码逻辑复杂化并使过程混乱。所以,简单的设计、在线至上、大把的金钱,这些看似土气的选择,或许往往比盲目优化更能让我们远离漩涡。但不管怎么说,经常分煎饼,连续分煎饼是个好习惯。尤其是山东煎饼,来自山东泰安。
