首页> 搜索结果页
"需要ccreate" 检索
共 34475 条结果
没有索引也能用SQL ?深度解析 SLS Schema-on-Read 分析原理与应用
引言日志数据的蓬勃发展随着数字化浪潮下企业数字化转型进程的不断加速,以及云原生趋势下可观测性理念的逐渐普及,企业的日志数据的规模正在快速增长,同时日志数据的范畴也在不断扩大,包括但不限于:应用程序运行日志、Prometheus采集的云原生应用监控指标、服务器Syslog、网络访问日志、移动端监控数据、数据库binlog、业务埋点数据、账单数据、IoT设备上报数据等等。这些数据有着丰富的用途,比如异常监测、故障诊断、安全风控、行为审计、运营报表、用户画像分析等等。为了能够发挥出数据背后的价值,企业不仅需要收集和存储海量的日志数据,同时更要能够对这些海量数据进行高效的计算和分析。大数据技术的演进与SQL的回归随着互联网的飞速发展,传统的关系型数据库逐渐难以满足海量数据的存储和查询需求,Google的“三驾马车”(MapReduce、GFS、BigTable)论文的发布,正式揭开了大数据时代的序幕。GFS 和 MapReduce 解决了数据大规模存储和计算的问题,只需要将大量普通的机器组织起来,就可以获得对海量数据的处理能力,从而大大降低了大数据技术的门槛。这些新的数据技术的诞生,引发了一场传统数据库与大数据的论战,其中最出名的是数据库领域图灵奖得主Michael Stonebraker写的 MapReduce: A major step backwards ,批判MapReduce丢弃了传统数据库的理论精华,引用文章中的一段话:数据库领域这四十年来中学到了三条重要的经验:Schemas are good.Separation of the schema from the application is good.High-level access languages are good.然而MapReduce没有吸收上面三个经验中的任何一个,甚至可以说是退步到了现代DBMS发明前的60年代。尽管以今天的视角看这篇文章对于MapReduce的批判整体上有些稍显偏激了,但从上面这段话我们可以看出,基于关系模型的Schema定义、声明式的SQL语言对于数据库的发展是至关重要的,在这个基础上,计算的“逻辑意图表达”与计算的“物理执行过程”被解耦开来,使用者只需要使用SQL去表达自己“需要算什么”,至于“怎么去算“则完全交给数据库执行引擎自己去优化,这种解耦大大降低了数据库的使用难度,从而极大地促进了数据库的普及。这场论战最终的结果是大数据技术与传统数据库技术都发现了对方的优点,双方取长补短、相互融合。正如Google自己在后续发布的Spanner论文(Spanner: Becoming a SQL System)中这样说到:虽然这些系统提供了数据库系统的一些优点,但它们缺乏应用程序开发人员经常依赖的许多传统数据库特性。一个关键的例子是健壮的查询语言,这意味着开发人员必须编写复杂的代码来处理和聚合应用程序中的数据。因此,我们决定把Spanner变成一个功能齐全的SQL系统,查询执行与Spanner的其他架构特性紧密结合。大数据领域这边吸收了传统数据库中的索引优化、查询优化、SQL语言等技术,演进出了各种SQL-on-Hadoop的解决方案,如Hive、Spark SQL、Presto等,如今对于SQL的支持基本已经成为各种大数据计算引擎的标配。而数据库领域则向着分布式、HTAP等方向演进着。这些融合标志着SQL重新回归,成为数据分析处理系统的通用接口。关系模型的约束与Schema-on-Read的兴起SQL之所以能够具备强大的计算表达能力和优越的执行友好性,是建立在对数据模型的严格要求上。SQL背后的理论基础是关系代数,关系模型则要求数据预先定义好Schema(有哪些表,每张表有哪些列,每一列的类型是什么),然后按照定义好的Schema去写数据。相对应的,文档模型则对于数据Schema的要求非常灵活,每条数据就是一个文档,文档内部的数据格式不做严格限定。正所谓天下没有免费的午餐,这种对数据模型的限制,反过来也会成为一种对灵活性的束缚。在大数据场景中,半结构化、无结构化的数据是非常常见的,因此也催生了 Schema-on-Write 和 Schema-on-Read 两种不同的处理方式。Schema-on-Write指的是数据的Schema是预先定义好的,数据在写入数据仓库之前,必须要按照定义好的Schema去写入,如果原始数据不符合定义的Schema,则需要先通过一些ETL的过程去对数据进行清洗加工,然后再写入。数据写入的同时,一般也同时会根据确定的Schema建立一些索引结构,查询的时候也同样直接按照确定的Schema去查询,往往能够获得较好的查询性能。而Schema-on-Read指的是原始数据在写入的时候不做过多的校验,而是在读取的时候“动态”的决定以何种视角去看待数据,类似在数据之上按需建立一个视图,这种方式显然更灵活,但相应的性能上会一般会打一些折扣。日志分析场景下的SQL日志数据天然是弱Schema的从数据模型的角度看,日志数据天然难以具备规范的Schema:来源多样性:日志数据种类繁多,不同来源的数据难以具有统一的Schema数据随机性:比如异常事件日志、用户行为日志,往往天然就是随机的,难以预测的业务复杂度:不同的参与方对schema的理解不同,比如开发流程中打日志的往往是开发者,但分析日志的往往是运维和运营,写日志过程中没有足够预见性导致不能被分析人员直接使用对于SLS这样的日志平台来说,SLS的日志数据可以从各种数据源采集过来,写入的时候可以灵活的格式写入,因此可以看做是文档模型,每条日志就是一个文档。而SQL要求是数据必须遵从关系模型,因此从数据模型的角度这其中就必然存在一个Gap,导致难以直接在原始数据上使用SQL进行查询。基于索引和列存的Schema-on-Write SQL在数据系统中,由于处理场景的多样性,往往单一的数据形式是无法满足所有的需求,因此就有了记录数据和派生数据的概念。记录数据(record data):指的是原始数据,只会在系统中存一份派生数据(derived data):指的是从原始数据衍生出的各种形式的数据,如索引、缓存、物化视图等等。派生数据是原始数据的冗余,本质上都是为了不同的读取场景进行优化。对于日志场景,SLS也是同样的做法,原始数据采集上来后,会生成出索引、列存、metric等不同形式的派生数据,分别用于搜索、SQL和时序场景。对于SLS的SQL来说,主要是依赖列存。SLS的原始数据可以是半结构或者无结构化的,而派生出的列存数据是结构化的,严格遵从关系数据模型,这样就解决了SQL对于数据模型的要求。此外,对于分析场景,列存在压缩比例、cache友好性、IO效率上相比于行存有着极大的优势,再配合索引的快速filter能力,极大的提高了SQL分析的性能。从Schema的角度看,虽然SLS原始数据写入没有Schema要求,但是列存是必须先定义Schema,然后写入的时候按照Schema写入(SLS构建列存时进行的转换),查询分析的时候也按照定义好的Schema进行分析。因此从SQL的视角看,这是一个标准的Schema-on-Write的模式。用户增效降本的诉求基于索引和列存的SQL的模式,可以很好的支持绝大部分的SQL分析需求。然而对于弱Schema和写多读少的场景,用户往往会有增效降本的诉求,具体表现在:增效——固定Schema的限制前面说到日志数据天然是弱Schema的,意味着Schema的不确定是常态。当Schema发生变化时,必须要去更新索引,频繁索引操作带来运维负担非结构化的日志数据,可能字段数目非常多,无法一一枚举,或者会有新增字段难以提前预测,这些情况下难以去创建对应的字段索引如果字段发生变化未及时感知到,对于索引变更前的历史数据,需要重建索引,重建索引带来索引流量费用,且超过30天的历史数据无法重建构建列存时的字段长度是有限制的(默认2k,最大支持16k),如果字段过长,超出的部分会被截断无法分析降本——派生数据带来的成本现有模式下,为了满足关系模型和保证查询的高性能,必须要构建索引和列存,从而产生索引流量和索引存储的成本。写多读少的场景,比如异常排查、审计,往往只在很少的时候需要分析,但是也必须全部开启索引非结构化数据中的长尾字段,即使数据价值密度很低,但是为了潜在的分析需求,也不得不提前开启索引这些场景下,全量字段开启索引所产生的费用可能与实际产生的数据价值并不匹配,但不开启索引又完全无法在需要的时候进行分析。因此如果要增效降本,则增效意味着要突破固定Schema的限制,降本意味着不要去依赖额外的派生数据。那么我们能否基于Schema-on-Read的理念,在原始数据上动态的应用Schema去完成分析需求呢?SLS Schema-on-Read SQL设计与实现需要解决的问题我们来重新审视下基于索引和列存的Schema-on-Write模式的SQL实现,这里面索引和列存实际上提供了两个作用:创建字段索引的时候,指定了有哪些字段,每个字段是什么类型,相当于创建了一个关系模型的Schema字段索引开启统计后,会为字段构建列存,从而在执行时可以高效读取指定列的数据相对应的,如果要实现Schema-on-Read,从技术实现角度至少需要解决以下两个问题:(1)没有Schema信息,SQL引擎怎么执行?SQL执行引擎都是遵从关系数据模型,一般都是遵从强Schema设计的,因此需要知道每张表有哪些列,每一列是什么类型。如果没有这些信息,SQL引擎将无法执行下去。(2)没有列存数据,怎么读取指定列的数据?只有原始的非结构化的行存数据,没有列存这种结构化的数据。需要从这种非结构化的行存数据中,提取出SQL分析需要的列的数据。关键点一:从SQL语句中自动推断schema首先针对第一个问题,没有Schema信息,SQL引擎如何执行?其实很多Schema-on-Read的实现是这样做的:写入的时候是直接写入存储,这一步不会对数据类型做太强的限制(比如HDFS或者对象存储),读取的时候需要先通过类似Create Table语句定义一个表(定义数据源、要分析的列的类型等等),然后基于这个表进行查询。不同的查询需求可以定义不同的表,这个也是符合Schema-on-Read的理念的。然而日志场景下我们对Schema这个问题有着进一步的思考:日志分析场景是非常强调交互式分析的灵活性的,试想一下,如果在每执行一条SQL语句对日志进行之前,都要先执行Create Table创建一个表,那么使用起来将会非常的不便捷在SLS的查询分析时,是指定了project和logstore信息的,也就是数据源的存储相关元信息已经知道了,唯一缺的只是列的信息(要分析哪些列,每个列是什么类型)而要分析哪些列,这个信息实际上是可以从SQL语句本身推断出来的,类型可以默认都为varchar(其他类型需要用cast语句转换下)因此我们的做法是:从用户输入的SQL语句中自动推断出所需要的Schema。这里举一个最简单的SQL语句的例子,Select id,login from users,这条SQL语句经过词法分析和语法分析后,会对原始语句进行分词并构建出一棵抽象语法树,语法树里面就隐含了这条语句需要什么样的Schema,比如对于刚才这条SQL语句,我们就可以从语法树中推断出,需要从users表里面,读取id和login这两列,类型分别是varchar,这就是这条语句所需要的schema信息。实际情况下这个推断过程需要考虑更多复杂的场景,比如多个表join、多级sql嵌套、表别名等等,但是基本原理上和上面这个例子是相同的,从语法树中提取出Table节点,以及与这个Table节点相关联的Identifier节点,然后组成对应的Schema信息。关键点二:从原始数据中自动构建列式数据第二个要解决的问题是在执行数据读取的过程中,如何从非结构化的原始数据中,匹配提取出结构化的数据来参与计算。这个过程在原理上是比较直观的,就是扫描原始数据,匹配提取出要读取的字段。在实现层面主要是要尽可能的提升这个扫描匹配过程的效率。举个例子,比如 select count(1) as pv, Api, Status group by Api, Status 这条SQL语句,前面在Schema推断的过程中,会推断出有一张临时表,表里面包含了Api、Status这两列的数据。在执行阶段,就需要从原始数据中,读取出只属于这两列的数据。后续的提取列式数据的过程是这样的:通过时间范围和索引查询条件,过滤出需要扫描的原始数据对分析的列名称求hash对待扫描的原始数据进行逐行逐字段扫描,计算字段hash并和待分析的列的hash进行比较,匹配上则提取出对应的字段值,未匹配上则填充null计算过程中优先比较上次匹配位置、记录已匹配情况提前终止等方式尽量减少循环次数此外,结合LRU缓存、亲和性调度等优化,尽可能减少重复扫描经过上述这些优化,可以有效的提高整个匹配过程的效率。以上就是SLS Schema-on-Read设计中两个关键问题的实现思路。SLS 扫描分析模式介绍扫描模式——SLS Schema-on-Read 查询/分析 解决方案SLS的Schema-on-Read查询分析方案,在产品功能上称为“扫描模式”(也称为“Scan模式”),以区别于Schema-on-Write的索引模式。扫描模式,顾名思义,不依赖于事先开启索引和列存,而是通过直接对原始数据进行“硬扫描”来进行相应的计算。扫描模式和索引模式等不同的计算模式,以及查询型、分析型、冷热存储等不同存储选型,共同组合成SLS计算存储引擎面向不同场景下多样化的解决方案。扫描查询与扫描分析的关系和索引模式相同,扫描模式也分为扫描查询和扫描分析,扫描查询先前已经发布,具体可以参考《聊聊日志硬扫描,阿里 Log Scan 的设计与实践》这篇文章。在介绍新推出的扫描分析能力之前,先看下扫描查询和扫描分析的联系和区别:语法上竖线前都是索引字段过滤,竖线后是对原始数据硬扫描,并按照实际扫描的数据量计费扫描查询竖线后是“where + 布尔表达式”,用来条件过滤;扫描分析竖线后是标准SQL,用来进行聚合、关联分析扫描查询是检索原文,可以不断的往后翻页;扫描分析一般是用来算聚合指标,是对范围内数据的一次性分析。扫描分析使用方式控制台开启扫描模式在控制台的“查询分析”右侧有一个“Scan扫描”的模式开关,打开这个开关表明开启 schema-on-read 能力(无需预先构建索引)打开这个扫描模式的开关后,在控制台直接写分析语句即可:扫描分析模式下,分析语句是标准SQL,和索引模式下完全相同扫描分析模式下,SQL语句里待分析的字段(如下图中的UserAgent字段)无须事先建立索引执行完扫描分析语句后,在柱状图的下方,可以看到一栏统计信息“分析模式:Scan模式”,这个表明当前工作在扫描模式下“扫描数据量”表示在执行这次SQL的过程中,实际扫描的原始数据量,将会按照这个数据量计费。注意扫描流量是针对在查询条件过滤之后的数据计算。以上面这个截图中的例子,比如最近4小时是有1亿条数据,经过查询条件“Method:Head and Status:500”(Method字段和Status字段开启了索引)过滤后有600万条,则扫描过程是针对这600万条数据进行的,扫描数据量是统计的实际扫描的数据量大小。通过Session参数开启扫描模式更通用的开启扫描模式的做法是,在分析语句上设置Session参数set session mode=scan,比如 “* | set session mode=scan; select api, count(1) as pv group by api”值得一提的是,对于SDK调用、告警、仪表盘、定时SQL等所有可以写sql语句的地方,都可以通过添加这个session参数来开启扫描模式。没有银弹——扫描模式分析能力是有限的在数据分析中,性能、成本、灵活性是一组“不可能三角”,Schema-on-Read分析模式增加了灵活性,降低了成本,则必然要付出性能上的牺牲。影响扫描分析模式的性能的因素具体体现在:读原始数据的IO放大索引模式下只需要从磁盘单独读取指定列的列存数据,而扫描模式下需要读取所有的原始数据,此外列存数据的压缩比也远远优于原始行存数据。这个是影响性能的主要部分。具体则取决于扫描行数、单条日志数据量大小、被扫描的数据分布(是连续的数据,还是通过索引条件过滤后的稀疏数据)等因素。额外的扫描时间扫描模式下还需要对读取的原始数据进行遍历,匹配提取待分析的字段,这个过程也需要消耗一定的计算时间。为了保证最终的执行时间在一个可接受的范围内,目前扫描分析模式下会对扫描行数进行限制(单次sql,限制单个shard扫描50w行,总扫描行数1000w行),超出这个限制会返回不精确的部分计算结果。SLS 扫描分析模式典型使用场景情形一:Schema不确定的场景扫描模式最重要的场景就是应对Schema无法事先确定(从而无法事先创建好对应的字段索引)的情况,这在日志场景中是非常常见的,比如:同一个logstore采集多个服务的日志,不同的采集配置对应的字段不完全相同日志文件本身是自描述的,比如通过logtail采集JSON格式日志,或者通过OSS导入CSV格式文件,这些格式下字段都是可变的通过sdk写入日志,根据运行时情况动态写入不同的日志字段通过数据加工或者定时SQL写入的日志数据,加工程序进行了修改导致字段发生变化等等在固定Schema的索引模式下,对于这类情况比较难解决:要么就是发生变化后去修改索引(问题:运维成本太高,而且可能字段太多无法都创建索引)要么只能通过一定的约束保证产生的日志字段都相同(问题:可能无法做到)要么通过数据加工进行二次加工清洗(问题:数据链路变长,额外的加工和存储成本)或者甚至干脆用一个字段去存储所有数据,然后用sql里的json函数或者正则函数去提取需要分析的字段(问题:日志不便于阅读、单条长度可能超过字段索引长度限制、sql书写复杂、sql执行时每一条都要执行提取函数性能差)有了扫描模式之后,可以只对高频出现的固定字段建立索引(如果不考虑成本因素也可以直接建立全文索引),对于长尾的可变字段不需要建立字段索引,从而不需要疲于应对schema的不断变化。一个JSON格式日志的例子举个开发同学比较熟悉的例子,比如go语言中常用的Zap Logger,输出到日志文件中是打印成json形式,程序中可以通过zap.XXX添加json日志中的一个字段。比如A同学通过这行代码,记录访问db的延时情况:logger.Info( "request-db", zap.Int("latency", latency), zap.String("url", url))打印出的日志是下面这样的json格式(略去时间戳、日志级别等公共字段),通过JSON模式采集到sls之后,在logstore就会新出现了status、url等新增字段,索引模式下为了能够分析这两个字段,需要给这两个字段增加索引。{ "msg":"request-db", "latency": 103, "url": "mock.host" }接下来B同学在增加了这样的日志,来记录某个临时增加的活动里发放的优惠券的类型和金额信息,这里面又新增了两个字段,有需要新增索引logger.Info( "discount", zap.Int("type", discountType), zap.String("amount", amount))由于每个开发同学都可能会在代码中根据当前上下文打印自己需要的字段,其结果是最终日志文件中的可能的字段非常多,无法一一建立索引进行分析。为了解决这个问题,往往不得不要求开发同学将所有信息都打在公共的msg字段里,再只对msg字段建立索引并开启统计。然而这样实际上丢失了json格式的优势,分析的时候也非常不方便。而在有了扫描分析模式之后,对于上面这种json日志格式,可以只对固定的msg字段开启索引,其他可变字段无须开启索引。现在比如A同学要分析“访问不同的db的平均延时的分布”,就可以这样直接通过扫描模式进行sql分析:msg:request-db | set session mode=scan; select avg(cast(latency as bigint)) as avgLatency, url group by url order by avgLatency desc注意:扫描分析模式下字段被视为varchar,因此对这里的latency要转为数字类型才能求均值而B同学要去分析“不同类型的优惠券分别发放了多少金额”,则可以这样进行分析:msg:discount-coupon | set session mode=scan; select sum(cast(amount as bigint)) as totalAmount, type group by type order by totalAmount desc这样打印日志的时候,就可以针对特定场景自由的新增自己关心的特征日志用于分析,而不需要考虑索引的变更。在分析的时候,先用相应的关键词过滤下(过滤后保证日志都是符合要分析的结构的,而且降低了扫描规模),然后再使用扫描分析模式,就可以直接通过sql分析自定义的指标字段。情形二:写多读少的降本场景对于写多读少的场景,如果日志的业务方经过分析,认为有部分字段不需要高频的查询分析,没有必要对100%的日志开启索引,希望合理降低日志的成本。这里举个参考的例子,做如下假设:每条日志大小是1KB,有10个字段,分别是 key_0/key_1/.../key_9,其中 key_0/key_1 是被经常会被查询分析,其余8个字段很少使用。每天日志量10亿条(即1TB),存储周期7天,日志压缩率按6计算对key_2/.../key_9这8个低频字段的分析频次是每天200次,每次分析时需要扫描500万条日志(即5GB)按照现有中国站列表价简单估算如下:100%索引 方案20%索引 + 扫描 方案写入流量(压缩后)0.17 TB/天0.17 TB/天索引流量1TB/天0.2 TB/天索引存储量7TB1.4 TB原文存储量(压缩后)1.17 TB1.17 TB扫描流量01 TB/天单日费用¥486¥183按照上述例子的假设,使用扫描模式可以有效降低使用成本。不过实际评估的时候还需要注意:扫描模式能够分析的数据量有限,耗时也会上升,需要评估业务上能否接受扫描分析模式下评估成本的关键因素是扫描流量。上述例子中每天1TB的扫描流量的费用是51.2元,对于不同的分析qps、单次扫描数日志行数、单条日志大小,最后算出来的扫描流量费用的差别会很大,需要根据实际业务情况评估。如果需要分析的频次比较高并扫描的数据量大,建议还是使用索引模式。情形三:无法使用索引分析模式的场景由于扫描分析模式只依赖原始数据,不依赖索引或者列存等任何派生数据,因此对于下面这些无法使用索引模式的场景,可以使用扫描模式进行分析。查询型Logstore/Lite版本查询型Logstore(也称为Lite版本)的定位是支持需要低成本长期存储、只需要查询不需要分析的场景,索引流量费用更低,但不支持SQL分析(无法为索引字段勾选“开启统计”)。新推出的扫描分析模式也可以在查询型Logstore上进行使用,从而具备一定的分析能力。无索引的历史数据某些情况下可能想分析的字段没有索引,比如:创建Logstore后没有及时开启索引配置,但是数据已经写入了Schema发生变化后没有及时更新索引配置,因此新增的字段就没有索引目前SLS有对历史数据重建索引的功能,但是只能重建30天内的数据。对于超过30天的历史数据,之前的解法是使用数据加工写到一个新的创建好索引的logstore中,这种做法可以彻底解决问题,但会增加额外的成本。现在有了扫描模式之后,如果历史数据需要分析的数据规模不大(或者可以通过已有的索引字段进行辅助过滤),则可以使用扫描模式对历史数据直接进行分析。日志字段超出索引长度限制索引分析模式下,字段能够被分析最大长度有限制(默认2k,可以调整配置,最大调整到16k),背后的原因是因为构建列存的时候,对列存的单条记录的长度有限制。超出长度的部分被截断,无法参与分析。在扫描分析模式下,因为是直接对原始数据进行扫描的,对于这种字段长度超出限制的场景,可以使用扫描分析模式进行分析。不过需要注意的是,无论是从分析性能还是灵活性上考虑,日志采集的最佳实践仍然是尽可能的按照日志结构对字段进行拆分,而不是都放在一个字段里。正如前面JSON日志的例子中指出的,可以对固定的、需要高频分析的字段建立索引并开启统计,对低频的、可变的字段使用扫描模式。总结与展望本文首先简要回顾了大数据技术的发展历程中,SQL逐渐成为数据分析领域的通用语言,而由于关系模型对Schema的约束,催生了Schema-on-Read概念的兴起。日志数据天然是弱Schema的,为了能够支持SQL,现有的做法是先定义好Schema,从原始非结构化数据中冗余一份结构化的符合关系模型的数据(列存),但这样又带来了灵活性不够和成本上升的问题。为了满足增效降本的诉求,SLS基于Schma-on-Read的理念,推出了扫描分析模式,无须在查询前先声明Schema,直接书写SQL语句即可在原始的非结构化数据上进行计算分析,在Schema多变的场景下能够体现出极大的灵活性,同时也能用于写多读少场景下的降本。同时也正如文中数次提到的,扫描模式不是银弹,并不能取代索引模式。在分析的数据规模大、分析频次高、分析性能有要求的场景下仍然需要首选索引模式,扫描模式则主要面向中小数据规模、Schema不确定、写多读少以及一些无法使用索引分析的场景。扫描查询/分析模式是SLS在Schema-on-Read分析场景的探索,未来将继续结合存储模式的演进、计算性能的提升,进一步大幅增强Schema-on-Read计算分析模式的性能和灵活性。目前扫描分析模式已经在各个地域陆续发布上线,欢迎参考官方文档进行体验使用。参考1、Demystifying Data Explorer:https://techcommunity.microsoft.com/t5/azure-synapse-analytics-blog/demystifying-data-explorer/ba-p/36361912、MapReduce: A major step backwards:https://homes.cs.washington.edu/~billhowe/mapreduce_a_major_step_backwards.html3、Designing Data Intensive Applications:https://www.oreilly.com/library/view/designing-data-intensive-applications/9781491903063/4、Why SQL is beating NoSQL and what this means for the future of data:https://www.timescale.com/blog/why-sql-beating-nosql-what-this-means-for-future-of-data-time-series-database-348b777b847a/5、Spanner: Becoming a SQL System:https://static.googleusercontent.com/media/research.google.com/zh-CN//pubs/archive/46103.pdf6、大数据技术发展简史:https://cloud.tencent.com/developer/article/16404057、扫描分析模式概述:https://help.aliyun.com/document_detail/473045.html8、标准型与查询型Logstore介绍:https://help.aliyun.com/document_detail/48990.html
文章
SQL  ·  存储  ·  JSON  ·  分布式计算  ·  大数据  ·  分布式数据库  ·  API  ·  数据库  ·  数据格式  ·  索引
2023-03-15
实践教程之PolarDB-X replica原理和使用
PolarDB-X 为了方便用户体验,提供了免费的实验环境,您可以在实验环境里体验 PolarDB-X 的安装部署和各种内核特性。除了免费的实验,PolarDB-X 也提供免费的视频课程,手把手教你玩转 PolarDB-X 分布式数据库。本期实验将指导您关于PolarDB-X replica的原理和使用本期免费实验地址本期教学视频地址前置准备假设已经根据前一讲内容完成了PolarDB-X的搭建部署,可以成功链接上PolarDB-X数据库。PolarDB-X作为MySQL的备库本步骤将指导您如何使用PolarDB-X作为MySQL的备库。1.建立复制链路。a.切换至终端一,执行如下命令,登录云服务器ECS_1实例中的MySQL说明:您需要将如下命令中的替换为云服务器ECS_1实例上的MySQL 8.0的初始密码。mysql -uroot -p<PASSWORD>b.执行如下命令,修改MySQL的root用户的初始密码为Aliyun123!ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'Aliyun123!';c.执行如下命令,修改root用户的登录ip白名单update mysql.user set host='%' where user='root';d.执行如下命令,使权限相关修改生效FLUSH PRIVILEGES;e.执行如下命令,查看最新的binlog File和binlog Positionshow master status;返回结果如下,您可查看到源库的binlog File和binlog Position。f.切换至终端二,执行PolarDB-X安装完成后输出的的连接方式,登录PolarDB-X。例如mysql -h127.0.0.1 -P7148 -upolardbx_root -pXXXXXX。g.执行如下命令,修改相关参数,建立复制链路。说明:如果您想了解更多有关MySQL Replica用法,详情请参考MySQL官方文档。参数说明:MASTER_HOST:源库主机的主机名(或 IP 地址),本示例填写为云服务器ECS_1实例的私有地址。MASTER_USER:源库的用户名,本示例填写为云服务器ECS_1实例的MySQL数据库的用户名root。MASTER_PASSWORD:源库的密码,本示例填写为Aliyun123!。MASTER_PORT:源库主机的TCP/IP端口,本示例填写为3306。MASTER_LOG_FILE:复制I/O线程应该在下次线程启动时开始从源库读取的坐标,填写为上一步中从源库获取的binlog File。MASTER_LOG_POS:复制I/O线程应该在下次线程启动时开始从源库读取的坐标,填写为上一步中从源库获取的binlog Postion。SOURCE_HOST_TYPE:源库类型,本示例填写为mysql。CHANGE MASTER TO MASTER_HOST='云服务器ECS_1实例的私有地址', MASTER_USER='root', MASTER_PASSWORD='Aliyun123!', MASTER_PORT=3306, MASTER_LOG_FILE='binlog File', MASTER_LOG_POS=binlog Postion, SOURCE_HOST_TYPE=mysql FOR CHANNEL 'mysql';h.执行如下命令,仅复制新添加的库。由于主备是异构系统,我们将复制仅限于我们即将要新添加的库,防止主库的心跳等数据影响同步。CHANGE REPLICATION FILTER replicate_do_db=(rpl) for channel 'mysql';i.执行如下命令,查看链路状态。SHOW SLAVE STATUS\Gj.执行如下命令,启动链路。START SLAVE;2.在master上执行DDL和DML语句。a.切换至终端一,执行如下命令,创建数据库rpl。create database rpl;b.执行如下命令,使用数据库rpl。use rpl;c.执行如下命令,创建表example。create table example ( `id` bigint(11) auto_increment NOT NULL, `name` varchar(255) DEFAULT NULL, `score` bigint(11) DEFAULT NULL, primary key (`id`) ) engine=InnoDB default charset=utf8;d.执行如下命令,插入数据。insert into example values(null,'lily',375),(null,'lisa',400),(null,'ljh',500);e.执行如下命令,查看数据。select * from example;返回结果如下,您可以看到源库中有三条数据。3.在slave上查看数据a.切换至终端二,执行如下命令,使用数据库rpl。use rpl;b.执行如下命令,查询数据库rpl中的表。show tables;c.执行如下命令,查看数据。select * from example;返回结果如下,你可查看到源库中的数据已经同步到备库中。4.暂停并删除测试链路a.执行如下命令,暂停链路。STOP SLAVE;b.执行如下命令,删除链路。RESET SLAVE ALL;c.执行如下命令,查看链路是否删除。SHOW SLAVE STATUS;使用PolarDB-X作为PolarDB-X的备库本步骤将指导您如何使用一个PolarDB-X集群作为另外一个PolarDB-X集群的备库,并进行TPC-C测试。1.建立复制链路。a.切换至终端三,执行PolarDB-X安装完成后输出的的连接方式,登录PolarDB-X。例如mysql -h127.0.0.1 -P7148 -upolardbx_root -pXXXXXX。b.执行如下命令,查看最新的binlog position。SHOW MASTER STATUS\G返回结果如下,您可查看到源库的binlog File和binlog Position。c.切换至终端二,执行如下命令,修改相关参数,建立复制链路。说明:如果您想了解更多有关MySQL Replica用法,详情请参考MySQL官方文档。参数说明:MASTER_HOST:源库主机的主机名(或 IP 地址),本示例填写为云服务器ECS_3实例的私有地址。MASTER_USER:源库的用户名,本示例填写为polardbx_root。MASTER_PASSWORD:源库的密码,本示例填写为云服务器ECS_3实例的PolarDB-X集群的密码。MASTER_PORT:源库主机的TCP/IP端口,本示例填写为云服务器ECS_3实例的PolarDB-X集群的端口号。MASTER_LOG_FILE:复制I/O线程应该在下次线程启动时开始从源库读取的坐标,填写为上一步中从源库获取的binlog File。MASTER_LOG_POS:复制I/O线程应该在下次线程启动时开始从源库读取的坐标,填写为上一步中从源库获取的binlog Postion。SOURCE_HOST_TYPE:源库类型,本示例填写为polardbx。CHANGE MASTER TO MASTER_HOST='云服务器ECS_3实例的私有地址', MASTER_USER='polardbx_root', MASTER_PASSWORD='云服务器ECS_3实例的PolarDB-X集群的密码', MASTER_PORT=云服务器ECS_3实例的PolarDB-X集群的端口号, MASTER_LOG_FILE='binlog File', MASTER_LOG_POS=binlog Postion, SOURCE_HOST_TYPE=polardbx FOR CHANNEL 'tpcc';d.执行如下命令,仅复制新添加的库。由于主备是异构系统,我们将复制仅限于我们即将要新添加的库,防止主库的心跳等数据影响同步CHANGE REPLICATION FILTER replicate_do_db=(tpcc);e.执行如下命令,启动链路。START SLAVE;f.执行如下命令,查看链路状态SHOW SLAVE STATUS\G2.TPC-C测试。a.首先请阅读PolarDB-X TPCC测试指南,了解PolarDB TPCC测试。b.切换至终端三,执行如下命令,创建数据库tpcc。create database tpcc;c.输入exit退出PolarDB-X集群。d.执行如下命令,使用yum安装JDK 1.8。yum -y install java-1.8.0-openjdk*e.执行如下命令,下载压测工具包。wget https://static-aliyun-doc.oss-cn-hangzhou.aliyuncs.com/file-manage-files/zh-CN/40302202/cgaj_benchmarksql.tar.gzf.执行如下命令,解压压测工具包。tar xzvf cgaj_benchmarksql.tar.gzg.执行如下命令,编辑props.mysql配置文件。填入对应的PolarDB-X实例连接信息。cd benchmarksql/run vim props.mysqlh.在props.mysql配置文件中,按下i键进入编辑模式,修改如下参数后,按下Esc键后,输入:wq后按下Enter键保存并退出。参数说明:HOST:主机名{HOST},在本示例中您需要将{HOST}替换为127.0.0.1。PORT:端口号{PORT},在本示例中您需要将{PORT}替换为云服务器ECS_3实例的PolarDB-X集群的端口号。user:用户名,在本示例中您需要将{USER}替换为云服务器ECS_3实例的PolarDB-X集群的用户名polardbx_root。password:密码,在本示例中您需要将{PASSWORD}替换为云服务器ECS_3实例的PolarDB-X的密码。warehouses:仓库数,在本示例中填写为1。loadWorkers:导入数据并发数,在本示例中填写为1。terminals:压测并发数,在本示例中填写为5。runMins:压测时间,在本示例中填写为2。i.执行如下命令,导入压测数据。nohup ./runDatabaseBuild.sh props.mysql &j.按下Ctrl+C键后,执行如下命令,查看导入压测数据的日志。cat nohup.out请您等待大约3分钟,压测数据的日志返回结果如下,表示压测数据已成功导入。说明:由于实验室资源有限,此处使用非生产规格实例,导致压测数据导入速度较慢。k.执行云服务器ECS_3实例的PolarDB-X集群安装完成后输出的的连接方式,登录PolarDB-X。例如mysql -h127.0.0.1 -P7148 -upolardbx_root -pXXXXXX。l.待数据导入完毕后,执行如下命令,在源端验证压测数据的完整性。use tpcc; select a.* from (Select w_id, w_ytd from bmsql_warehouse) a left join (select d_w_id, sum(d_ytd) as d_ytd_sum from bmsql_district group by d_w_id) b on a.w_id = b.d_w_id and a.w_ytd = b.d_ytd_sum where b.d_w_id is null; select a.* from (Select d_w_id, d_id, D_NEXT_O_ID - 1 as d_n_o_id from bmsql_district) a left join (select o_w_id, o_d_id, max(o_id) as o_id_max from bmsql_oorder group by o_w_id, o_d_id) b on a.d_w_id = b.o_w_id and a.d_id = b.o_d_id and a.d_n_o_id = b.o_id_max where b.o_w_id is null; select a.* from (Select d_w_id, d_id, D_NEXT_O_ID - 1 as d_n_o_id from bmsql_district) a left join (select no_w_id, no_d_id, max(no_o_id) as no_id_max from bmsql_new_order group by no_w_id, no_d_id) b on a.d_w_id = b.no_w_id and a.d_id = b.no_d_id and a.d_n_o_id = b.no_id_max where b.no_id_max is null; select * from (select (count(no_o_id)-(max(no_o_id)-min(no_o_id)+1)) as diff from bmsql_new_order group by no_w_id, no_d_id) a where diff != 0; select a.* from (select o_w_id, o_d_id, sum(o_ol_cnt) as o_ol_cnt_cnt from bmsql_oorder group by o_w_id, o_d_id) a left join (select ol_w_id, ol_d_id, count(ol_o_id) as ol_o_id_cnt from bmsql_order_line group by ol_w_id, ol_d_id) b on a.o_w_id = b.ol_w_id and a.o_d_id = b.ol_d_id and a.o_ol_cnt_cnt = b.ol_o_id_cnt where b.ol_w_id is null; select a.* from (select d_w_id, sum(d_ytd) as d_ytd_sum from bmsql_district group by d_w_id) a left join (Select w_id, w_ytd from bmsql_warehouse) b on a.d_w_id = b.w_id and a.d_ytd_sum = b.w_ytd where b.w_id is null;返回结果如下,若结果集均为空,则证明压测数据完整。m.输入exit退出PolarDB-X集群。n.执行如下命令,运行TPC-C测试。./runBenchmark.sh props.mysql返回结果如下,运行TPC-C测试后可以您看到实时的tpmC数值。请您耐心等待2分钟后,运行结束后会显示平均的tpmC数值。o.执行云服务器ECS_3实例的PolarDB-X集群安装完成后输出的的连接方式,登录PolarDB-X。例如mysql -h127.0.0.1 -P7148 -upolardbx_root -pXXXXXX。p.分别在终端二和终端三的PolarDB-X集群中执行如下命令,进行数据校验。use tpcc; select bit_xor(crc32(CONCAT_WS(',', `ol_w_id`, `ol_d_id`, `ol_o_id`, `ol_number`, `ol_i_id`, `ol_amount`, `ol_supply_w_id`, `ol_quantity`, `ol_dist_info`))) as checksum from bmsql_order_line;当源端和目标端返回结果一致时,表示两侧数据一致,replica保证了数据的一致性。清理现场和回收资源本步骤将指导您如何停止MySQL服务和删除PolarDB-X集群。说明:在云起实验室中您可直接在页面右上角单击结束实验,即可释放所有资源。1.停止MySQL服务。a.切换至终端一,输入exit退出MySQL数据库。b.执行如下命令,停止MySQL服务。systemctl stop mysqld.service2.删除PolarDB-X集群。a.切换至终端二,输入exit退出PolarDB-X集群。b.执行如下命令,删除云服务器ECS_2实例的PolarDB-X集群。pxd cleanupc.切换至终端三,输入exit退出PolarDB-X集群。d.执行如下命令,删除云服务器ECS_3实例的PolarDB-X集群。pxd cleanup本文来源:PolarDB-X知乎号
文章
SQL  ·  网络协议  ·  关系型数据库  ·  MySQL  ·  Java  ·  测试技术  ·  分布式数据库  ·  数据库  ·  数据安全/隐私保护  ·  UED
2023-03-14
设计模式:使用状态模式推动业务全生命周期的流转
1.业务背景本文借助海外互金业务的借款流程展开。业务核心是借款的生命周期,相当于是电商中的订单一样。一笔借款的整个生命周期包含了提交,审批,确认,放款,还款。一笔借款的状态对应已上的操作,同样就很多了。如图是一笔借款的生命周期:对于这么多状态,业务代码上有很多判断分支,什么情况下可以做什么操作,这是强校验的。业务初期快速上线,if else可以快速地将业务串联起来,在各个业务处理的节点判断并执行业务逻辑。 if (LoanStatusConstant.CREATED.equals(oldStatus) ||      LoanStatusConstant.NEED_MORE_INFO.equals(oldStatus)) {      log.info("---> Loan to Submitted");  }  ​  if (LoanStatusConstant.APPROVED.equals(loan.getStatus())) {      log.info("---> Loan confirmed");  }  ​  if (!LoanStatusConstant.APPROVED.equals(loanStatus)) {      log.info("---> Loan approved,to Fund");  }  //.......2.业务代码的“坏味道”随着运营推广的力度加大,用户蜂拥而至,风控环境更加严苛,整个产品线也陆续加入了更多的流程去迭代产品,让风险和收益能趋于平衡。这时候在开发代码上的体现就是代码库急剧膨胀,业务扩张自然会扩招,新同事也会在已有的代码上打补丁,在这些补丁式的需求下,曾经的if else会指数级的混乱,一个简单的需求都可能挑战现有的状态分支。这种“坏味道”不加以干预,整个项目的生命力直接走向暮年。 //审核  public void approve(@RequestBody LoanSubmitDTO submitDTO) {      final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, submitDTO.getLoanRefId()));      if (Objects.isNull(loan)){          throw new BaseBizException("loan Not exists");     }      if (!SUBMITTED.getCode().equals(loan.getStatus())){          throw new BaseBizException("loan status incorrect");     }  ​      loan.setStatus(APPROVED.getCode());      loanMapper.updateById(loan);  }  //确认  public LoanDTO submit(LoanSubmitDTO submitDTO) {      final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, submitDTO.getLoanRefId()));      if (Objects.isNull(loan)){          throw new BaseBizException("loan Not exists");     }      if (!CREATED.getCode().equals(loan.getStatus())){          throw new BaseBizException("loan status incorrect");     }  ​      loan.setStatus(SUBMITTED.getCode());      loanMapper.updateById(loan);      riskService.callRisk(submitDTO);  ​      return BeanConvertUtil.map(loan,LoanDTO.class);  }随着项目不断膨胀,为了对贷款状态进行校验,if else会充斥业务层的各个地方,此时,一旦产品上对业务流程进行调整,状态也会随着修改。比如新增了风控确认机制,用户可以补充信息再提交,对于满足一定条件的贷款可以让用户补充一定的风控信息再提交,那么此时对于借款提交流程的前置状态就要发生变化了: //提交  public LoanDTO submit(LoanSubmitDTO submitDTO) {      final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, submitDTO.getLoanRefId()));      if (Objects.isNull(loan)){          throw new BaseBizException("loan Not exists");     }      //判断条件发生变化。      if (!CREATED.getCode().equals(loan.getStatus())&&!NEED_MORE_INFO.getCode().equals(loan.getStatus())){          throw new BaseBizException("loan status incorrect");     }  ​      loan.setStatus(SUBMITTED.getCode());      loanMapper.updateById(loan);      riskService.callRisk(submitDTO);  ​      return BeanConvertUtil.map(loan,LoanDTO.class);  }项目迭代过程中每一次上线前测试同学都会进行严格地测试。以上这种变动可能会修改多个地方的代码,测试同学就不得不进行大面积的回归测试,上线风险会大大增加;而我们开发同学这种新逻辑上线就硬改原有代码的行为,违背了开闭原则,随着业务的迭代,项目代码的可读性会越来越差(if-else越来越多,测试用例不清晰),可能很多人觉得就改了个判断语句没什么大不了的,但实际上很多生产事故都是因为这种频繁的小改动导致的。如何去规避已上这种 “坏味道” 呢?3.OCP原则(开放封闭原则)3.1 定义我们先来看看什么是【开放封闭原则】:Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.--Bertrand Meyer翻译过来的意思是:一个软件实体(类、模块、函数等)应对扩展开放,但对修改封闭。也就是说当我们设计一个模块,一个实体对象时,应该在不修改自身源代码的情况下,能够扩展新的行为。以上这句话读完,不能修改和能够扩展是自相矛盾的,如何才能实现并满足开闭原则呢?抽象化是关键,我们抽象出共性的基类,并且通过基类衍生出来的实现类去实现新行为的扩展。【抽象层保持不变,实现层实现扩展】。3.2 对比说明举个简单的例子来看,我们要做支付,支付方式必须要支持微信支付,支付宝支付等多种支付方式。此时我们的类设计如下:此时产品要接入新的支付方式,要支持银联支付,我们就不得不去修改switch语句块的逻辑,新增银联支付方式,并新增银联支付的channel类。这就破坏了开闭原则。其实开闭原则是我们做面向对象开发的基础原则,所有导致原有代码修改的行为都会破坏开闭原则。这就考验我们在对象设计时的要做到敏锐性和合理性,及时发现关键领域中具有相同行为的对象,使用抽象类或者接口,将相同的行为抽象到抽象类或者接口中,将不同的行为封装到子类实现类上面。这样处理之后,系统需要扩展功能时,我们只要扩展新的子类就可以。对于子类的修改我们也可以重新实现一个新的子类。比如,我们把所有的支付渠道抽象出统一的支付行为,并且针对不同的支付渠道去扩展不同的子类,并通过工厂模式去管理所有的支付渠道,把支付渠道的选择逻辑也封装到工厂中去,此时对于PayService这类业务对象来说,就不会因为支付方式的修改而去变动代码。这样就做到了,对扩展开放,对对更改关闭。此时的类设计如下:4.状态模式4.1 定义Allow an object to alter its behavior when its internal state changes.The object will appear to change its class.  ——《设计模式:可复用面向对象软件的基础》状态模式(State Pattern):状态模式是一个行为型设计模式。允许一个对象在其内部状态改变时改变它的行为。该对象将看起来改变了它的类。状态模式的使用场景:用于解决系统中复杂对象的状态转换以及不同状态下行为的封装问题。对有状态的对象,把复杂多样的状态从对象中抽离出来,封装到专门的状态类中,这样就可以让对象的状态灵活变化。4.2 UML图状态模式的参与者:环境类Context角色:可以认为是状态上下文,内部维护了对象当前的ConcreteState,对客户方提供接口,可以负责具体状态的切换。抽象状态State角色:这是一个接口,用来封装环境类对象中的一个特定状态相关的行为。具体状态ConcreteState角色:是State的子类实现类,具体状态子类,每一个子类都封装了一个ConcreteState相关的行为。4.3 简单示例抽象状态类: public abstract class State {  ​      protected void a2b(Context context) {          throw new BaseBizException(context.getState().getClass().getSimpleName()+" not support this transition to B state" );     }  ​      protected void b2c(Context context) {          throw new BaseBizException(context.getState().getClass().getSimpleName()+" not support this transition to C state" );     }  ​      protected void a2c(Context context) {          throw new BaseBizException("current state is "+context.getState().getClass().getSimpleName()+" not support transition" );     }  }具体状态类: public class AState extends State {  ​  ​      @Override      protected void a2b(Context context) {          System.out.println("状态流转:a State ->b State");          context.setState(new BState());     }  ​      @Override      protected void a2c(Context context) {          System.out.println("状态流转:a State ->c State");          context.setState(new BState());     }  }  ​  public class BState extends State {  ​      @Override      protected void b2c(Context context) {          System.out.println("状态流转:b State ->c State");          context.setState(new CState());     }  }  public class CState extends State {  ​  }环境上下文类: public class Context {      private State state;  ​      public Context(State initState) {          this.state = initState;     }      public State getState() {          return state;     }      public void setState(State state) {          this.state = state;     }      public void a2b() {          state.a2b(this);     }      public void b2c() {          state.b2c(this);     }      public void a2c() {          state.a2c(this);     }  }测试类: public class ClientInvoker {  ​      public static void main(String[] args) {          Context context = new Context(StateFactory.getState(AState.class.getSimpleName()));          context.a2b();          context.a2c();     }  }测试结果: 状态流转:a State ->b State  Exception in thread "main" cn.ev.common.Exception.BaseBizException: current state is BState not support transition   at state.State.a2c(State.java:16)   at state.Context.a2c(Context.java:22)   at state.ClientInvoker.main(ClientInvoker.java:8)4.4 状态模式的使用场景和优缺点使用场景:对象有多个状态,并且不同状态需要处理不同的行为。对象需要根据自身变量的当前值改变行为,不期望使用大量 if-else 语句。对于某些确定的状态和行为,不想使用重复代码。优点:符合开闭原则,可以方便地扩展新的状态和对应的行为,只需要改变对象状态即可改变对象的行为。状态转换逻辑与状态对象合成一体,避免了大量的分支判断语句和超大的条件语句块。缺点:一个状态一个子类,增加了系统类和对象的个数。如果使用不当将导致程序结构和代码的混乱。一定程度上满足了开闭原则,不过对于控制状态流转的职责类,添加新的状态类需要修改。5.优化借款流程5.1 抽象状态类首先我们定义抽象状态类AbstractLoanState: public  abstract class AbstractLoanState {  ​      @Resource      protected LoanMapper loanMapper;      /**       * 获取State       * @return       */      abstract Integer getState();      protected Loan getLoanDTO(String loanRefId) {          final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, loanRefId));          if (Objects.isNull(loan)){              throw new BaseBizException("loan Not exists");         }else {  ​              return loan;         }     }      /**       * 借款提交       * @param loanRefId 借款id       * @param currentState 当前状态       * @return       */      protected  LoanDTO submit(String loanRefId, Enum<LoanStateEnum> currentState) {          throw new BaseBizException("状态不正确,请勿操作");     }      /**       * 借款审批通过       * @param loanRefId 借款id       * @param currentState 当前状态       * @return       */      protected LoanDTO approve(String loanRefId, Enum<LoanStateEnum> currentState) {          throw new BaseBizException("状态不正确,请勿操作");     }      /**       * 借款确认       * @param loanRefId 借款id       * @param currentState 当前状态       * @return       */      protected LoanDTO confirm(String loanRefId, Enum<LoanStateEnum> currentState){          throw new BaseBizException("状态不正确,请勿操作");     }      /**       * 借款审批拒绝       * @param loanRefId 借款id       * @param currentState 当前状态       * @return       */      protected LoanDTO reject(String loanRefId, Enum<LoanStateEnum> currentState){          throw new BaseBizException("状态不正确,请勿操作");     }      /**       * 放款       * @param loanRefId 借款id       * @param currentState 当前状态       * @return       */      protected LoanDTO tofund(String loanRefId, Enum<LoanStateEnum> currentState) {          throw new BaseBizException("状态不正确,请勿操作");     }      /**       * 还款       * @param loanRefId 借款id       * @param currentState 当前状态       * @return       */      protected LoanDTO repay(String loanRefId, Enum<LoanStateEnum> currentState) {          throw new BaseBizException("状态不正确,请勿操作");     }      /**       * 补充信息       * @param loanRefId 借款id       * @param currentState 当前状态       * @return       */      protected LoanDTO needMoreInfo(String loanRefId, Enum<LoanStateEnum> currentState) {          throw new BaseBizException("状态不正确,请勿操作");     }  }5.2 具体状态类定义具体的状态类,每种不同的状态我们都依次定义专属的状态类,并赋予它特有的行为。例如LoanSubmitState: @Component  @Slf4j  public class LoanSubmitState extends AbstractLoanState{  ​      @Override      Integer getState() {          return SUBMITTED.getCode();     }  ​      @Override      protected LoanDTO approve(String loanRefId, Enum<LoanStateEnum> currentState) {          final Loan loan = getLoanDTO(loanRefId);          loan.setStatus(APPROVED.getCode());          loanMapper.updateById(loan);          log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,APPROVED);          return BeanConvertUtil.map(loan,LoanDTO.class);     }  ​      @Override      protected LoanDTO reject(String loanRefId, Enum<LoanStateEnum> currentState) {          final Loan loan = getLoanDTO(loanRefId);          loan.setStatus(REJECTED.getCode());          loanMapper.updateById(loan);          log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,REJECTED);          return BeanConvertUtil.map(loan,LoanDTO.class);     }  ​      @Override      protected LoanDTO needMoreInfo(String loanRefId, Enum<LoanStateEnum> currentState) {          final Loan loan = getLoanDTO(loanRefId);          loan.setStatus(NEED_MORE_INFO.getCode());          loanMapper.updateById(loan);          log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,NEED_MORE_INFO);          return BeanConvertUtil.map(loan,LoanDTO.class);     }  }LoanConfirmState,通过这些独立的状态类,我们可以做到避免写大块的if-else语句,避免在业务的多个角落去维护这些分支语句。 @Component  @Slf4j  public class LoanConfirmState extends AbstractLoanState{      @Resource      private FundService fundService;  ​      @Override      Integer getState() {          return CONFIRMED.getCode();     }  ​      @Override      protected LoanDTO tofund(String loanRefId, Enum<LoanStateEnum> currentState) {          final Loan loan = getLoanDTO(loanRefId);          loan.setStatus(FUNDED.getCode());          loanMapper.updateById(loan);          final LoanFundDTO loanFundDTO = BeanConvertUtil.map(loan, LoanFundDTO.class);          fundService.sendMoney(loanFundDTO);  ​          log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,FUNDED);          return BeanConvertUtil.map(loan,LoanDTO.class);     }  }5.3 环境上下文类LoanStatusHandler封装了借款生命周期中的所有操作接口,对于外部客户调用方只需要和他交互就可以对借款状态进行流转,完全不需要和具体的状态类进行耦合,这样对于后期的状态类扩展就很方便了。 @Service  public class LoanStatusHandler {  ​  ​      @Resource      protected LoanMapper loanMapper;      public Loan getLoan(String loanRefId) {          final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, loanRefId));          if (Objects.isNull(loan)){              throw new BaseBizException("loan Not exists");         }else {  ​              return loan;         }     }      /**       * 借款提交       * @param loanRefId 借款id       * @return       */      public  LoanDTO submit(String loanRefId) {          final Loan loan = getLoan(loanRefId);          final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());          return currentState.submit(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));     }      /**       * 借款审批通过       * @param loanRefId 借款id       * @return       */      public LoanDTO approve(String loanRefId) {          final Loan loan = getLoan(loanRefId);          final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());          return currentState.approve(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));     }      /**       * 借款确认       * @param loanRefId 借款id       * @return       */      public LoanDTO confirm(String loanRefId){          final Loan loan = getLoan(loanRefId);          final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());          return currentState.confirm(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));     }      /**       * 借款审批拒绝       * @param loanRefId 借款id       * @return       */      public LoanDTO reject(String loanRefId){          final Loan loan = getLoan(loanRefId);          final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());          return currentState.reject(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));     }      /**       * 放款       * @param loanRefId 借款id       * @return       */      public LoanDTO tofund(String loanRefId) {          final Loan loan = getLoan(loanRefId);          final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());          return currentState.tofund(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));     }  ​      /**       * 还款       * @param loanRefId 借款id       * @return       */      public LoanDTO repay(String loanRefId) {          final Loan loan = getLoan(loanRefId);          final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());          return currentState.repay(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));     }      /**       * 补充信息       * @param loanRefId 借款id       * @return       */      public LoanDTO needMoreInfo(String loanRefId) {          final Loan loan = getLoan(loanRefId);          final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());          return currentState.needMoreInfo(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));     }  }5.4 状态工厂类封装了一个持有所有状态实例的工厂,这样就可以根据借款的状态去获取单例的状态类,既不浪费内存,共享了所有的状态实例,也可以很好地结合了借款领域对象的状态去推动我们封装的状态模式流转业务。 @Component  public class LoanStateFactory implements ApplicationContextAware {  ​      private final static Map<Integer, AbstractLoanState> loanStateMap =new LinkedHashMap<>();      @Override      public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {          Map<String, AbstractLoanState> map = applicationContext.getBeansOfType(AbstractLoanState.class);          map.forEach((key,value)->loanStateMap.put(value.getState(),value));     }  ​      public static AbstractLoanState chooseLoanState(Integer currentState){          return loanStateMap.get(currentState);     }  }5.5 单元测试首先创建一笔借款,然后依次通过LoanStatusHandler状态处理类去推动借款的生命周期,如果状态变化可以从Created-->PaidOff,那么就说明状态模式的流转没有问题,测试通过。 @Test  public void testStateCreatedToPaidOff() {      LoanCreateDTO loanCreateDTO = createLoan();  ​      LoanDTO loanDTO = loanService.create(loanCreateDTO);  ​      String loanRefId = loanDTO.getRefId();  ​      loanStatusHandler.submit(loanRefId);  ​      loanStatusHandler.needMoreInfo(loanRefId);  ​      loanStatusHandler.submit(loanRefId);  ​      loanStatusHandler.approve(loanRefId);  ​      loanStatusHandler.confirm(loanRefId);  ​      loanStatusHandler.tofund(loanRefId);      loanStatusHandler.repay(loanRefId);  }5.6 测试结果以下输出结果说明了整个借款的正向流程的状态变化,单元测试通过。 2023-03-17 17:35:14.907 [main] [INFO ] c.e.p.t.fsm.ifElse.LoanService.create:52 [] - W3p6qS_loan loan created  2023-03-17 17:35:15.230 [main] [INFO ] c.e.p.t.fsm.ifElse.RiskService.callRisk:17 [] - null callRisk  2023-03-17 17:35:15.231 [main] [INFO ] c.e.p.t.f.s.LoanCreateState.submit:36 [] - loan W3p6qS_loan 从 CREATED 状态流转到 SUBMITTED,调用风控  2023-03-17 17:35:15.370 [main] [INFO ] c.e.p.t.f.s.LoanSubmitState.needMoreInfo:49 [] - loan W3p6qS_loan 从 SUBMITTED 状态流转到 NEED_MORE_INFO  2023-03-17 17:35:15.501 [main] [INFO ] c.e.p.t.fsm.ifElse.RiskService.callRisk:17 [] - null callRisk  2023-03-17 17:35:15.501 [main] [INFO ] c.e.p.t.f.s.LoanNeedMoreInfoState.submit:35 [] - loan W3p6qS_loan 从 NEED_MORE_INFO 状态流转到 SUBMITTED,调用风控  2023-03-17 17:35:15.639 [main] [INFO ] c.e.p.t.f.s.LoanSubmitState.approve:31 [] - loan W3p6qS_loan 从 SUBMITTED 状态流转到 APPROVED  2023-03-17 17:35:15.772 [main] [INFO ] c.e.p.t.f.s.LoanApproveState.confirm:30 [] - loan W3p6qS_loan 从 APPROVED 状态流转到 CONFIRMED  2023-03-17 17:35:15.915 [main] [INFO ] c.e.p.t.fsm.ifElse.FundService.sendMoney:17 [] - null sendMoney  2023-03-17 17:35:15.915 [main] [INFO ] c.e.p.t.f.s.LoanConfirmState.tofund:36 [] - loan W3p6qS_loan 从 CONFIRMED 状态流转到 FUNDED  2023-03-17 17:35:16.051 [main] [INFO ] c.e.p.t.f.ifElse.RepayService.rapay:17 [] - W3p6qS_loan sendMoney  2023-03-17 17:35:16.051 [main] [INFO ] c.e.p.t.f.s.LoanFundState.repay:36 [] - loan W3p6qS_loan 从 FUNDED 状态流转到 PAID_OFF6.更多思考实际项目中使用状态模式去改造业务流程会有这些情况发生:扩展状态需增加状态类,状态多了会出现很多状态类,随着状态的不断增多,导致抽象状态类和上下文类中的方法定义可能会变得很多。状态模式虽然让状态独立,通过定义新的子类很容易地增加新的状态和转换,较好的适应了开闭原则。但是并没有完全实现状态与业务解耦。比如上文中具体状态类中还有对领域对象的DB操作。对于复杂的业务状态流转,其实可以有一种优雅的实现方法:状态机。在Java项目中,比较常用的有Spring Statemachine和Squirrel-foundation。通过状态机去推动业务流程,状态转移和业务逻辑基于事件完全解耦,整个系统状态可以更好地维护和扩展。
文章
设计模式  ·  Java  ·  测试技术  ·  uml  ·  Spring
2023-03-20
Linux线程同步与互斥(一)
线程的大部分资源是共享的,包括定义的全局变量等等,全局变量是能够让全部线程共享的。大部分资源共享带来的优点是通信方便,缺点是缺乏访问控制,同时如果因为一个线程的操作问题,给其它线程造成了不可控,或者引起崩溃异常,逻辑不正确等现象,就会造成线程安全问题!所有需要进行后续的访问控制:同步与互斥!先来一些概念:1.临界资源:凡是被线程共享访问的资源都是临界资源。比如说打印数据到显示器,显示器就是一个临界资源。2.临界区:我们写的代码中,访问临界资源的那段代码称为临界区。3.需要对临界区进行保护,本质是对临界资源的保护。方法同步和互斥。4.互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),称之为互斥。5.原子性:如果需要执行printf("hello world");访问临界资源(显示器),为了安全,加上互斥锁:lock();printf();unlock();在加上锁到解锁的这段过程内,只能执行锁内的代码,并且要么不执行,要么就一次执行完毕!6.同步:一般而言,让访问临界资源的过程在安全的前提下(这个前提一般是互斥和原子性),让访问资源的执行流具有一定的顺序性!互斥量mutex多线程并发操作带来的问题大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量,但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互,多个线程并发的操作共享变量,会带来一些问题。下面用示例代码举个例子:写一个测试代码:操作共享变量会有问题的售票系统代码。出售1千张票,5个线程去抢这一千张,但是到最后会出现一个错误,出现了负数的票数!#include <iostream> #include <string> #include <pthread.h> #include <unistd.h> //抢票逻辑,1000张票,5个线程同时抢 int tickets = 1000;//tickets是临界资源 void *ThreadRoutine(void *args) { int id = *(int*)args; delete (int*)args; while(true) { if(tickets > 0) { //抢票 usleep(1000); std::cout << "我是[" << id << "] 我要抢的票是: " << tickets << std::endl; tickets-- ; printf(""); } else { break; //没有票了 } } } int main() { //创建线程 pthread_t tid[5]; for(int i = 0;i<5;i++) { int *id = new int(i); pthread_create(tid+i,nullptr,ThreadRoutine,id); } //线程等待 for(int i = 0;i<5;i++) { pthread_join(tid[i],nullptr); } return 0; } 可以看到在代码中,全局变量tickets属于临界资源,多线程直接访问临界资源,出现问题了!下面分析一下问题:tickets作为全局变量,保存在内存当中,而抢票的操作就一行的代码:tickets--;而这个操作并非原子的!因为tickets--是运算,是运算就会在CPU中进行,在CPU中运算完成,就写回到内存当中,所以这一操作看起来就一行代码,但是在汇编语言中是多行代码。于是,当线程A准备执行tickets--,就被切换成线程B了,线程A切换时线程A的上下文就被保存起来,此时的tickets还是原来那个数值的。而此时是线程B被切换过来后,就不断地进行运算,假设线程B的优先级很高,直至把票数变成剩下10张,才被切换出去,再次切换成线程A。此时线程A保存的上下文继续上一次的操作,在CPU运算,然后写回内存,此时就会把原本内存中tickets为10的票数,变成999!这就是造成的问题的原因之一! 上面的过程,只是tickets--这一个操作,更何况还有判断票数的操作!代码中的临界区,就是这一段代码: 总结一下无法获取正确结果的原因:1、if 语句判断条件为真以后,代码可以并发的切换到其他线程2、usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段3、ticket-- 操作本身就不是一个原子操作要解决这个问题,就要多临界区进行加锁,这把锁就叫做互斥量。互斥量接口首先定义一个互斥量:互斥变量使用特定的数据类型:pthread_mutex_t。pthread_mutex_t mtx;初始化互斥量初始化互斥量有两种方法:①静态分配。使用宏PTHREAD_MUTEX_INITIALIZER来初始化互斥量。pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER②动态分配。函数原型:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 参数: mutex:要初始化的互斥量 attr:对于这个参数我们直接设为nullptr即可 销毁互斥量int pthread_mutex_destroy(pthread_mutex_t *mutex);x销毁互斥量要注意以下三点:1.使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁2.不要销毁一个已经加锁的互斥量3.已经销毁的互斥量,要确保后面不会有线程再尝试加锁互斥量的加锁和解锁加锁:int pthread_mutex_lock(pthread_mutex_t *mutex); 解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex); 返回值:成功返回0,失败返回错误号在调用 pthread_mutex_lock的时可能会遇到以下情况:1.互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。2.发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。对上面的售票系统加锁改进: #include <iostream> #include <string> #include <pthread.h> #include <unistd.h> //抢票逻辑,1000张票,5个线程同时抢 //定义一个锁的类 class Ticket{ private: int tickets; pthread_mutex_t mtx; public: Ticket() :tickets(1000) { pthread_mutex_init(&mtx,nullptr); } bool GetTicket() { //这里的bool变量不是被所有线程共享,是属于某个线程自己的,它是局部变变量 bool res = true; //加锁 pthread_mutex_lock(&mtx); //加锁后,执行这部分代码的执行流是互斥的,是串行执行的! if(tickets > 0) { //抢票 usleep(1000); std::cout << "我是[" << pthread_self() << "] 我要抢的票是: " << tickets << std::endl; tickets-- ; printf(""); } else { printf("票被抢空了\n"); res = false; //没有票了 } //解锁 pthread_mutex_unlock(&mtx); return res; } ~Ticket() { pthread_mutex_destroy(&mtx); } }; void *ThreadRoutine(void *args) { Ticket *t = (Ticket*)args; while(true) { if(!t->GetTicket()) { break; } } } int main() { Ticket* t = new Ticket(); //创建线程 pthread_t tid[5]; for(int i = 0;i<5;i++) { int *id = new int(i); pthread_create(tid+i,nullptr,ThreadRoutine,(void*)t); } //线程等待 for(int i = 0;i<5;i++) { pthread_join(tid[i],nullptr); } return 0; } 加锁之后,临界资源tickets就变得安全了。因此,在访问临界资源tickets前,需要先访问mtx,要访问mtx,前提是所有线程能够看得到mtx,这意味着,mtx这把锁本身也是一个临界资源!是临界资源就要受保护,必须有安全性,那么该如何保证锁本身的安全呢?接下来我们得去了解互斥量实现的原理!互斥量实现原理让一行代码拥有原子性,是让它的汇编只有一行!我们先记住这个点。对于加锁lock和解锁unlock,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下:lock: movb $0, %al xchgb %al, mutex if (al寄存器的内容 > 0) { return 0; } else { 挂起等待; } goto lock; unlock: movb $1, mutex 唤醒等待mutex的线程; return 0; 对锁的进一步理解:在临界区中,可能会不止一行代码,而是会有很多行代码,比如上面售票系统的临界区。在一个线程在在临界区执行一半的时候,是有可能被切换的!但是在线程被切走的时候,它的上下文会被保存,并且锁的数据也会被保存在这个线程的上下文中!这代表着,这个拥有锁的线程被切走时,是带着锁走的!在此期间其它线程休想申请锁资源,休想进入临界区!这就保证了锁的作用,保证了线程安全!站在其它线程的视角来看,对它们有意义的状态,要么就是线程A没有申请锁,要么线程A申请锁后已经使用完了,那么其它线程就可以去竞争锁了!这就保证了线程访问临界区的原子性!可重入与线程安全概念1.线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。2.重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。线程不安全的一些常见情况1.不保护共享变量的函数。2.函数状态随着被调用,状态发生变化的函数。3.返回指向静态变量指针的函数。4.调用线程不安全函数的函数。线程安全的一些常见情况1.每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作。2.多个线程之间的切换不会导致该接口的执行结果存在二义性。不可重入的一些常见情况1.调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。2.调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。3.可重入函数体内使用了静态的数据结构。可重入的一些常见情况1.不使用全局变量或静态变量。2.不使用用malloc或者new开辟出的空间。3.不调用不可重入函数。4.不返回静态或全局数据,所有数据都有函数的调用者提供。5.使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。可重入与线程安全的关系1.函数是可重入的,那就是线程安全的。2.函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。3.如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。可重入和线程安全的区别1.可重入函数是线程安全函数的一种。2.线程安全不一定是可重入的,而可重入函数则一定是线程安全的。3.如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。
文章
安全  ·  Linux  ·  数据安全/隐私保护
2023-03-21
一文读懂Linux多线程中互斥锁、读写锁、自旋锁、条件变量、信号量
关注公众号:Linux兵工厂,领取海量Linux硬核学习资料!同步和互斥互斥:多线程中互斥是指多个线程访问同一资源时同时只允许一个线程对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的;同步:多线程同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。互斥锁在多任务操作系统中,同时运行的多个任务可能都需要使用同一种资源。为了同一时刻只允许一个任务访问资源,需要用互斥锁对资源进行保护。互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即上锁( lock )和解锁( unlock )。互斥锁操作基本流程访问共享资源前,对互斥锁进行加锁完成加锁后访问共享资源对共享资源完成访问后,对互斥锁进行解锁对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放互斥锁特性原子性:互斥锁是一个原子操作,操作系统保证如果一个线程锁定了一个互斥锁,那么其他线程在同一时间不会成功锁定这个互斥锁唯一性:如果一个线程锁定了一个互斥锁,在它解除锁之前,其他线程不可以锁定这个互斥锁非忙等待:如果一个线程已经锁定了一个互斥锁,第二个线程又试图去锁定这个互斥锁,则第二个线程将被挂起且不占用任何CPU资源,直到第一个线程解除对这个互斥锁的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥锁示例#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> #include <string.h> char *pTestBuf = nullptr; // 全局变量 /* 定义互斥锁 */ pthread_mutex_t mutex; void *ThrTestMutex(void *p) { pthread_mutex_lock(&mutex); // 加锁 { pTestBuf = (char*)p; sleep(1); } pthread_mutex_unlock(&mutex); // 解锁 } int main() { /* 初始化互斥量, 默认属性 */ pthread_mutex_init(&mutex, NULL); /* 创建两个线程对共享资源访问 */ pthread_t tid1, tid2; pthread_create(&tid1, NULL, ThrTestMutex, (void *)"Thread1"); pthread_create(&tid2, NULL, ThrTestMutex, (void *)"Thread2"); /* 等待线程结束 */ pthread_join(tid1, NULL); pthread_join(tid2, NULL); /* 销毁互斥锁 */ pthread_mutex_destroy(&mutex); return 0; }读写锁读写锁允许更高的并行性,也叫共享互斥锁。互斥量要么是加锁状态,要么就是解锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态、写模式加锁状态、不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁,即允许多个线程读但只允许一个线程写。当读操作较多,写操作较少时,可用读写锁提高线程读并发性读写锁特性如果有线程读数据,则允许其它线程执行读操作,但不允许写操作如果有线程写数据,则其它线程都不允许读、写操作如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁读写锁适合于对数据的读次数比写次数多得多的情况读写锁创建和销毁 #include <pthread.h> int phtread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr); int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);参数:rwlock:读写锁,attr:读写锁属性返回值:成功返回0,出错返回错误码读写锁加锁解锁 #include <pthread.h> /** 加读锁 */ int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); /** 加写锁 */ int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); /** 释放锁 */ int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);参数:rwlock:读写锁返回值:成功返回 0;出错,返回错误码示例#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> #include <string.h> /* 定义读写锁 */ pthread_rwlock_t rwlock; /* 定义共享资源变量 */ int g_nNum = 0; /* 读操作 其他线程允许读操作 不允许写操作 */ void *fun1(void *arg) { while(1) { pthread_rwlock_rdlock(&rwlock); { printf("read thread 1 == %d\n", g_nNum); } pthread_rwlock_unlock(&rwlock); sleep(1); } } /* 读操作,其他线程允许读操作,不允许写操作 */ void *fun2(void *arg) { while(1) { pthread_rwlock_rdlock(&rwlock); { printf("read thread 2 == %d\n", g_nNum); } pthread_rwlock_unlock(&rwlock); sleep(1); } } /* 写操作,其它线程都不允许读或写操作 */ void *fun3(void *arg) { while(1) { pthread_rwlock_wrlock(&rwlock); { g_nNum++; printf("write thread 1\n"); } pthread_rwlock_unlock(&rwlock); sleep(1); } } /* 写操作,其它线程都不允许读或写操作 */ void *fun4(void *arg) { while(1) { pthread_rwlock_wrlock(&rwlock); { g_nNum++; printf("write thread 2\n"); } pthread_rwlock_unlock(&rwlock); sleep(1); } } int main(int arc, char *argv[]) { pthread_t ThrId1, ThrId2, ThrId3, ThrId4; pthread_rwlock_init(&rwlock, NULL); // 初始化一个读写锁 /* 创建测试线程 */ pthread_create(&ThrId1, NULL, fun1, NULL); pthread_create(&ThrId2, NULL, fun2, NULL); pthread_create(&ThrId3, NULL, fun3, NULL); pthread_create(&ThrId4, NULL, fun4, NULL); /* 等待线程结束,回收其资源 */ pthread_join(ThrId1, NULL); pthread_join(ThrId2, NULL); pthread_join(ThrId3, NULL); pthread_join(ThrId4, NULL); pthread_rwlock_destroy(&rwlock); // 销毁读写锁 return 0; }结果自旋锁自旋锁与互斥锁功能相同,唯一不同的就是互斥锁阻塞后休眠不占用CPU,而自旋锁阻塞后不会让出CPU,会一直忙等待,直到得到锁自旋锁在用户态较少用,而在内核态使用的比较多自旋锁的使用场景:锁的持有时间比较短,或者说小于2次上下文切换的时间自旋锁在用户态的函数接口和互斥量一样,把pthread_mutex_lock()/pthread_mutex_unlock()中mutex换成spin,如:pthread_spin_init()自旋锁函数linux中的自旋锁用结构体spinlock_t 表示,定义在include/linux/spinlock_type.h。自旋锁的接口函数全部定义在include/linux/spinlock.h头文件中,实际使用时只需include<linux/spinlock.h>即可示例 include<linux/spinlock.h> spinlock_t lock;      //定义自旋锁 spin_lock_init(&lock);   //初始化自旋锁 spin_lock(&lock);     //获得锁,如果没获得成功则一直等待 { .......         //处理临界资源 } spin_unlock(&lock);    //释放自旋锁条件变量条件变量用来阻塞一个线程,直到条件发生。通常条件变量和互斥锁同时使用。条件变量使线程可以睡眠等待某种条件满足。条件变量是利用线程间共享的全局变量进行同步的一种机制。条件变量的逻辑:一个线程挂起去等待条件变量的条件成立,而另一个线程使条件成立。基本原理线程在改变条件状态之前先锁住互斥量。如果条件为假,线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程。如果两进程共享可读写的内存,条件变量可以被用来实现这两进程间的线程同步示例#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <pthread.h> pthread_cond_t taxicond = PTHREAD_COND_INITIALIZER; pthread_mutex_t taximutex = PTHREAD_MUTEX_INITIALIZER; void *ThrFun1(void *name) { char *p = (char *)name; // 加锁,把信号量加入队列,释放信号量 pthread_mutex_lock(&taximutex); { pthread_cond_wait(&taxicond, &taximutex); } pthread_mutex_unlock(&taximutex); printf ("ThrFun1: %s now got a signal!\n", p); pthread_exit(NULL); } void *ThrFun2(void *name) { char *p = (char *)name; printf ("ThrFun2: %s cond signal.\n", p); // 发信号 pthread_cond_signal(&taxicond); pthread_exit(NULL); } int main (int argc, char **argv) { pthread_t Thread1, Thread2; pthread_attr_t threadattr; pthread_attr_init(&threadattr); // 线程属性初始化 // 创建三个线程 pthread_create(&Thread1, &threadattr, ThrFun1, (void *)"Thread1"); sleep(1); pthread_create(&Thread2, &threadattr, ThrFun2, (void *)"Thread2"); sleep(1); pthread_join(Thread1, NULL); pthread_join(Thread2, NULL); return 0; }结果虚假唤醒当线程从等待已发出信号的条件变量中醒来,却发现它等待的条件不满足时,就会发生虚假唤醒。之所以称为虚假,是因为该线程似乎无缘无故地被唤醒了。但是虚假唤醒不会无缘无故发生:它们通常是因为在发出条件变量信号和等待线程最终运行之间,另一个线程运行并更改了条件避免虚假唤醒在wait端,我们必须把判断条件和wait()放到while循环中 pthread_mutex_lock(&taximutex); { while(value != wantValue) { pthread_cond_wait(&taxicond, &taximutex); } } pthread_mutex_unlock(&taximutex); 信号量信号量用于进程或线程间的同步和互斥,信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于0时,则可以访问,否则将阻塞#include <semaphore.h> // 初始化信号量 int sem_init(sem_t *sem, int pshared, unsigned int value); // 信号量P操作(减 1) int sem_wait(sem_t *sem); // 以非阻塞的方式来对信号量进行减1操作 int sem_trywait(sem_t *sem); // 信号量V操作(加 1) int sem_post(sem_t *sem); // 获取信号量的值 int sem_getvalue(sem_t *sem, int *sval); // 销毁信号量 int sem_destroy(sem_t *sem);示例// 信号量用于同步实例 #include <stdio.h> #include <unistd.h> #include <pthread.h> #include <semaphore.h> sem_t sem_g,sem_p; //定义两个信号量 char s8Test = 'a'; void *pthread_g(void *arg) //此线程改变字符的值 { while(1) { sem_wait(&sem_g); s8Test++; sleep(2); sem_post(&sem_p); } } void *pthread_p(void *arg) //此线程打印字符的值 { while(1) { sem_wait(&sem_p); printf("%c",s8Test); fflush(stdout); sem_post(&sem_g); } } int main(int argc, char *argv[]) { pthread_t tid1,tid2; sem_init(&sem_g, 0, 0); // 初始化信号量为0 sem_init(&sem_p, 0, 1); // 初始化信号量为1 pthread_create(&tid1, NULL, pthread_g, NULL); pthread_create(&tid2, NULL, pthread_p, NULL); pthread_join(tid1, NULL); pthread_join(tid2, NULL); return 0; }结果
文章
Linux
2023-03-16
高德Go生态的服务稳定性建设|性能优化的实战总结
本文共同作者:阳迪、联想、君清前言go语言凭借着优秀的性能,简洁的编码风格,极易使用的协程等优点,逐渐在各大互联网公司中流行起来。而高德业务使用go语言已经有3年时间了,随着高德业务的发展,go语言生态也日趋完善,今后会有越来越多新的go服务出现。在任何时候,保障服务的稳定性都是首要的,go服务也不例外,而性能优化作为保障服务稳定性,降本增效的重要手段之一,在高德go服务日益普及的当下显得愈发重要。此时此刻,我们将过去go服务开发中的性能调优经验进行总结和沉淀,为您呈上这篇精心准备的go性能调优指南。通过本文您将收获以下内容: 从理论的角度,和你一起捋清性能优化的思路,制定最合适的优化方案。推荐几款go语言性能分析利器,与你一起在性能优化的路上披荆斩棘。总结归纳了众多go语言中常用的性能优化小技巧,总有一个你能用上。基于高德go服务百万级QPS实践,分享几个性能优化实战案例,让性能优化不再是纸上谈兵。1. 性能调优-理论篇1.1 衡量指标优化的第一步是先衡量一个应用性能的好坏,性能良好的应用自然不必费心优化,性能较差的应用,则需要从多个方面来考察,找到木桶里的短板,才能对症下药。那么如何衡量一个应用的性能好坏呢?最主要的还是通过观察应用对核心资源的占用情况以及应用的稳定性指标来衡量。所谓核心资源,就是相对稀缺的,并且可能会导致应用无法正常运行的资源,常见的核心资源如下:cpu:对于偏计算型的应用,cpu往往是影响性能好坏的关键,如果代码中存在无限循环,或是频繁的线程上下文切换,亦或是糟糕的垃圾回收策略,都将导致cpu被大量占用,使得应用程序无法获取到足够的cpu资源,从而响应缓慢,性能变差。内存:内存的读写速度非常快,往往不是性能的瓶颈,但是内存相对来说容量有限切价格昂贵,如果应用大量分配内存而不及时回收,就会造成内存溢出或泄漏,应用无法分配新的内存,便无法正常运行,这将导致很严重的事故。带宽:对于偏网络I/O型的应用,例如网关服务,带宽的大小也决定了应用的性能好坏,如果带宽太小,当系统遇到大量并发请求时,带宽不够用,网络延迟就会变高,这个虽然对服务端可能无感知,但是对客户端则是影响甚大。磁盘:相对内存来说,磁盘价格低廉,容量很大,但是读写速度较慢,如果应用频繁的进行磁盘I/O,那性能可想而知也不会太好。以上这些都是系统资源层面用于衡量性能的指标,除此之外还有应用本身的稳定性指标:异常率:也叫错误率,一般分两种,执行超时和应用panic。panic会导致应用不可用,虽然服务通常都会配置相应的重启机制,确保偶然的应用挂掉后能重启再次提供服务,但是经常性的panic,会导致应用频繁的重启,减少了应用正常提供服务的时间,整体性能也就变差了。异常率是非常重要的指标,服务的稳定和可用是一切的前提,如果服务都不可用了,还谈何性能优化。响应时间(RT):包括平均响应时间,百分位(top percentile)响应时间。响应时间是指应用从收到请求到返回结果后的耗时,反应的是应用处理请求的快慢。通常平均响应时间无法反应服务的整体响应情况,响应慢的请求会被响应快的请求平均掉,而响应慢的请求往往会给用户带来糟糕的体验,即所谓的长尾请求,所以我们需要百分位响应时间,例如tp99响应时间,即99%的请求都会在这个时间内返回。吞吐量:主要指应用在一定时间内处理请求/事务的数量,反应的是应用的负载能力。我们当然希望在应用稳定的情况下,能承接的流量越大越好,主要指标包括QPS(每秒处理请求数)和QPM(每分钟处理请求数)。1.2 制定优化方案明确了性能指标以后,我们就可以评估一个应用的性能好坏,同时也能发现其中的短板并对其进行优化。但是做性能优化,有几个点需要提前注意:第一,不要反向优化。比如我们的应用整体占用内存资源较少,但是rt偏高,那我们就针对rt做优化,优化完后,rt下降了30%,但是cpu使用率上升了50%,导致一台机器负载能力下降30%,这便是反向优化。性能优化要从整体考虑,尽量在优化一个方面时,不影响其他方面,或是其他方面略微下降。第二,不要过度优化。如果应用性能已经很好了,优化的空间很小,比如rt的tp99在2ms内,继续尝试优化可能投入产出比就很低了,不如将这些精力放在其他需要优化的地方上。由此可见,在优化之前,明确想要优化的指标,并制定合理的优化方案是很重要的。常见的优化方案有以下几种:优化代码有经验的程序员在编写代码时,会时刻注意减少代码中不必要的性能消耗,比如使用strconv而不是fmt.Sprint进行数字到字符串的转化,在初始化map或slice时指定合理的容量以减少内存分配等。良好的编程习惯不仅能使应用性能良好,同时也能减少故障发生的几率。总结下来,常用的代码优化方向有以下几种:提高复用性,将通用的代码抽象出来,减少重复开发。池化,对象可以池化,减少内存分配;协程可以池化,避免无限制创建协程打满内存。并行化,在合理创建协程数量的前提下,把互不依赖的部分并行处理,减少整体的耗时。异步化,把不需要关心实时结果的请求,用异步的方式处理,不用一直等待结果返回。算法优化,使用时间复杂度更低的算法。使用设计模式设计模式是对代码组织形式的抽象和总结,代码的结构对应用的性能有着重要的影响,结构清晰,层次分明的代码不仅可读性好,扩展性高,还能避免许多潜在的性能问题,帮助开发人员快速找到性能瓶颈,进行专项优化,为服务的稳定性提供保障。常见的对性能有所提升的设计模式例如单例模式,我们可以在应用启动时将需要的外部依赖服务用单例模式先初始化,避免创建太多重复的连接。空间换时间或时间换空间在优化的前期,可能一个小的优化就能达到很好的效果。但是优化的尽头,往往要面临抉择,鱼和熊掌不可兼得。性能优秀的应用往往是多项资源的综合利用最优。为了达到综合平衡,在某些场景下,就需要做出一些调整和牺牲,常用的方法就是空间换时间或时间换空间。比如在响应时间优先的场景下,把需要耗费大量计算时间或是网络i/o时间的中间结果缓存起来,以提升后续相似请求的响应速度,便是空间换时间的一种体现。使用更好的三方库在我们的应用中往往会用到很多开源的第三方库,目前在github上的go开源项目就有173万+。有很多go官方库的性能表现并不佳,比如go官方的日志库性能就一般,下面是zap发布的基准测试信息(记录一条消息和10个字段的性能表现)。PackageTimeTime % to zapObjects Allocated⚡️ zap862 ns/op+0%5 allocs/op⚡️ zap (sugared)1250 ns/op+45%11 allocs/opzerolog4021 ns/op+366%76 allocs/opgo-kit4542 ns/op+427%105 allocs/opapex/log26785 ns/op+3007%115 allocs/oplogrus29501 ns/op+3322%125 allocs/oplog1529906 ns/op+3369%122 allocs/op从上面可以看出zap的性能比同类结构化日志包更好,也比标准库更快,那我们就可以选择更好的三方库。2. 性能调优-工具篇当我们找到应用的性能短板,并针对短板制定相应优化方案,最后按照方案对代码进行优化之后,我们怎么知道优化是有效的呢?直接将代码上线,观察性能指标的变化,风险太大了。此时我们需要有好用的性能分析工具,帮助我们检验优化的效果,下面将为大家介绍几款go语言中性能分析的利器。2.1 benchmarkGo语言标准库内置的 testing 测试框架提供了基准测试(benchmark)的能力,benchmark可以帮助我们评估代码的性能表现,主要方式是通过在一定时间(默认1秒)内重复运行测试代码,然后输出执行次数和内存分配结果。下面我们用一个简单的例子来验证一下,strconv是否真的比fmt.Sprint快。首先我们来编写一段基准测试的代码,如下:package main import ( "fmt" "strconv" "testing" ) func BenchmarkStrconv(b *testing.B) { for n := 0; n < b.N; n++ { strconv.Itoa(n) } } func BenchmarkFmtSprint(b *testing.B) { for n := 0; n < b.N; n++ { fmt.Sprint(n) } }我们可以用命令行go test -bench . 来运行基准测试,输出结果如下:goos: darwin goarch: amd64 pkg: main cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkStrconv-12 41988014 27.41 ns/op BenchmarkFmtSprint-12 13738172 81.19 ns/op ok main 7.039s可以看到strconv每次执行只用了27.41纳秒,而fmt.Sprint则是81.19纳秒,strconv的性能是fmt.Sprint的三倍,那为什么strconv要更快呢?会不会是这次运行时间太短呢?为了公平起见,我们决定让他们再比赛一轮,这次我们延长比赛时间,看看结果如何。通过go test -bench . -benchtime=5s 命令,我们可以把测试时间延长到5秒,结果如下:goos: darwin goarch: amd64 pkg: main cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkStrconv-12 211533207 31.60 ns/op BenchmarkFmtSprint-12 69481287 89.58 ns/op PASS ok main 18.891s结果有些变化,strconv每次执行的时间上涨了4ns,但变化不大,差距仍有2.9倍。但是我们仍然不死心,我们决定让他们一次跑三轮,每轮5秒,三局两胜。通过go test -bench . -benchtime=5s -count=3 命令,我们可以把测试进行3轮,结果如下:goos: darwin goarch: amd64 pkg: main cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkStrconv-12 217894554 31.76 ns/op BenchmarkStrconv-12 217140132 31.45 ns/op BenchmarkStrconv-12 219136828 31.79 ns/op BenchmarkFmtSprint-12 70683580 89.53 ns/op BenchmarkFmtSprint-12 63881758 82.51 ns/op BenchmarkFmtSprint-12 64984329 82.04 ns/op PASS ok main 54.296s结果变化也不大,看来strconv是真的比fmt.Sprint快很多。那快是快,会不会内存分配上情况就相反呢?通过go test -bench . -benchmem 这个命令我们可以看到两个方法的内存分配情况,结果如下:goos: darwin goarch: amd64 pkg: main cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkStrconv-12 43700922 27.46 ns/op 7 B/op 0 allocs/op BenchmarkFmtSprint-12 143412 80.88 ns/op 16 B/op 2 allocs/op PASS ok main 7.031s可以看到strconv在内存分配上是0次,每次运行使用的内存是7字节,只是fmt.Sprint的43.8%,简直是全方面的优于fmt.Sprint啊。那究竟是为什么strconv比fmt.Sprint好这么多呢?通过查看strconv的代码,我们发现,对于小于100的数字,strconv是直接通过digits和smallsString这两个常量进行转换的,而大于等于100的数字,则是通过不断除以100取余,然后再找到余数对应的字符串,把这些余数的结果拼起来进行转换的。const digits = "0123456789abcdefghijklmnopqrstuvwxyz" const smallsString = "00010203040506070809" + "10111213141516171819" + "20212223242526272829" + "30313233343536373839" + "40414243444546474849" + "50515253545556575859" + "60616263646566676869" + "70717273747576777879" + "80818283848586878889" + "90919293949596979899" // small returns the string for an i with 0 <= i < nSmalls. func small(i int) string { if i < 10 { return digits[i : i+1] } return smallsString[i*2 : i*2+2] } func formatBits(dst []byte, u uint64, base int, neg, append_ bool) (d []byte, s string) { ... for j := 4; j > 0; j-- { is := us % 100 * 2 us /= 100 i -= 2 a[i+1] = smallsString[is+1] a[i+0] = smallsString[is+0] } ... }而fmt.Sprint则是通过反射来实现这一目的的,fmt.Sprint得先判断入参的类型,在知道参数是int型后,再调用fmt.fmtInteger方法把int转换成string,这多出来的步骤肯定没有直接把int转成string来的高效。// fmtInteger formats signed and unsigned integers. func (f *fmt) fmtInteger(u uint64, base int, isSigned bool, verb rune, digits string) { ... switch base { case 10: for u >= 10 { i-- next := u / 10 buf[i] = byte('0' + u - next*10) u = next } ... }benchmark还有很多实用的函数,比如ResetTimer可以重置启动时耗费的准备时间,StopTimer和StartTimer则可以暂停和启动计时,让测试结果更集中在核心逻辑上。2.2 pprof2.2.1 使用介绍pprof是go语言官方提供的profile工具,支持可视化查看性能报告,功能十分强大。pprof基于定时器(10ms/次)对运行的go程序进行采样,搜集程序运行时的堆栈信息,包括CPU时间、内存分配等,最终生成性能报告。pprof有两个标准库,使用的场景不同:runtime/pprof 通过在代码中显式的增加触发和结束埋点来收集指定代码块运行时数据生成性能报告。net/http/pprof 是对runtime/pprof的二次封装,基于web服务运行,通过访问链接触发,采集服务运行时的数据生成性能报告。runtime/pprof的使用方法如下:package main import ( "os" "runtime/pprof" "time" ) func main() { w, _ := os.OpenFile("test_cpu", os.O_RDWR | os.O_CREATE | os.O_APPEND, 0644) pprof.StartCPUProfile(w) time.Sleep(time.Second) pprof.StopCPUProfile() }我们也可以使用另外一种方法,net/http/pprof:package main import ( "net/http" _ "net/http/pprof" ) func main() { err := http.ListenAndServe(":6060", nil) if err != nil { panic(err) } }将程序run起来后,我们通过访问http://127.0.0.1:6060/debug/pprof/就可以看到如下页面:点击profile就可以下载cpu profile文件。那我们如何查看我们的性能报告呢? pprof支持两种查看模式,终端和web界面,注意: 想要查看可视化界面需要提前安装graphviz。这里我们以web界面为例,在终端内我们输入如下命令:go tool pprof -http :6060 test_cpu就会在浏览器里打开一个页面,内容如下:从界面左上方VIEW栏下,我们可以看到,pprof支持Flame Graph,dot Graph和Top等多种视图,下面我们将一一介绍如何阅读这些视图。2.2.1 火焰图 Flame Graph如何阅读首先,推荐直接阅读火焰图,在查函数耗时场景,这个比较直观;最简单的:横条越长,资源消耗、占用越多; 注意:每一个function 的横条虽然很长,但可能是他的下层“子调用”耗时产生的,所以一定要关注“下一层子调用”各自的耗时分布;每个横条支持点击下钻能力,可以更详细的分析子层的耗时占比。2.2.2 dot Graph 图如何阅读英文原文在这里:https://github.com/google/pprof/blob/master/doc/README.md节点颜色:红色表示耗时多的节点;绿色表示耗时少的节点;灰色表示耗时几乎可以忽略不计(接近零);节点字体大小 :字体越大,表示占“上层函数调用”比例越大;(其实上层函数自身也有耗时,没包含在此)字体越小,表示占“上层函数调用”比例越小;线条(边)粗细:线条越粗,表示消耗了更多的资源;反之,则越少;线条(边)颜色:颜色越红,表示性能消耗占比越高;颜色越绿,表示性能消耗占比越低;灰色,表示性能消耗几乎可以忽略不计;虚线:表示中间有一些节点被“移除”或者忽略了;(一般是因为耗时较少所以忽略了) 实线:表示节点之间直接调用 内联边标记:被调用函数已经被内联到调用函数中(对于一些代码行比较少的函数,编译器倾向于将它们在编译期展开从而消除函数调用,这种行为就是内联。)2.2.3 TOP 表如何阅读flat:当前函数,运行耗时(不包含内部调用其他函数的耗时)flat%:当前函数,占用的 CPU 运行耗时总比例(不包含外部调用函数)sum%:当前行的flat%与上面所有行的flat%总和。cum:当前函数加上它内部的调用的运行总耗时(包含内部调用其他函数的耗时)cum%:同上的 CPU 运行耗时总比例2.3 tracepprof已经有了对内存和CPU的分析能力,那trace工具有什么不同呢?虽然pprof的CPU分析器,可以告诉你什么函数占用了最多的CPU时间,但它并不能帮助你定位到是什么阻止了goroutine运行,或者在可用的OS线程上如何调度goroutines。这正是trace真正起作用的地方。我们需要更多关于Go应用中各个goroutine的执行情况的更为详细的信息,可以从P(goroutine调度器概念中的processor)和G(goroutine调度器概念中的goroutine)的视角完整的看到每个P和每个G在Tracer开启期间的全部“所作所为”,对Tracer输出数据中的每个P和G的行为分析并结合详细的event数据来辅助问题诊断的。Tracer可以帮助我们记录的详细事件包含有:与goroutine调度有关的事件信息:goroutine的创建、启动和结束;goroutine在同步原语(包括mutex、channel收发操作)上的阻塞与解锁。与网络有关的事件:goroutine在网络I/O上的阻塞和解锁;与系统调用有关的事件:goroutine进入系统调用与从系统调用返回;与垃圾回收器有关的事件:GC的开始/停止,并发标记、清扫的开始/停止。Tracer主要也是用于辅助诊断这三个场景下的具体问题的:并行执行程度不足的问题:比如没有充分利用多核资源等;因GC导致的延迟较大的问题;Goroutine执行情况分析,尝试发现goroutine因各种阻塞(锁竞争、系统调用、调度、辅助GC)而导致的有效运行时间较短或延迟的问题。2.3.1 trace性能报告打开trace性能报告,首页信息包含了多维度数据,如下图:View trace:以图形页面的形式渲染和展示tracer的数据,这也是我们最为关注/最常用的功能Goroutine analysis:以表的形式记录执行同一个函数的多个goroutine的各项trace数据Network blocking profile:用pprof profile形式的调用关系图展示网络I/O阻塞的情况Synchronization blocking profile:用pprof profile形式的调用关系图展示同步阻塞耗时情况Syscall blocking profile:用pprof profile形式的调用关系图展示系统调用阻塞耗时情况Scheduler latency profile:用pprof profile形式的调用关系图展示调度器延迟情况User-defined tasks和User-defined regions:用户自定义trace的task和regionMinimum mutator utilization:分析GC对应用延迟和吞吐影响情况的曲线图通常我们最为关注的是View trace和Goroutine analysis,下面将详细说说这两项的用法。2.3.2 view trace如果Tracer跟踪时间较长,trace会将View trace按时间段进行划分,避免触碰到trace-viewer的限制:View trace使用快捷键来缩放时间线标尺:w键用于放大(从秒向纳秒缩放),s键用于缩小标尺(从纳秒向秒缩放)。我们同样可以通过快捷键在时间线上左右移动:s键用于左移,d键用于右移。(游戏快捷键WASD)采样状态这个区内展示了三个指标:Goroutines、Heap和Threads,某个时间点上的这三个指标的数据是这个时间点上的状态快照采样:Goroutines:某一时间点上应用中启动的goroutine的数量,当我们点击某个时间点上的goroutines采样状态区域时(我们可以用快捷键m来准确标记出那个时间点),事件详情区会显示当前的goroutines指标采样状态:Heap指标则显示了某个时间点上Go应用heap分配情况(包括已经分配的Allocated和下一次GC的目标值NextGC):Threads指标显示了某个时间点上Go应用启动的线程数量情况,事件详情区将显示处于InSyscall(整阻塞在系统调用上)和Running两个状态的线程数量情况:P视角区这里将View trace视图中最大的一块区域称为“P视角区”。这是因为在这个区域,我们能看到Go应用中每个P(Goroutine调度概念中的P)上发生的所有事件,包括:EventProcStart、EventProcStop、EventGoStart、EventGoStop、EventGoPreempt、Goroutine辅助GC的各种事件以及Goroutine的GC阻塞(STW)、系统调用阻塞、网络阻塞以及同步原语阻塞(mutex)等事件。除了每个P上发生的事件,我们还可以看到以单独行显示的GC过程中的所有事件。事件详情区点选某个事件后,关于该事件的详细信息便会在这个区域显示出来,事件详情区可以看到关于该事件的详细信息:Title:事件的可读名称;Start:事件的开始时间,相对于时间线上的起始时间;Wall Duration:这个事件的持续时间,这里表示的是G1在P4上此次持续执行的时间;Start Stack Trace:当P4开始执行G1时G1的调用栈;End Stack Trace:当P4结束执行G1时G1的调用栈;从上面End Stack Trace栈顶的函数为runtime.asyncPreempt来看,该Goroutine G1是被强行抢占了,这样P4才结束了其运行;Incoming flow:触发P4执行G1的事件;Outgoing flow:触发G1结束在P4上执行的事件;Preceding events:与G1这个goroutine相关的之前的所有的事件;Follwing events:与G1这个goroutine相关的之后的所有的事件All connected:与G1这个goroutine相关的所有事件。2.3.3 Goroutine analysisGoroutine analysis提供了从G视角看Go应用执行的图景。与View trace不同,这次页面中最广阔的区域提供的G视角视图,而不再是P视角视图。在这个视图中,每个G都会对应一个单独的条带(和P视角视图一样,每个条带都有两行),通过这一条带可以按时间线看到这个G的全部执行情况。通常仅需在goroutine analysis的表格页面找出执行最快和最慢的两个goroutine,在Go视角视图中沿着时间线对它们进行对比,以试图找出执行慢的goroutine究竟出了什么问题。2.4 后记虽然pprof和trace有着非常强大的profile能力,但在使用过程中,仍存在以下痛点:获取性能报告麻烦:一般大家做压测,为了更接近真实环境性能态,都使用生产环境/pre环境进行。而出于安全考虑,生产环境内网一般和PC办公内网是隔离不通的,需要单独配置通路才可以获得生产环境内网的profile 文件下载到PC办公电脑中,这也有一些额外的成本;查看profile分析报告麻烦:之前大家在本地查看profile 分析报告,一般 go tool pprof -http=":8083" profile 命令在本地PC开启一个web service 查看,并且需要至少安装graphviz 等库。查看trace分析同样麻烦:查看go trace 的profile 信息来分析routine 锁和生命周期时,也需要类似的方式在本地PC执行命令 go tool trace mytrace.profile 分享麻烦:如果我想把自己压测的性能结果内容,分享个另一位同学,那只能把1中获取的性能报告“profile文件”通过钉钉发给被分享人。然而有时候本地profile文件比较多,一不小心就发错了,还不如截图,但是截图又没有了交互放大、缩小、下钻等能力。处处不给力!留存复盘麻烦:系统的性能分析就像一份病历,每每看到阶段性的压测报告,总结或者对照时,不禁要询问,做过了哪些优化和改造,病因病灶是什么,有没有共性,值不值得总结归纳,现在是不是又面临相似的性能问题?那么能不能开发一个平台工具,解决以上的这些痛点呢?目前在阿里集团内部,高德的研发同学已经通过对go官方库的定制开发,实现了go语言性能平台,解决了以上这些痛点,并在内部进行了开源。该平台已面向阿里集团,累计实现性能场景快照数万条的获取和分析,解决了很多的线上服务性能调试和优化问题,这里暂时不展开,后续有机会可以单独分享。3. 性能调优-技巧篇除了前面提到的尽量用strconv而不是fmt.Sprint进行数字到字符串的转化以外,我们还将介绍一些在实际开发中经常会用到的技巧,供各位参考。3.1 字符串拼接拼接字符串为了书写方便快捷,最常用的两个方法是运算符 + 和 fmt.Sprintf()运算符 + 只能简单地完成字符串之间的拼接,fmt.Sprintf() 其底层实现使用了反射,性能上会有所损耗。从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少(<=5),拼接字符串推荐使用运算符 +,反之使用 fmt.Sprintf()。// 推荐:用+进行字符串拼接 func BenchmarkPlus(b *testing.B) { for i := 0; i < b.N; i++ { s := "a" + "b" _ = s } } // 不推荐:用fmt.Sprintf进行字符串拼接 func BenchmarkFmt(b *testing.B) { for i := 0; i < b.N; i++ { s := fmt.Sprintf("%s%s", "a", "b") _ = s } } goos: darwin goarch: amd64 pkg: main cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkPlus-12 1000000000 0.2658 ns/op 0 B/op 0 allocs/op BenchmarkFmt-12 16559949 70.83 ns/op 2 B/op 1 allocs/op PASS ok main 5.908s3.2 提前指定容器容量在初始化slice时,尽量指定容量,这是因为当添加元素时,如果容量的不足,slice会重新申请一个更大容量的容器,然后把原来的元素复制到新的容器中。// 推荐:初始化时指定容量 func BenchmarkGenerateWithCap(b *testing.B) { nums := make([]int, 0, 10000) for n := 0; n < b.N; n++ { for i:=0; i < 10000; i++ { nums = append(nums, i) } } } // 不推荐:初始化时不指定容量 func BenchmarkGenerate(b *testing.B) { nums := make([]int, 0) for n := 0; n < b.N; n++ { for i:=0; i < 10000; i++ { nums = append(nums, i) } } } goos: darwin goarch: amd64 pkg: main cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkGenerateWithCap-12 23508 336485 ns/op 476667 B/op 0 allocs/op BenchmarkGenerate-12 22620 68747 ns/op 426141 B/op 0 allocs/op PASS ok main 16.628s3.3 遍历 []struct{} 使用下标而不是 range常用的遍历方式有两种,一种是for循环下标遍历,一种是for循环range遍历,这两种遍历在性能上是否有差异呢?让我们来一探究竟。针对[]int,我们来看看两种遍历有和差别吧func getIntSlice() []int { nums := make([]int, 1024, 1024) for i := 0; i < 1024; i++ { nums[i] = i } return nums } // 用下标遍历[]int func BenchmarkIndexIntSlice(b *testing.B) { nums := getIntSlice() b.ResetTimer() for i := 0; i < b.N; i++ { var tmp int for k := 0; k < len(nums); k++ { tmp = nums[k] } _ = tmp } } // 用range遍历[]int元素 func BenchmarkRangeIntSlice(b *testing.B) { nums := getIntSlice() b.ResetTimer() for i := 0; i < b.N; i++ { var tmp int for _, num := range nums { tmp = num } _ = tmp } } goos: darwin goarch: amd64 pkg: demo/test cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkIndexIntSlice-12 3923230 270.2 ns/op 0 B/op 0 allocs/op BenchmarkRangeIntSlice-12 4518495 287.8 ns/op 0 B/op 0 allocs/op PASS ok demo/test 3.303s可以看到,在遍历[]int时,两种方式并无差别。我们再看看遍历[]struct{}的情况type Item struct { id int val [1024]byte } // 推荐:用下标遍历[]struct{} func BenchmarkIndexStructSlice(b *testing.B) { var items [1024]Item for i := 0; i < b.N; i++ { var tmp int for j := 0; j < len(items); j++ { tmp = items[j].id } _ = tmp } } // 推荐:用range的下标遍历[]struct{} func BenchmarkRangeIndexStructSlice(b *testing.B) { var items [1024]Item for i := 0; i < b.N; i++ { var tmp int for k := range items { tmp = items[k].id } _ = tmp } } // 不推荐:用range遍历[]struct{}的元素 func BenchmarkRangeStructSlice(b *testing.B) { var items [1024]Item for i := 0; i < b.N; i++ { var tmp int for _, item := range items { tmp = item.id } _ = tmp } } goos: darwin goarch: amd64 pkg: demo/test cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkIndexStructSlice-12 4413182 266.7 ns/op 0 B/op 0 allocs/op BenchmarkRangeIndexStructSlice-12 4545476 269.4 ns/op 0 B/op 0 allocs/op BenchmarkRangeStructSlice-12 33300 35444 ns/op 0 B/op 0 allocs/op PASS ok demo/test 5.282s可以看到,用for循环下标的方式性能都差不多,但是用range遍历数组里的元素时,性能则相差很多,前面两种方法是第三种方法的130多倍。主要原因是通过for k, v := range获取到的元素v实际上是原始值的一个拷贝。所以在面对复杂的struct进行遍历的时候,推荐使用下标。但是当遍历对象是复杂结构体的指针([]*struct{})时,用下标还是用range迭代元素的性能就差不多了。3.4 利用unsafe包避开内存copyunsafe包提供了任何类型的指针和 unsafe.Pointer 的相互转换及uintptr 类型和 unsafe.Pointer 可以相互转换,如下图unsafe包指针转换关系依据上述转换关系,其实除了string和[]byte的转换,也可以用于slice、map等的求长度及一些结构体的偏移量获取等,但是这种黑科技在一些情况下会带来一些匪夷所思的诡异问题,官方也不建议用,所以还是慎用,除非你确实很理解各种机制了,这里给出项目中实际用到的常规string和[]byte之间的转换,如下:func Str2bytes(s string) []byte { x := (*[2]uintptr)(unsafe.Pointer(&s)) h := [3]uintptr{x[0], x[1], x[1]} return *(*[]byte)(unsafe.Pointer(&h)) } func Bytes2str(b []byte) string { return *(*string)(unsafe.Pointer(&b)) } 我们通过benchmark来验证一下是否性能更优:// 推荐:用unsafe.Pointer实现string到bytes func BenchmarkStr2bytes(b *testing.B) { s := "testString" var bs []byte for n := 0; n < b.N; n++ { bs = Str2bytes(s) } _ = bs } // 不推荐:用类型转换实现string到bytes func BenchmarkStr2bytes2(b *testing.B) { s := "testString" var bs []byte for n := 0; n < b.N; n++ { bs = []byte(s) } _ = bs } // 推荐:用unsafe.Pointer实现bytes到string func BenchmarkBytes2str(b *testing.B) { bs := Str2bytes("testString") var s string b.ResetTimer() for n := 0; n < b.N; n++ { s = Bytes2str(bs) } _ = s } // 不推荐:用类型转换实现bytes到string func BenchmarkBytes2str2(b *testing.B) { bs := Str2bytes("testString") var s string b.ResetTimer() for n := 0; n < b.N; n++ { s = string(bs) } _ = s } goos: darwin goarch: amd64 pkg: demo/test cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkStr2bytes-12 1000000000 0.2938 ns/op 0 B/op 0 allocs/op BenchmarkStr2bytes2-12 38193139 28.39 ns/op 16 B/op 1 allocs/op BenchmarkBytes2str-12 1000000000 0.2552 ns/op 0 B/op 0 allocs/op BenchmarkBytes2str2-12 60836140 19.60 ns/op 16 B/op 1 allocs/op PASS ok demo/test 3.301s可以看到使用unsafe.Pointer比强制类型转换性能是要高不少的,从内存分配上也可以看到完全没有新的内存被分配。3.5 协程池go语言最大的特色就是很容易的创建协程,同时go语言的协程调度策略也让go程序可以最大化的利用cpu资源,减少线程切换。但是无限度的创建goroutine,仍然会带来问题。我们知道,一个go协程占用内存大小在2KB左右,无限度的创建协程除了会占用大量的内存空间,同时协程的切换也有不少开销,一次协程切换大概需要100ns,虽然相较于线程毫秒级的切换要优秀很多,但依然存在开销,而且这些协程最后还是需要GC来回收,过多的创建协程,对GC也是很大的压力。所以我们在使用协程时,可以通过协程池来限制goroutine数量,避免无限制的增长。限制协程的方式有很多,比如可以用channel来限制:var wg sync.WaitGroup ch := make(chan struct{}, 3) for i := 0; i < 10; i++ { ch <- struct{}{} wg.Add(1) go func(i int) { defer wg.Done() log.Println(i) time.Sleep(time.Second) <-ch }(i) } wg.Wait()这里通过限制channel长度为3,可以实现最多只有3个协程被创建的效果。当然也可以使用@烟渺实现的errgoup。使用方法如下:func Test_ErrGroupRun(t *testing.T) { errgroup := WithTimeout(nil, 10*time.Second) errgroup.SetMaxProcs(4) for index := 0; index < 10; index++ { errgroup.Run(nil, index, "test", func(context *gin.Context, i interface{}) (interface{}, error) { t.Logf("[%s]input:%+v, time:%s", "test", i, time.Now().Format("2006-01-02 15:04:05")) time.Sleep(2*time.Second) return i, nil }) } errgroup.Wait() }输出结果如下:=== RUN Test_ErrGroupRun errgroup_test.go:23: [test]input:0, time:2022-12-04 17:31:29 errgroup_test.go:23: [test]input:3, time:2022-12-04 17:31:29 errgroup_test.go:23: [test]input:1, time:2022-12-04 17:31:29 errgroup_test.go:23: [test]input:2, time:2022-12-04 17:31:29 errgroup_test.go:23: [test]input:4, time:2022-12-04 17:31:31 errgroup_test.go:23: [test]input:5, time:2022-12-04 17:31:31 errgroup_test.go:23: [test]input:6, time:2022-12-04 17:31:31 errgroup_test.go:23: [test]input:7, time:2022-12-04 17:31:31 errgroup_test.go:23: [test]input:8, time:2022-12-04 17:31:33 errgroup_test.go:23: [test]input:9, time:2022-12-04 17:31:33 --- PASS: Test_ErrGroupRun (6.00s) PASSerrgroup可以通过SetMaxProcs设定协程池的大小,从上面的结果可以看到,最多就4个协程在运行。3.6 sync.Pool 对象复用我们在代码中经常会用到json进行序列化和反序列化,举一个投放活动的例子,一个投放活动会有许多字段会转换为字节数组。type ActTask struct { Id int64 `ddb:"id"` // 主键id Status common.Status `ddb:"status"` // 状态 0=初始 1=生效 2=失效 3=过期 BizProd common.BizProd `ddb:"biz_prod"` // 业务类型 Name string `ddb:"name"` // 活动名 Adcode string `ddb:"adcode"` // 城市 RealTimeRuleByte []byte `ddb:"realtime_rule"` // 实时规则json ... } type RealTimeRuleStruct struct { Filter []*struct { PropertyId int64 `json:"property_id"` PropertyCode string `json:"property_code"` Operator string `json:"operator"` Value []string `json:"value"` } `json:"filter"` ExtData [1024]byte `json:"ext_data"` } func (at *ActTask) RealTimeRule() *form.RealTimeRule { if err := json.Unmarshal(at.RealTimeRuleByte, &at.RealTimeRuleStruct); err != nil { return nil } return at.RealTimeRuleStruct }以这里的实时投放规则为例,我们会将过滤规则反序列化为字节数组。每次json.Unmarshal都会申请一个临时的结构体对象,而这些对象都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。对于需要频繁创建并回收的对象,我们可以使用对象池来提升性能。sync.Pool可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系统的性能。sync.Pool的使用方法很简单,只需要实现 New 函数即可。对象池中没有对象时,将会调用 New 函数创建。var realTimeRulePool = sync.Pool{ New: func() interface{} { return new(RealTimeRuleStruct) }, }然后调用 Pool 的 Get() 和 Put() 方法来获取和放回池子中。rule := realTimeRulePool.Get().(*RealTimeRuleStruct) json.Unmarshal(buf, rule) realTimeRulePool.Put(rule)Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。Put() 则是在对象使用完毕后,放回到对象池。接下来我们进行性能测试,看看性能如何var realTimeRule = []byte("{\\\"filter\\\":[{\\\"property_id\\\":2,\\\"property_code\\\":\\\"search_poiid_industry\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"yimei\\\"]},{\\\"property_id\\\":4,\\\"property_code\\\":\\\"request_page_id\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"all\\\"]}],\\\"white_list\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"white_list_for_adiu\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"j838ef77bf227chcl89888f3fb0946\\\",\\\"lb89bea9af558589i55559764bc83e\\\"]}],\\\"ipc_user_tag\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"ipc_crowd_tag\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"test_20227041152_mix_ipc_tag\\\"]}],\\\"relation_id\\\":0,\\\"is_copy\\\":true}") // 推荐:复用一个对象,不用每次都生成新的 func BenchmarkUnmarshalWithPool(b *testing.B) { for n := 0; n < b.N; n++ { task := realTimeRulePool.Get().(*RealTimeRuleStruct) json.Unmarshal(realTimeRule, task) realTimeRulePool.Put(task) } } // 不推荐:每次都会生成一个新的临时对象 func BenchmarkUnmarshal(b *testing.B) { for n := 0; n < b.N; n++ { task := &RealTimeRuleStruct{} json.Unmarshal(realTimeRule, task) } } goos: darwin goarch: amd64 pkg: demo/test cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkUnmarshalWithPool-12 3627546 319.4 ns/op 312 B/op 7 allocs/op BenchmarkUnmarshal-12 2342208 490.8 ns/op 1464 B/op 8 allocs/op PASS ok demo/test 3.525s可以看到,两种方法在时间消耗上差不太多,但是在内存分配上差距明显,使用sync.Pool后内存占用仅为不使用的1/5。3.7 避免系统调用系统调用是一个很耗时的操作,在各种语言中都是,go也不例外,在go的GPM模型中,异步系统调用G会和MP分离,同步系统调用GM会和P分离,不管何种形式除了状态切换及内核态中执行操作耗时外,调度器本身的调度也耗时。所以在可以避免系统调用的地方尽量去避免// 推荐:不使用系统调用 func BenchmarkNoSytemcall(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { if configs.PUBLIC_KEY != nil { } } }) } // 不推荐:使用系统调用 func BenchmarkSytemcall(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { if os.Getenv("PUBLIC_KEY") != "" { } } }) } goos: darwin goarch: amd64 pkg: demo/test cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz BenchmarkNoSytemcall-12 1000000000 0.1495 ns/op 0 B/op 0 allocs/op BenchmarkSytemcall-12 37224988 31.10 ns/op 0 B/op 0 allocs/op PASS ok demo/test 1.877s4. 性能调优-实战篇案例1: go协程创建数据库连接不释放导致内存暴涨应用背景感谢@路现提供的案例。遇到的问题及表象特征线上机器偶尔出现内存使用率超过百分之九十报警。分析思路及排查方向在报警触发时,通过直接拉取线上应用的profile文件,查看内存分配情况,我们看到内存分配主要产生在本地缓存的组件上。但是分析代码并没有发现存在内存泄露的情况,看着像是资源一直没有被释放,进一步分析goroutine的profile文件发现存在大量的goroutine未释放,表现在本地缓存击穿后回源数据库,对数据库的查询访问一直不释放。调优手段与效果最终通过排查,发现使用的数据库组件存在bug,在极端情况下会出现死锁的情况,导致数据库访问请求无法返回也无法释放。最终bug修复后升级数据库组件版本解决了问题。案例2: 优惠索引内存分配大,gc 耗时高应用背景感谢@梅东提供的案例。遇到的问题及表象特征接口tp99高,偶尔会有一些特别耗时的请求,导致用户的优惠信息展示不出来分析思路及排查方向通过直接在平台上抓包观察,我们发现使用的分配索引这个方法占用的堆内存特别高,通过 top 可以看到是排在第一位的我们分析代码,可以看到,获取城市索引的地方,每次都是重新申请了内存的,通过改动为返回指针,就不需要每次都单独申请内存了,核心代码改动:调优手段与效果修改后,上线观察,可以看到使用中的内存以及gc耗时都有了明显降低案例3:流量上涨导致cpu异常飙升应用背景感谢@君度提供的案例。遇到的问题及表象特征能量站v2接口和task-home-page接口流量较大时,会造成ab实验策略匹配时cpu飙升分析思路及排查方向调优手段与效果主要优化点如下:1、优化toEntity方法,简化为单独的ID()方法2、优化数组、map初始化结构3、优化adCode转换为string过程4、关闭过多的match log打印优化后profile:优化上线前后CPU的对比案例4: 内存对象未释放导致内存泄漏应用背景感谢@淳深提供的案例,提供案例的服务,日常流量峰值在百万qps左右,是高德内部十分重要的服务。此前该服务是由java实现的,后来用go语言进行重构,在重构完成切全量后,有许多性能优化的优秀案例,这里选取内存泄漏相关的一个案例分享给大家,希望对大家在自己服务进行内存泄漏问题排查时能提供参考和帮助。遇到的问题及表象特征go语言版本全量切流后,每天会对服务各项指标进行详细review,发现每日内存上涨约0.4%,如下图在go版本服务切全量前,从第一张图可以看到整个内存使用是平稳的,无上涨趋势,但是切go版本后,从第二张图可以看到,整个内存曲线呈上升趋势,遂认定内存泄漏,开始排查内存泄漏的“罪魁祸首”。分析思路及排查方向我们先到线上机器抓取当前时间的heap文件,间隔一天后再次抓取heap文件,通过pprof diff对比,我们发现time.NewTicker的内存占用增长了几十MB(由于未保留当时的heap文件,此处没有截图),通过调用栈信息,我们找到了问题的源头,来自中间件vipserver client的SrvHost方法,通过深扒vipserver client代码,我们发现,每个vipserver域名都会有一个对应的协程,这个协程每隔三秒钟就会新建一个ticker对象,且用过的ticker对象没有stop,也就不会释放相应的内存资源。而这个time.NewTicker会创建一个timer对象,这个对象会占用72字节内存。在服务运行一天的情况下,进过计算,该对象累计会在内存中占用约35.6MB,和上述内存每日增长0.4%正好能对上,我们就能断定这个内存泄漏来自这里。调优手段与效果知道是timer对象重复创建的问题后,只需要修改这部分的代码就好了,最新的vipserver client修改了此处的逻辑,如下修改完后,运行一段时间,内存运行状态平稳,已无内存泄漏问题。结语目前go语言不仅在阿里集团内部,在整个互联网行业内也越来越流行,希望本文能为正在使用go语言的同学在性能优化方面带来一些参考价值。在阿里集团内部,高德也是最早规模化使用go语言的团队之一,目前高德线上运行的go服务已经达到近百个,整体qps已突破百万量级。在使用go语言的同时,高德也为集团内go语言生态建设做出了许多贡献,包括开发支持阿里集团常见的中间件(比如配置中心-Diamond、分布式RPC服务框架-HSF、服务发现-Vipserver、消息队列-MetaQ、流量控制-Sentinel、日志追踪-Eagleeye等)go语言版本,并被阿里中间件团队官方收录。但是go语言生态建设仍然有很长的道路要走,希望能有更多对go感兴趣的同学能够加入我们,一起参与阿里的go生态建设,乃至为互联网业界的go生态发展添砖加瓦。
文章
设计模式  ·  缓存  ·  Java  ·  中间件  ·  测试技术  ·  Go  ·  调度  ·  数据库  ·  索引  ·  容器
2023-03-03
Java代码是如何被CPU狂飙起来的?
无论是刚刚入门Java的新手还是已经工作了的老司机,恐怕都不容易把Java代码如何一步步被CPU执行起来这个问题完全讲清楚。但是对于一个Java程序员来说写了那么久的代码,我们总要搞清楚自己写的Java代码到底是怎么运行起来的。另外在求职面试的时候这个问题也常常会聊到,面试官主要想通过它考察求职同学对于Java以及计算机基础技术体系的理解程度,看似简单的问题实际上囊括了JVM运行原理、操作系统以及CPU运行原理等多方面的技术知识点。我们一起来看看Java代码到底是怎么被运行起来的。Java如何实现跨平台在介绍Java如何一步步被执行起来之前,我们需要先弄明白为什么Java可以实现跨平台运行,因为搞清楚了这个问题之后,对于我们理解Java程序如何被CPU执行起来非常有帮助。为什么需要JVMwrite once run anywhere曾经是Java响彻编程语言圈的slogan,也就是所谓的程序员开发完java应用程序后,可以在不需要做任何调整的情况下,无差别的在任何支持Java的平台上运行,并获得相同的运行结果从而实现跨平台运行,那么Java到底是如何做到这一点的呢?其实对于大多数的编程语言来说,都需要将程序转换为机器语言才能最终被CPU执行起来。因为无论是如Java这种高级语言还是像汇编这种低级语言实际上都是给人看的,但是计算机无法直接进行识别运行。因此想要CPU执行程序就必须要进行语言转换,将程序语言转化为CPU可以识别的机器语言。学过计算机组成原理的同学肯定都知道,CPU内部都是用大规模晶体管组合而成的,而晶体管只有高电位以及低点位两种状态,正好对应二进制的0和1,因此机器码实际就是由0和1组成的二进制编码集合,它可以被CPU直接识别和执行。但是像X86架构或者ARM架构,不同类型的平台对应的机器语言是不一样的,这里的机器语言指的是用二进制表示的计算机可以直接识别和执行的指令集集合。不同平台使用的CPU不同,那么对应的指令集也就有所差异,比如说X86使用的是CISC复杂指令集而ARM使用的是RISC精简指令集。所以Java要想实现跨平台运行就必须要屏蔽不同架构下的计算机底层细节差异。因此,如何解决不同平台下机器语言的适配问题是Java实现一次编写,到处运行的关键所在。那么Java到底是如何解决这个问题的呢?怎么才能让CPU可以看懂程序员写的Java代码呢?其实这就像在我们的日常生活中,如果双方语言不通,要想进行交流的话就必须中间得有一个翻译,这样通过翻译的语言转换就可以实现双方畅通无阻的交流了。打个比方,一个中国厨师要教法国厨师和阿拉伯厨师做菜,中国厨师不懂法语和阿拉伯语,法国厨师和阿拉伯厨师不懂中文,要想顺利把菜做好就需要有翻译来帮忙。中国厨师把做菜的菜谱告诉翻译者,翻译者将中文菜谱转换为法文菜谱以及阿拉伯语菜谱,这样法国厨师和阿拉伯厨师就知道怎么做菜了。因此Java的设计者借助了这样的思想,通过JVM(Java Virtual Machine,Java虚拟机)这个中间翻译来实现语言转换。程序员编写以.java为结尾的程序之后通过javac编译器把.java为结尾的程序文件编译成.class结尾的字节码文件,这个字节码文件需要JVM这个中间翻译进行识别解析,它由一组如下图这样的16进制数组成。JVM将字节码文件转化为汇编语言后再由硬件解析为机器语言最终最终交给CPU执行。所以说通过JVM实现了计算机底层细节的屏蔽,因此windows平台有windows平台的JVM,Linux平台有Linux平台的JVM,这样在不同平台上存在对应的JVM充当中间翻译的作用。因此只要编译一次,不同平台的JVM都可以将对应的字节码文件进行解析后运行,从而实现在不同平台下运行的效果。那么问题又来了,JVM是怎么解析运行.class文件的呢?要想搞清楚这个问题,我们得先看看JVM的内存结构到底是怎样的,了解JVM结构之后这个问题就迎刃而解了。JVM结构JVM(Java Virtual Machine)即Java虚拟机,它的核心作用主要有两个,一个是运行Java应用程序,另一个是管理Java应用程序的内存。它主要由三部分组成,类加载器、运行时数据区以及字节码执行引擎。类加载器类加载器负责将字节码文件加载到内存中,主要经历加载-》连接-》实例化三个阶段完成类加载操作。另外需要注意的是.class并不是一次性全部加载到内存中,而是在Java应用程序需要的时候才会加载。也就是说当JVM请求一个类进行加载的时候,类加载器就会尝试查找定位这个类,当查找对应的类之后将他的完全限定类定义加载到运行时数据区中。运行时数据区JVM定义了在Java程序运行期间需要使用到的内存区域,简单来说这块内存区域存放了字节码信息以及程序执行过程数据。运行时数据区主要划分了堆、程序计数器虚拟机栈、本地方法栈以及元空间数据区。其中堆数据区域在JVM启动后便会进行分配,而虚拟机栈、程序计数器本地方法栈都是在常见线程后进行分配。不过需要说明的是在JDK 1.8及以后的版本中,方法区被移除了,取而代之的是元空间(Metaspace)。元空间与方法区的作用相似,都是存储类的结构信息,包括类的定义、方法的定义、字段的定义以及字节码指令。不同的是,元空间不再是JVM内存的一部分,而是通过本地内存(Native Memory)来实现的。在JVM启动时,元空间的大小由MaxMetaspaceSize参数指定,JVM在运行时会自动调整元空间的大小,以适应不同的程序需求。字节码执行引擎字节码执行引擎最核心的作用就是将字节码文件解释为可执行程序,主要包含了解释器、即使编译以及垃圾回收器。字节码执行引擎从元空间获取字节码指令进行执行。当Java程序调用一个方法时,JVM会根据方法的描述符和方法所在的类在元空间中查找对应的字节码指令。字节码执行引擎从元空间获取字节码指令,然后执行这些指令。JVM如何运行Java程序在搞清楚了JVM的结构之后,接下来我们一起来看看天天写的Java代码是如何被CPU飙起来的。一般公司的研发流程都是产品经理提需求然后程序员来实现。所以当产品经理把需求提过来之后,程序员就需要分析需求进行设计然后编码实现,比如我们通过Idea来完成编码工作,这个时候工程中就会有一堆的以.java结尾的Java代码文件,实际上就是程序员将产品需求转化为对应的Java程序。但是这个.java结尾的Java代码文件是给程序员看的,计算机无法识别,所以需要进行转换,转换为计算机可以识别的机器语言。通过上文我们知道,Java为了实现write once,run anywhere的宏伟目标设计了JVM来充当转换翻译的工作。因此我们编写好的.java文件需要通过javac编译成.class文件,这个class文件就是传说中的字节码文件,而字节码文件就是JVM的输入。当我们有了.class文件也就是字节码文件之后,就需要启动一个JVM实例来进一步加载解析.class字节码。实际上JVM本质其实就是操作系统中的一个进程,因此要想通过JVM加载解析.class文件,必须先启动一个JVM进程。JVM进程启动之后通过类加载器加载.class文件,将字节码加载到JVM对应的内存空间。当.class文件对应的字节码信息被加载到中之后,操作系统会调度CPU资源来按照对应的指令执行java程序。以上是CPU执行Java代码的大致步骤,看到这里我相信很多同学都有疑问这个执行步骤也太大致了吧。哈哈,别着急,有了基本的解析流程之后我们再对其中的细节进行分析,首先我们就需要弄清楚JVM是如何加载编译后的.class文件的。字节码文件结构要想搞清楚JVM如何加载解析字节码文件,我们就先得弄明白字节码文件的格式,因为任何文件的解析都是根据该文件的格式来进行。就像CPU有自己的指令集一样,JVM也有自己一套指令集也就是Java字节码,从根上来说Java字节码是机器语言的.class文件表现形式。字节码文件结构是一组以 8 位为最小单元的十六进制数据流,具体的结构如下图所示,主要包含了魔数、class文件版本、常量池、访问标志、索引、字段表集合、方法表集合以及属性表集合描述数据信息。这里简单说明下各个部分的作用,后面会有专门的文章再详细进行阐述。魔数与文件版本魔数的作用就是告诉JVM自己是一个字节码文件,你JVM快来加载我吧,对于Java字节码文件来说,其魔数为0xCAFEBABE,现在知道为什么Java的标志是咖啡了吧。而紧随魔数之后的两个字节是文件版本号,Java的版本号通常是以52.0的形式表示,其中高16位表示主版本号,低16位表示次版本号。常量池在常量池中说明常量个数以及具体的常量信息,常量池中主要存放了字面量以及符号引用这两类常量数据,所谓字面量就是代码中声明为final的常量值,而符号引用主要为类和接口的完全限定名、字段的名称和描述符以及方法的名称以及描述符。这些信息在加载到JVM之后在运行期间将符号引用转化为直接引用才能被真正使用。常量池的第一个元素是常量池大小,占据两个字节。常量池表的索引从1开始,而不是从0开始,这是因为常量池的第0个位置是用于特殊用途的。访问标志类或者接口的访问标记,说明类是public还是abstract,用于描述该类的访问级别和属性。访问标志的取值范围是一个16位的二进制数。索引包含了类索引、父类索引、接口索引数据,主要说明类的继承关系。字段表集合主要是类级变量而不是方法内部的局部变量。方法表集合主要用来描述类中有几个方法,每个方法的具体信息,包含了方法访问标识、方法名称索引、方法描述符索引、属性计数器、属性表等信息,总之就是描述方法的基础信息。属性表集合方法表集合之后是属性表集合,用于描述该类的所有属性。属性表集合包含了所有该类的属性的描述信息,包括属性名称、属性类型、属性值等等。解析字节码文件知道了字节码文件的结构之后,JVM就需要对字节码文件进行解析,将字节码结构解析为JVM内部流转的数据结构。大致的过程如下:1、读取字节码文件JVM首先需要读取字节码文件的二进制数据,这通常是通过文件输入流来完成的。2、解析字节码JVM解析字节码的过程是将字节码文件中的二进制数据解析为Java虚拟机中的数据结构。首先JVM首先会读取字节码文件的前四个字节,判断魔数是否为0xCAFEBABE,以此来确认该文件是否是一个有效的Java字节码文件。JVM接着会解析常量池表,将其中的常量转换为Java虚拟机中的数据结构,例如将字符串常量转换为Java字符串对象。解析类、接口、字段、方法等信息:JVM会依次解析类索引、父类索引、接口索引集合、字段表集合、方法表集合等信息,将这些信息转换为Java虚拟机中的数据结构。最后,JVM将解析得到的数据结构组装成一个Java类的结构,并将其放入元空间中。在完成字节码文件解析之后,接下来就需要类加载器闪亮登场了,类加载器会将类文件加载到JVM内存中,并为该类生成一个Class对象。类加载加载器启动我们都知道,Java应用的类都是通过类加载器加载到运行时数据区的,这里很多同学可能会有疑问,那么类加载器本身又是被谁加载的呢?这有点像先有鸡还是先有蛋的灵魂拷问。实际上类加载器启动大致会经历如下几个阶段:1、以linux系统为例,当我们通过"java"启动一个Java应用的时候,其实就是启动了一个JVM进程实例,此时操作系统会为这个JVM进程实例分配CPU、内存等系统资源;2、"java"可执行文件此时就会解析相关的启动参数,主要包括了查找jre路径、各种包的路径以及虚拟机参数等,进而获取定位libjvm.so位置,通过libjvm.so来启动JVM进程实例;3、当JVM启动后会创建引导类加载器Bootsrap ClassLoader,这个ClassLoader是C++语言实现的,它是最基础的类加载器,没有父类加载器。通过它加载Java应用运行时所需要的基础类,主要包括JAVA_HOME/jre/lib下的rt.jar等基础jar包;4、而在rt.jar中包含了Launcher类,当Launcher类被加载之后,就会触发创建Launcher静态实例对象,而Launcher类的构造函数中,完成了对于ExtClassLoader及AppClassLoader的创建。Launcher类的部分代码如下所示:public class Launcher { private static URLStreamHandlerFactory factory = new Factory(); //类静态实例 private static Launcher launcher = new Launcher(); private static String bootClassPath = System.getProperty("sun.boot.class.path"); private ClassLoader loader; private static URLStreamHandler fileHandler; public static Launcher getLauncher() { return launcher; } //Launcher构造器 public Launcher() { ExtClassLoader var1; try { var1 = Launcher.ExtClassLoader.getExtClassLoader(); } catch (IOException var10) { throw new InternalError("Could not create extension class loader", var10); } try { this.loader = Launcher.AppClassLoader.getAppClassLoader(var1); } catch (IOException var9) { throw new InternalError("Could not create application class loader", var9); } Thread.currentThread().setContextClassLoader(this.loader); String var2 = System.getProperty("java.security.manager"); if (var2 != null) { SecurityManager var3 = null; if (!"".equals(var2) && !"default".equals(var2)) { try { var3 = (SecurityManager)this.loader.loadClass(var2).newInstance(); } catch (IllegalAccessException var5) { } catch (InstantiationException var6) { } catch (ClassNotFoundException var7) { } catch (ClassCastException var8) { } } else { var3 = new SecurityManager(); } if (var3 == null) { throw new InternalError("Could not create SecurityManager: " + var2); } System.setSecurityManager(var3); } } ... }双亲委派模型为了保证Java程序的安全性和稳定性,JVM设计了双亲委派模型类加载机制。在双亲委派模型中,启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)以及应用程序类加载器(Application ClassLoader)按照一个父子关系形成了一个层次结构,其中启动类加载器位于最顶层,应用程序类加载器位于最底层。当一个类加载器需要加载一个类时,它首先会委派给它的父类加载器去尝试加载这个类。如果父类加载器能够成功加载这个类,那么就直接返回这个类的Class对象,如果父类加载器无法加载这个类,那么就会交给子类加载器去尝试加载这个类。这个过程会一直持续到顶层的启动类加载器。通过这种双亲委派模型,可以保证同一个类在不同的类加载器中只会被加载一次,从而避免了类的重复加载,也保证了类的唯一性。同时,由于每个类加载器只会加载自己所负责的类,因此可以防止恶意代码的注入和类的篡改,提高了Java程序的安全性。数据流转过程当类加载器完成字节码数据加载任务之后,JVM划分了专门的内存区域内承载这些字节码数据以及运行时中间数据。其中程序计数器、虚拟机栈以及本地方法栈属于线程私有的,堆以及元数据区属于共享数据区,不同的线程共享这两部分内存数据。我们还是以下面这段代码来说明程序运行的时候,各部分数据在Runtime data area中是如何流转的。public class Test { public static void main(String[] args) { User user = new User(); Integer result = calculate(user.getAge()); System.out.println(result); } private static Integer calculate(Integer age) { Integer data = age + 3; return data; } }以上代码对应的字节码指令如下所示:如上代码所示,JVM创建线程来承载代码的执行过程,我们可以将线程理解为一个按照一定顺序执行的控制流。当线程创建之后,同时创建该线程独享的程序计数器(Program Counter Register)以及Java虚拟机栈(Java Virtual Machine Stack)。如果当前虚拟机中的线程执行的是Java方法,那么此时程序计数器中起初存储的是方法的第一条指令,当方法开始执行之后,PC寄存器存储的是下一个字节码指令的地址。但是如果当前虚拟机中的线程执行的是naive方法,那么程序计数器中的值为undefined。那么程序计数器中的值又是怎么被改变的呢?如果是正常进行代码执行,那么当线程执行字节码指令时,程序计数器会进行自动加1指向下一条字节码指令地址。但是如果遇到判断分支、循环以及异常等不同的控制转移语句,程序计数器会被置为目标字节码指令的地址。另外在多线程切换的时候,虚拟机会记录当前线程的程序计数器,当线程切换回来的时候会根据此前记录的值恢复到程序计数器中,来继续执行线程的后续的字节码指令。除了程序计数器之外,字节码指令的执行流转还需要虚拟机栈的参与。我们先来看下虚拟机栈的大致结构,如下图所示,栈大家肯定都知道,它是一个先入后出的数据结构,非常适合配合方法的执行过程。虚拟机栈操作的基本元素就是栈帧,栈帧的结构主要包含了局部变量、操作数栈、动态连接以及方法返回地址这几个部分。局部变量:主要存放了栈帧对应方法的参数以及方法中定义的局部变量,实际上它是一个以0为起始索引的数组结构,可以通过索引来访问局部变量表中的元素,还包括了基本类型以及对象引用等。非静态方法中,第0个槽位默认是用于存储this指针,而其他参数和变量则会从第1个槽位开始存储。在静态方法中,第0个槽位可以用来存放方法的参数或者其他的数据。操作数栈:和虚拟机栈一样操作数栈也是一个栈数据结构,只不过两者存储的对象不一样。操作数栈主要存储了方法内部操作数的值以及计算结果,操作数栈会将运算的参与方以及计算结果都压入操作数栈中,后续的指令操作就可以从操作数栈中使用这些值来进行计算。当方法有返回值的时候,返回值也会被压入操作数栈中,这样方法调用者可以获取到返回值。动态链接:一个类中的方法可能会被程序中的其他多个类所共享使用,因此在编译期间实际无法确定方法的实际位置到底在哪里,因此需要在运行时动态链接来确定方法对应的地址。动态链接是通过在栈帧中维护一张方法调用的符号表来实现的。这张符号表中保存了当前方法中所有调用的方法的符号引用,包括方法名、参数类型和返回值类型等信息。当方法需要调用另一个方法时,它会在符号表中查找所需方法的符号引用,然后进行动态链接,确定方法的具体内存地址。这样,就能够正确地调用所需的方法。方法返回地址:当一个方法执行完毕后,JVM会将记录的方法返回地址数据置入程序计数器中,这样字节码执行引擎可以根据程序计数器中的地址继续向后执行字节码指令。同时JVM会将方法返回值压入调用方的操作栈中以便于后续的指令计算,操作完成之后从虚拟机栈中奖栈帧进行弹出。知道了虚拟机栈的结构之后,我们来看下方法执行的流转过程是怎样的。1、JVM启动完成.class文件加载之后,它会创建一个名为"main"的线程,并且该线程会自动调用定义在该类中的名为"main"的静态方法,这也是Java程序的入口点;2、当JVM在主线程中调用当方法的时候就会创建当前线程独享的程序计数器以及虚拟机栈,在Test.class类中,开始执行mian方法 ,因此JVM会虚拟机栈中压入main方法对应的帧栈帧;3、在栈帧的操作数栈中存储了操作的数据,JVM执行字节码指令的时候从操作数栈中获取数据,执行计算操作之后再将结果压入操作数栈;4、当进行calculate方法调用的时候,虚拟机栈继续压入calculate方法对应的栈帧,被调用方法的参数、局部变量和操作数栈等信息会存储在新创建的栈帧中。其中该栈帧中的方法返回地址中存放了main方法执行的地址信息,方便在调用方法执行完成后继续恢复调用前的代码执行;5、对于age + 3一条加法指令,在执行该指令之前,JVM会将操作数栈顶部的两个元素弹出,并将它们相加,然后将结果推入操作数栈中。在这个例子中,指令的操作码是“add”,它表示执行加法操作;操作数是0,它表示从操作数栈的顶部获取第一个操作数;操作数是1,它表示从操作数栈的次顶部获取第二个操作数;6、程序计数器中存储了下一条需要执行操作的字节码指令的地址,因此Java线程执行业务逻辑的时候必须借助于程序计数器才能获得下一步命令的地址;7、当calculate方法执行完成之后,对应的栈帧将从虚拟机栈中弹出,其中方法执行的结果会被压入main方法对应的栈帧中的操作数栈中,而方法返回地址被重置到main现场对应的程序计数器中,以便于后续字节码执行引擎从程序计数器中获取下一条命令的地址。如果方法没有返回值,JVM仍然会将一个null值推送到调用该方法的栈帧的操作数栈中,作为占位符,以便恢复调用方的操作数栈状态。8、字节码执行引擎中的解释器会从程序计数器中获取下一个字节码指令的地址,也就是从元空间中获取对应的字节码指令,在获取到指令之后,通过翻译器翻译为对应的汇编语言而再交给硬件解析为机器指令,最终由CPU进行执行,而后再将执行结果进行写回。CPU执行程序通过上文我们知道无论什么编程语言最终都需要转化为机器语言才能被CPU执行,但是CPU、内存这些硬件资源并不是直接可以和应用程序打交道,而是通过操作系统来进行统一管理的。对于CPU来说,操作系统通过调度器(Scheduler)来决定哪些进程可以被CPU执行,并为它们分配时间片。它会从就绪队列中选择一个进程并将其分配给CPU执行。当一个进程的时间片用完或者发生了I/O等事件时,CPU会被释放,操作系统的调度器会重新选择一个进程并将其分配给CPU执行。也就是说操作系统通过进程调度算法来管理CPU的分配以及调度,进程调度算法的目的就是为了最大化CPU使用率,避免出现任务分配不均空闲等待的情况。主要的进程调度算法包括了FCFS、SJF、RR、MLFQ等。CPU如何执行指令?前文中我们大致搞清楚了类是如何被加载的,各部分类字节码数据在运行时数据区怎么流转以及字节码执行引擎翻译字节码。实际上在运行时数据区数据流转的过程中,CPU已经参与其中了。程序的本质是为了根据输入获得相应的输出,而CPU本质就是根据程序的指令一步步执行获得结果的工具。对于CPU来说,它核心工作主要分为如下三个步骤;1、获取指令CPU从PC寄存器中获取对应的指令地址,此处的指令地址是将要执行指令的地址,根据指令地址获取对应的操作指令到指令寄存中,此时如果是顺存执行则PC寄存器地址会自动加1,但是如果程序涉及到条件、循环等分支执行逻辑,那么PC寄存器的地址就会被修改为下一条指令执行的地址。2、指令译码将获取到的指令进行翻译,搞清楚哪些是操作码哪些是操作数。CPU首先读取指令中的操作码然后根据操作码来确定该指令的类型以及需要进行的操作,CPU接着根据操作码来确定指令所需的寄存器和内存地址,并将它们提取出来。3、执行指令经过指令译码之后,CPU根据获取到的指令进行具体的执行操作,并将指令运算的结果存储回内存或者寄存器中。因此一旦CPU上电之后,它就像一个勤劳的小蜜蜂一样,一直不断重复着获取指令-》指令译码-》执行指令的循环操作。CPU如何响应中断?当操作系统需要执行某些操作时,它会发送一个中断请求给CPU。CPU在接收到中断请求后,会停止当前的任务,并转而执行中断处理程序,这个处理程序是由操作系统提供的。中断处理程序会根据中断类型,执行相应的操作,并返回到原来的任务继续执行。在执行完中断处理程序后,CPU会将之前保存的程序现场信息恢复,然后继续执行被中断的程序。这个过程叫做中断返回(Interrupt Return,IRET)。在中断返回过程中,CPU会将处理完的结果保存在寄存器中,然后从栈中弹出被中断的程序的现场信息,恢复之前的现场状态,最后再次执行被中断的程序,继续执行之前被中断的指令。那么CPU又是如何响应中断的呢?主要经历了以下几个步骤:1、保存当前程序状态CPU会将当前程序的状态(如程序计数器、寄存器、标志位等)保存到内存或栈中,以便在中断处理程序执行完毕后恢复现场。2、确定中断类型CPU会检查中断信号的类型,以确定需要执行哪个中断处理程序。3、转移控制权CPU会将程序的控制权转移到中断处理程序的入口地址,开始执行中断处理程序。4、执行中断处理程序中断处理程序会根据中断类型执行相应的操作,这些操作可能包括保存现场信息、读取中断事件的相关数据、执行特定的操作,以及返回到原来的程序继续执行等。5、恢复现场中断处理程序执行完毕后,CPU会从保存的现场信息中恢复原来程序的状态,然后将控制权返回到原来的程序中,继续执行被中断的指令。后记很多时候看似理所当然的问题,当我们深究下去就会发现原来别有一番天地。正如阿里王坚博士说的那样,要想看一个人对某个领域的知识掌握的情况,那就看他能就这个领域的知识能讲多长时间。想想的确如此,如果我们能够对某个知识点高度提炼同时又可以细节满满的进行展开阐述,那我们对于这个领域的理解程度就会鞭辟入里。这种检验自己知识学习深度的方式也推荐给大家。
文章
存储  ·  算法  ·  Java  ·  程序员  ·  Linux  ·  编译器  ·  调度  ·  C++  ·  索引  ·  Windows
2023-03-09
PolarDB-PG | PostgreSQL + 阿里云OSS 实现高效低价的海量数据冷热存储分离
背景数据库里的历史数据越来越多, 占用空间大, 备份慢, 恢复慢, 查询少但是很费钱, 迁移慢. 怎么办?冷热分离方案:使用PostgreSQL 或者 PolarDB-PG, 将历史数据存成parquet文件格式, 放到aliyun OSS存储里面. 使用duckdb_fdw对OSS内的parquet文件进行查询.《DuckDB DataLake 场景使用举例 - aliyun OSS对象存储parquet》《PolarDB 开源版通过 duckdb_fdw 支持 parquet 列存数据文件以及高效OLAP》duckdb 存储元数据(parquet 映射)《DuckDB parquet 分区表 / Delta Lake(数据湖) 应用》方案特点:内网oss不收取网络费用, 只收取存储费用, 非常便宜.oss分几个档, 可以根据性能需求选择.parquet为列存储, 一般历史数据的分析需求多, 性能不错.duckdb 支持 parquet下推过滤, 数据过滤性能也不错.存储在oss内, 可以使用oss的函数计算功能, 仅计算时收费. 而且使用OSS存储打破数据孤岛, OSS与PG和PolarDB以及其他数据源打通, 形成数据联邦, 更容易发挥更大层面的数据价值.架构如下: PolarDB-PG 或 PostgreSQL ↑↓ ↑↓ 热数据: 高速本地存储 ↑↓ ↓↓ ↑↓ ↓↓ LibDuckDB ForeignServer 层: ↓↓ 1、(通过 duckdb_fdw 读写OSS) 2、(通过 postgres_scanner 读高速本地存储) ↑↓ ↑↓ 归档数据: OSS 冷暖存储 (Parquet格式) demo在以下debian 容器中部署1、部署duckdb和依赖的parquet、httpfs插件《Debian学习入门 - (作为服务器使用, Debian 操作系统可能是长期更好的选择?)》确认编译了httpfs 和 parquet 插件root@9b780f5ea2e8:~/duckdb/build/release/extension# pwd /root/duckdb/build/release/extension root@9b780f5ea2e8:~/duckdb/build/release/extension# ll total 72K -rw-r--r-- 1 root root 2.3K Mar 3 06:16 cmake_install.cmake -rw-r--r-- 1 root root 6.2K Mar 3 06:16 Makefile drwxr-xr-x 15 root root 4.0K Mar 3 06:16 . drwxr-xr-x 2 root root 4.0K Mar 3 06:16 CMakeFiles drwxr-xr-x 4 root root 4.0K Mar 3 06:40 jemalloc drwxr-xr-x 10 root root 4.0K Mar 3 06:43 .. drwxr-xr-x 4 root root 4.0K Mar 3 06:45 icu drwxr-xr-x 3 root root 4.0K Mar 3 06:47 parquet drwxr-xr-x 4 root root 4.0K Mar 3 06:47 tpch drwxr-xr-x 4 root root 4.0K Mar 3 06:47 tpcds drwxr-xr-x 3 root root 4.0K Mar 3 06:47 fts drwxr-xr-x 3 root root 4.0K Mar 3 06:48 httpfs drwxr-xr-x 3 root root 4.0K Mar 3 06:48 visualizer drwxr-xr-x 5 root root 4.0K Mar 3 06:49 json drwxr-xr-x 4 root root 4.0K Mar 3 06:49 excel drwxr-xr-x 4 root root 4.0K Mar 3 06:50 sqlsmith drwxr-xr-x 3 root root 4.0K Mar 3 06:50 inet 2、安装postgresql 或 PolarDB开源版本.PolarDB开源版本部署请参考: 《如何用 PolarDB 证明巴菲特的投资理念 - 包括PolarDB on Docker简单部署》以下是使用postgresql的例子:apt install -y curl fastjar mkdir /home/postgres useradd postgres chown postgres:postgres /home/postgres su - postgres curl https://ftp.postgresql.org/pub/source/v15.2/postgresql-15.2.tar.bz2 -o ./postgresql-15.2.tar.bz2 tar -jxvf postgresql-15.2.tar.bz2 cd postgresql-15.2 ./configure --prefix=/home/postgres/pg15.2 make world -j 4 make install-world 3、部署duckdb_fdwsu - postgres git clone --depth 1 https://github.com/alitrack/duckdb_fdw 将duckdb的lib包拷贝到postgresql的lib目录root@9b780f5ea2e8:~/duckdb/build/release/src# pwd /root/duckdb/build/release/src root@9b780f5ea2e8:~/duckdb/build/release/src# ll libduckdb.so -rwxr-xr-x 1 root root 58M Mar 3 06:42 libduckdb.so cp libduckdb.so /home/postgres/pg15.2/lib/ 安装duckdb_fdw插件su - postgres export PATH=/home/postgres/pg15.2/bin:$PATH cd duckdb_fdw USE_PGXS=1 make USE_PGXS=1 make install 4、初始化postgresql数据库集群initdb -D /home/postgres/pgdata -E UTF8 --lc-collate=C -U postgres 5、简单配置一下pg配置文件vi /home/postgres/pgdata/postgresql.conf listen_addresses = '0.0.0.0' port = 1921 max_connections = 100 unix_socket_directories = '/tmp,.' shared_buffers = 128MB dynamic_shared_memory_type = posix max_wal_size = 1GB min_wal_size = 80MB log_destination = 'csvlog' logging_collector = on log_directory = 'log' log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' log_file_mode = 0600 log_rotation_age = 1d log_rotation_size = 10MB log_truncate_on_rotation = on log_timezone = 'Etc/UTC' datestyle = 'iso, mdy' timezone = 'Etc/UTC' lc_messages = 'C' lc_monetary = 'C' lc_numeric = 'C' lc_time = 'C' default_text_search_config = 'pg_catalog.english' 6、启动数据库, 加载duckdb_fdw插件pg_ctl start -D /home/postgres/pgdata $ psql -h 127.0.0.1 -p 1921 psql (15.2) Type "help" for help. postgres=# create extension duckdb_fdw ; CREATE EXTENSION 创建oss实验环境可以使用阿里云云起实验免费创建oss实验环境, 参考如下:《DuckDB DataLake 场景使用举例 - aliyun OSS对象存储parquet》1、初始化实验环境后, 得到一些需要的内容如下, 将被duckdb用于连接oss.AK ID: LTAI5t6eUHtPZiFKLCNQro8n AK Secret: 5wHLZXCbTpNbwUUeqRBqr7vGyirFL5 Endpoint外网域名: oss-cn-shanghai.aliyuncs.com Bucket名称: adc-oss-labs01969 Object路径: ECSOSS/u-bimcc3ei/ duckdb读写OSS的方法COPY <table_name> TO 's3://<Bucket名称>/<Object路径>/filename'; SELECT * FROM read_parquet('s3://<Bucket名称>/<Object路径>/filename'); 在debian中, 测试duckdb是否能正常使用OSS, 并生成100万测试数据, 写入oss.root@9b780f5ea2e8:~/duckdb/build/release# pwd /root/duckdb/build/release root@9b780f5ea2e8:~/duckdb/build/release# ./duckdb v0.7.1 b00b93f Enter ".help" for usage hints. Connected to a transient in-memory database. Use ".open FILENAME" to reopen on a persistent database. D load 'httpfs'; D set s3_access_key_id='LTAI5t6eUHtPZiFKLCNQro8n'; // AK ID D set s3_secret_access_key='5wHLZXCbTpNbwUUeqRBqr7vGyirFL5'; // AK Secret D set s3_endpoint='s3.oss-cn-shanghai.aliyuncs.com'; // Endpoint外网域名|内网域名 D COPY (select id, md5(random()::text) as info, now() as ts from range(0,1000000) as t(id)) TO 's3://adc-oss-labs01969/ECSOSS/u-bimcc3ei/test_duckdb1.parquet'; 测试创建视图是否正常使用IT-C02YW2EFLVDL:release digoal$ ./duckdb v0.7.1 b00b93f Enter ".help" for usage hints. Connected to a transient in-memory database. Use ".open FILENAME" to reopen on a persistent database. D set s3_access_key_id='LTAI5t6eUHtPZiFKLCNQro8n'; D set s3_secret_access_key='5wHLZXCbTpNbwUUeqRBqr7vGyirFL5'; D set s3_endpoint='s3.oss-cn-shanghai.aliyuncs.com'; 将parquet文件映射为view D create or replace view test_duckdb1 as SELECT * FROM read_parquet('s3://adc-oss-labs01969/ECSOSS/u-bimcc3ei/test_duckdb1.parquet'); D select count(*) from test_duckdb1; ┌──────────────┐ │ count_star() │ │ int64 │ ├──────────────┤ │ 1000000 │ └──────────────┘ D select * from main."test_duckdb1" limit 10; 100% ▕████████████████████████████████████████████████████████████▏ ┌───────┬──────────────────────────────────┬────────────────────────────┐ │ id │ info │ ts │ │ int64 │ varchar │ timestamp with time zone │ ├───────┼──────────────────────────────────┼────────────────────────────┤ │ 0 │ 87a144c45874838dbcd3255c215ababc │ 2023-03-08 17:28:12.902+08 │ │ 1 │ cce8d1f5d58e72e9f34a36ccd87188ed │ 2023-03-08 17:28:12.902+08 │ │ 2 │ 0ea50d2769b01c26537e09902dc5f732 │ 2023-03-08 17:28:12.902+08 │ │ 3 │ 70a6c5f594def5d1d1bbb993260a2fd7 │ 2023-03-08 17:28:12.902+08 │ │ 4 │ 5a7924f417b480210601508e2c144a2f │ 2023-03-08 17:28:12.902+08 │ │ 5 │ d1fde1c1dc8f268d9eb9fce477653bb0 │ 2023-03-08 17:28:12.902+08 │ │ 6 │ 1aac9556fd1b259c56ecef3ef4636a66 │ 2023-03-08 17:28:12.902+08 │ │ 7 │ 04181693f9b6c8576bb251612ffbe318 │ 2023-03-08 17:28:12.902+08 │ │ 8 │ 332b9bb9d00e8fa53a5661804bd1b41a │ 2023-03-08 17:28:12.902+08 │ │ 9 │ f0189d662187cc436662a458577a7ed2 │ 2023-03-08 17:28:12.902+08 │ ├───────┴──────────────────────────────────┴────────────────────────────┤ │ 10 rows 3 columns │ └───────────────────────────────────────────────────────────────────────┘ Run Time (s): real 9.773 user 1.121633 sys 0.928902 D .timer on D select max(id) from test_duckdb1; ┌─────────┐ │ max(id) │ │ int64 │ ├─────────┤ │ 999999 │ └─────────┘ Run Time (s): real 0.482 user 0.087439 sys 0.065868 在postgresql中使用duckdb_fdw访问oss内的parquet文件你可以创建duckdb内存数据库, 也可以指定为一个持久化文件, 使用持久化文件的话可以拥有一些元数据存储的能力, 不用每次都创建映射和配置.下面用的是内存存储(非持久化)例子:在psql内执行postgres=# CREATE SERVER DuckDB_server FOREIGN DATA WRAPPER duckdb_fdw OPTIONS (database ':memory:'); CREATE SERVER -- 设置为保持连接(会话内保持) postgres=# alter server duckdb_server options ( keep_connections 'true'); ALTER SERVER 接下来创建一个duckdb视图, 用以查询parquet.一定要分开执行:SELECT duckdb_execute('duckdb_server', $$ set s3_access_key_id='LTAI5t6eUHtPZiFKLCNQro8n'; $$); SELECT duckdb_execute('duckdb_server', $$ set s3_secret_access_key='5wHLZXCbTpNbwUUeqRBqr7vGyirFL5'; $$); SELECT duckdb_execute('duckdb_server', $$ set s3_endpoint='s3.oss-cn-shanghai.aliyuncs.com'; $$); SELECT duckdb_execute('duckdb_server', $$ create or replace view test_duckdb1 as SELECT * FROM read_parquet('s3://adc-oss-labs01969/ECSOSS/u-bimcc3ei/test_duckdb1.parquet'); $$); 检查是否保持连接postgres=# select * from duckdb_fdw_get_connections(); server_name | valid ---------------+------- duckdb_server | t (1 row) 创建duckdb_fdw外部表, 指向刚才创建的duckdb视图:create foreign TABLE ft_test_duckdb1( id int, info text, ts timestamp) SERVER duckdb_server OPTIONS (table 'test_duckdb1'); 我们查看一下duckdb_fdw的下推能力, 非常帮, 过滤、limit、排序、distinct等都进行了下推, 详细参考duckdb_fdw开源项目:postgres=# explain verbose select id from ft_test_duckdb1 limit 1; QUERY PLAN -------------------------------------------------------------------------- Foreign Scan on public.ft_test_duckdb1 (cost=1.00..1.00 rows=1 width=4) Output: id SQLite query: SELECT "id" FROM main."test_duckdb1" LIMIT 1 (3 rows) postgres=# explain verbose select * from ft_test_duckdb1 where id<100; QUERY PLAN ----------------------------------------------------------------------------------------- Foreign Scan on public.ft_test_duckdb1 (cost=10.00..401.00 rows=401 width=44) Output: id, info, ts SQLite query: SELECT "id", "info", "ts" FROM main."test_duckdb1" WHERE (("id" < 100)) (3 rows) postgres=# explain verbose select * from ft_test_duckdb1 where id<100 order by ts limit 100; QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------- Foreign Scan on public.ft_test_duckdb1 (cost=1.00..1.00 rows=1 width=44) Output: id, info, ts SQLite query: SELECT "id", "info", "ts" FROM main."test_duckdb1" WHERE (("id" < 100)) ORDER BY "ts" ASC NULLS LAST LIMIT 100 (3 rows) postgres=# explain verbose select count(distinct id) from ft_test_duckdb1; QUERY PLAN ---------------------------------------------------------------------- Foreign Scan (cost=1.00..1.00 rows=1 width=8) Output: (count(DISTINCT id)) SQLite query: SELECT count(DISTINCT "id") FROM main."test_duckdb1" (3 rows) postgres=# select * from ft_test_duckdb1 limit 1; id | info | ts ----+----------------------------------+------------------------- 0 | 87a144c45874838dbcd3255c215ababc | 2023-03-08 09:28:12.902 (1 row) 执行查询, 看看性能如何. 以下对比pg本地表、parquet(实验环境在公网, 如果是内网还不好说谁快谁慢.)postgres=# create table t as select * from ft_test_duckdb1 ; SELECT 1000000 Time: 21196.441 ms (00:21.196) postgres=# \timing Timing is on. postgres=# select count(distinct id) from ft_test_duckdb1; count --------- 1000000 (1 row) Time: 1281.537 ms (00:01.282) postgres=# select count(distinct id) from t; count --------- 1000000 (1 row) Time: 260.007 ms postgres=# select count(*) from ft_test_duckdb1 where id<100; count ------- 100 (1 row) Time: 806.976 ms postgres=# select count(*) from t where id<100; count ------- 100 (1 row) Time: 60.254 ms 多个会话同时访问相同server, 相同foreign table使用同一个server, 每次建立连接都会新建一个duckdb inmemory进程. 每次都需要设置oss配置, 创建duckdb view. 然后就能通过ft读取数据. session a:访问正常postgres=# select * from ft_test_duckdb1 limit 1; id | info | ts ----+----------------------------------+------------------------- 0 | 77e736e2033e489f3134607dcfd63d05 | 2023-03-09 05:45:25.506 (1 row) session b:访问正常postgres=# select * from ft_test_duckdb1 limit 1; id | info | ts ----+----------------------------------+------------------------- 0 | 77e736e2033e489f3134607dcfd63d05 | 2023-03-09 05:45:25.506 (1 row) 通过duckdb_fdw将历史数据写入oss, 实现历史数据归档操作准备工作, 配置需要密码连接postgresql.vi pg_hba.conf # IPv4 local connections: host all all 127.0.0.1/32 md5 pg_ctl reload -D $PGDATA psql alter role postgres encrypted password '123456'; 1、建立pg本地表postgres=# create table t1 (id int, info text, ts timestamp); CREATE TABLE postgres=# insert into t1 select generate_series(1,1000000), md5(random()::text), clock_timestamp(); INSERT 0 1000000 2、在duckdb中使用postgres插件可以读取pg本地表的数据root@9b780f5ea2e8:~# cd duckdb/build/release/ root@9b780f5ea2e8:~/duckdb/build/release# ./duckdb v0.7.1 b00b93f Enter ".help" for usage hints. Connected to a transient in-memory database. Use ".open FILENAME" to reopen on a persistent database. D load 'postgres'; D select * from POSTGRES_SCAN_PUSHDOWN('dbname=postgres user=postgres hostaddr=127.0.0.1 port=1921 password=123456', 'public', 't1') limit 1; ┌───────┬──────────────────────────────────┬────────────────────────────┐ │ id │ info │ ts │ │ int32 │ varchar │ timestamp │ ├───────┼──────────────────────────────────┼────────────────────────────┤ │ 1 │ c8ecbcc36395bfa4d39b414e306c1b81 │ 2023-03-09 05:49:30.184854 │ └───────┴──────────────────────────────────┴────────────────────────────┘ D 3、在duckdb中可以打通pg和oss, 也就是将pg的数据写入ossset s3_access_key_id='LTAI5tJiSWjkwPHRNrJYvLFM'; set s3_secret_access_key='6WUWvNCv1xOdf2eC6894L9sOVdG0a0'; set s3_endpoint='s3.oss-cn-shanghai.aliyuncs.com'; COPY ( select * from POSTGRES_SCAN_PUSHDOWN('dbname=postgres user=postgres hostaddr=127.0.0.1 port=1921 password=123456', 'public', 't1') ) TO 's3://adc-oss-1872hd2/ECSOSS/u-ijr7vhba/abc.parquet'; 100% ▕████████████████████████████████████████████████████████████▏ 4、紧接着, 直接在pg里面使用duckdb_fdw插件, 让duckdb来读取pg的数据写入oss.SELECT duckdb_execute('duckdb_server', $$ install 'postgres'; $$); SELECT duckdb_execute('duckdb_server', $$ load 'postgres'; $$); SELECT duckdb_execute('duckdb_server', $$ COPY ( select * from POSTGRES_SCAN_PUSHDOWN('dbname=postgres user=postgres hostaddr=127.0.0.1 port=1921 password=123456', 'public', 't1') ) TO 's3://adc-oss-1872hd2/ECSOSS/u-ijr7vhba/test_import_from_pg1.parquet'; $$); 使用如上方法install postgres时, 会自动从duckdb官方下载对应版本编译好的插件, 例如:https://extensions.duckdb.org/v0.7.1/linux_amd64/postgres_scanner.duckdb_extension.gz详见:https://duckdb.org/docs/extensions/overview.html方法没问题, 目前bug可能和gpdb postgres_fdw遇到的问题一样, 感兴趣的朋友可以参与一起解决: https://github.com/alitrack/duckdb_fdw/issues/15postgres=# SELECT duckdb_execute('duckdb_server', $$ COPY ( select * from POSTGRES_SCAN('dbname=postgres user=postgres hostaddr=127.0.0.1 port=1921 password=123456', 'public', 't1') ) TO 's3://adc-oss-1872hd2/ECSOSS/u-ijr7vhba/test_import_from_pg1.parquet'; $$); ERROR: HV00L: SQL error during prepare: IO Error: Unable to connect to Postgres at dbname=postgres user=postgres hostaddr=127.0.0.1 port=1921 password=123456: libpq is incorrectly linked to backend functions COPY ( select * from POSTGRES_SCAN('dbname=postgres user=postgres hostaddr=127.0.0.1 port=1921 password=123456', 'public', 't1') ) TO 's3://adc-oss-1872hd2/ECSOSS/u-ijr7vhba/test_import_from_pg1.parquet'; LOCATION: sqlite_prepare_wrapper, duckdb_fdw.c:504 相关代码sqlite3_prepare_v2:/* Wrapper for sqlite3_prepare */ static void sqlite_prepare_wrapper(ForeignServer *server, sqlite3 * db, char *query, sqlite3_stmt * *stmt, const char **pzTail, bool is_cache) { int rc; // db = sqlite_get_connection(server, false); // elog(DEBUG1, "duckdb_fdw : %s %s %p %p %p %p\n", __func__, query, server,db,&stmt,stmt); rc = sqlite3_prepare_v2(db, query, -1, stmt, pzTail); // elog(DEBUG1, "duckdb_fdw : %s %s %d \n", __func__, query, rc); if (rc != SQLITE_OK) { ereport(ERROR, (errcode(ERRCODE_FDW_UNABLE_TO_CREATE_EXECUTION), errmsg("SQL error during prepare: %s %s", sqlite3_errmsg(db), query) )); } /* cache stmt to finalize at last */ if (is_cache) sqlite_cache_stmt(server, stmt); } gpdb类似的一个issue. https://github.com/greenplum-db/gpdb/issues/11400 https://github.com/greenplum-db/gpdb/commit/667f0c37bc6d7bce7be8b758652ef95ddb823e19Fix postgres_fdw's libpq issue (#10617) * Fix postgres_fdw's libpq issue When using posgres_fdw, it reports the following error: unsupported frontend protocol 28675.0: server supports 2.0 to 3.0 root cause: Even if postgres_fdw.so is dynamic linked to libpq.so which is compiled with the option -DFRONTEND, but when it's loaded in gpdb and run, it will use the backend libpq which is compiled together with postgres program and reports the error. We statically link libpq into postgres_fdw and hide all the symbols of libpq.a with --exclude-libs=libpq.a to make it uses the frontend libpq. As postgres_fdw is compiled as a backend without -DFRONTEND, and linked to libpq which is a frontend, but _PQconninfoOption's length is different between backend and frontend as there is a macro in it. The backend's _PQconninfoOption has field connofs, but the frontend doesn't. This leads to the crash of postgres_fdw. So we delete the frontend macro in _PQconninfoOption. * Add FRONTEND macro on including libpq header files postgres_fdw is compiled as a backend, it needs the server's header files such as executor/tuptable.h. It also needs libpq to connect to a remote postgres database, so it's staticly linked to libpq.a which is compiled as a frontend using -DFRONTEND. But the struct PQconninfoOption's length is different between backend and frontend, there is no "connofs" field in frontend. When postgres_fdw calls the function "PQconndefaults" implemented in libpq.a and uses the returned PQconninfoOption variable, it crashes, because the PQconninfoOption variable returned by libpq.a doesn't contain the "connofs" value, but the postgres_fdw thinks it has, so it crashes. In last commit, we remove the FRONTEND macro in struct PQconninfoOption to make PQconninfoOption is same in backend and frontend, but that brings an ABI change. To avoid that, we revert that, and instead, we add the FRONTEND macro on including libpq header files, so that postgres_fdw can process the libpq's variables returned by libpq.a's functions as frontend. * Report error if the libpq-fe.h is included before postgres_fdw.h postgres_fdw needs to include frontend mode libpq-fe.h, so if the libpq-fe.h is included before the postgres_fdw.h, and we don't know if it is frontend mode, so we just report the error here. 感谢steven贡献duckdb_fdw未来duckdb_fdw的优化期待: 1、在server中加入更多的option, 例如设置s3的参数, 连接时就默认配置好, 这样的话就可以直接查询foreign table, 不需要每次都需要通过execute接口来配置.启动时设置allow_unsigned_extensions, 允许使用未签名的外部extension.https://duckdb.org/docs/extensions/overview.html参考https://github.com/alitrack/duckdb_fdw《Debian学习入门 - (作为服务器使用, Debian 操作系统可能是长期更好的选择?)》《DuckDB DataLake 场景使用举例 - aliyun OSS对象存储parquet》《用duckdb_fdw加速PostgreSQL分析计算, 提速40倍, 真香.》《PolarDB 开源版通过 duckdb_fdw 支持 parquet 列存数据文件以及高效OLAP》《如何用 PolarDB 证明巴菲特的投资理念 - 包括PolarDB on Docker简单部署》
文章
存储  ·  NoSQL  ·  关系型数据库  ·  数据库  ·  对象存储  ·  PostgreSQL  ·  容器  ·  PolarDB  ·  分布式数据库  ·  Docker
2023-03-08
「 前端开发规范 」10人小团队前端开发规范参考这篇就够了!
前言引自《阿里规约》的开头片段:----现代软件架构的复杂性需要协同开发完成,如何高效地协同呢?无规矩不成方圆,无规范难以协同,比如,制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全,试想如果没有限速,没有红绿灯,谁还敢上路行驶。对软件来说,适当的规范和标准绝不是消灭代码内容的创造性、优雅性,而是限制过度个性化,以一种普遍认可的统一方式一起做事,提升协作效率,降低沟通成本。代码的字里行间流淌的是软件系统的血液,质量的提升是尽可能少踩坑,杜绝踩重复的坑,切实提升系统稳定性,码出质量。一、编程规约(一)命名规范1.1.1 项目命名全部采用小写方式, 以中划线分隔。正例:mall-management-system反例:mall_management-system / mallManagementSystem1.1.2 目录命名全部采用小写方式, 以中划线分隔,有复数结构时,要采用复数命名法, 缩写不用复数正例: scripts / styles / components / images / utils / layouts / demo-styles / demo-scripts / img / doc反例: script / style / demo_scripts / demoStyles / imgs / docs【特殊】VUE 的项目中的 components 中的组件目录,使用 kebab-case 命名正例: head-search / page-loading / authorized / notice-icon反例: HeadSearch / PageLoading【特殊】VUE 的项目中的除 components 组件目录外的所有目录也使用 kebab-case 命名正例: page-one / shopping-car / user-management反例: ShoppingCar / UserManagement1.1.3 JS、CSS、SCSS、HTML、PNG 文件命名全部采用小写方式, 以中划线分隔正例: render-dom.js / signup.css / index.html / company-logo.png反例: renderDom.js / UserManagement.html1.1.4 命名严谨性代码中的命名严禁使用拼音与英文混合的方式,更不允许直接使用中文的方式。 说明:正确的英文拼写和语法可以让阅读者易于理解,避免歧义。注意,即使纯拼音命名方式也要避免采用正例:henan / luoyang / rmb 等国际通用的名称,可视同英文。反例:DaZhePromotion [打折] / getPingfenByName() [评分] / int 某变量 = 3杜绝完全不规范的缩写,避免望文不知义:反例:AbstractClass“缩写”命名成 AbsClass;condition“缩写”命名成 condi,此类随意缩写严重降低了代码的可阅读性。(二)HTML 规范 (Vue Template 同样适用)1.2.1 HTML 类型推荐使用 HTML5 的文档类型申明: <!DOCTYPE html>.(建议使用 text/html 格式的 HTML。避免使用 XHTML。XHTML 以及它的属性,比如 application/xhtml+xml 在浏览器中的应用支持与优化空间都十分有限)。规定字符编码 IE 兼容模式 规定字符编码 doctype 大写正例:<!DOCTYPE html> <html> <head> <meta http-equiv="X-UA-Compatible" content="IE=Edge" /> <meta charset="UTF-8" /> <title>Page title</title> </head> <body> <img src="images/company-logo.png" alt="Company" /> </body> </html>1.2.2 缩进缩进使用 2 个空格(一个 tab)嵌套的节点应该缩进。1.2.3 分块注释在每一个块状元素,列表元素和表格元素后,加上一对 HTML 注释。注释格式<!-- 英文 中文 start > <!-- 英文 中文 end >正例: <body> <!-- header 头部 start --> <header> <div class="container"> <a href="#"> <!-- 图片会把a标签给撑开,所以不用设置a标签的大小 --> <img src="images/header.jpg" /> </a> </div> </header> <!-- header 头部 end --> </body>1.2.4 语义化标签HTML5 中新增很多语义化标签,所以优先使用语义化标签,避免一个页面都是 div 或者 p 标签正例 <header></header> <footer></footer> 反例 <div> <p></p> </div>1.2.5 引号使用双引号(" ") 而不是单引号(' ') 。正例: <div class="news-div"></div>反例: <div class='news-div'></div>(三) CSS 规范1.3.1 命名类名使用小写字母,以中划线分隔 id 采用驼峰式命名 scss 中的变量、函数、混合、placeholder 采用驼峰式命名ID 和 class 的名称总是使用可以反应元素目的和用途的名称,或其他通用的名称,代替表象和晦涩难懂的名称不推荐: .fw-800 { font-weight: 800; } .red { color: red; } 推荐: .heavy { font-weight: 800; } .important { color: red; }1.3.2 选择器1)css 选择器中避免使用标签名从结构、表现、行为分离的原则来看,应该尽量避免 css 中出现 HTML 标签,并且在 css 选择器中出现标签名会存在潜在的问题。2)很多前端开发人员写选择器链的时候不使用 直接子选择器(注:直接子选择器和后代选择器的区别)。有时,这可能会导致疼痛的设计问题并且有时候可能会很耗性能。然而,在任何情况下,这是一个非常不好的做法。如果你不写很通用的,需要匹配到 DOM 末端的选择器, 你应该总是考虑直接子选择器。不推荐: .content .title { font-size: 2rem; } 推荐: .content > .title { font-size: 2rem; }1.3.3 尽量使用缩写属性不推荐: border-top-style: none; font-family: palatino, georgia, serif; font-size: 100%; line-height: 1.6; padding-bottom: 2em; padding-left: 1em; padding-right: 1em; padding-top: 0; 推荐: border-top: 0; font: 100%/1.6 palatino, georgia, serif; padding: 0 1em 2em;1.3.4 每个选择器及属性独占一行不推荐: button{ width:100px;height:50px;color:#fff;background:#00a0e9; } 推荐: button{ width:100px; height:50px; color:#fff; background:#00a0e9; }1.3.5 省略0后面的单位不推荐: div{ padding-bottom: 0px; margin: 0em; } 推荐: div{ padding-bottom: 0; margin: 0; }1.3.6 避免使用ID选择器及全局标签选择器防止污染全局样式不推荐: #header{ padding-bottom: 0px; margin: 0em; } 推荐: .header{ padding-bottom: 0px; margin: 0em; }(四) LESS 规范1.4.1 代码组织1)将公共less文件放置在style/less/common文件夹例:// color.less,common.less2)按以下顺序组织1、@import;2、变量声明;3、样式声明;@import "mixins/size.less"; @default-text-color: #333; .page { width: 960px; margin: 0 auto; }1.4.2 避免嵌套层级过多 将嵌套深度限制在3级。对于超过4级的嵌套,给予重新评估。这可以避免出现过于详实的CSS选择器。避免大量的嵌套规则。当可读性受到影响时,将之打断。推荐避免出现多于20行的嵌套规则出现不推荐: .main{ .title{ .name{ color:#fff } } } 推荐: .main-title{ .name{ color:#fff } }(五) Javascript 规范1.5.1 命名1) 采用小写驼峰命名 lowerCamelCase,代码中的命名均不能以下划线,也不能以下划线或美元符号结束反例: _name / name_ / name$2) 方法名、参数名、成员变量、局部变量都统一使用 lowerCamelCase 风格,必须遵从驼峰形式。正例: localValue / getHttpMessage() / inputUserId**其中 method 方法命名必须是 动词 或者 动词+名词 形式**正例:saveShopCarData /openShopCarInfoDialog反例:save / open / show / go**特此说明,增删查改,详情统一使用如下 5 个单词,不得使用其他(目的是为了统一各个端)**add / update / delete / detail / get附: 函数方法常用的动词:get 获取/set 设置, add 增加/remove 删除 create 创建/destory 移除 start 启动/stop 停止 open 打开/close 关闭, read 读取/write 写入 load 载入/save 保存, create 创建/destroy 销毁 begin 开始/end 结束, backup 备份/restore 恢复 import 导入/export 导出, split 分割/merge 合并 inject 注入/extract 提取, attach 附着/detach 脱离 bind 绑定/separate 分离, view 查看/browse 浏览 edit 编辑/modify 修改, select 选取/mark 标记 copy 复制/paste 粘贴, undo 撤销/redo 重做 insert 插入/delete 移除, add 加入/append 添加 clean 清理/clear 清除, index 索引/sort 排序 find 查找/search 搜索, increase 增加/decrease 减少 play 播放/pause 暂停, launch 启动/run 运行 compile 编译/execute 执行, debug 调试/trace 跟踪 observe 观察/listen 监听, build 构建/publish 发布 input 输入/output 输出, encode 编码/decode 解码 encrypt 加密/decrypt 解密, compress 压缩/decompress 解压缩 pack 打包/unpack 解包, parse 解析/emit 生成 connect 连接/disconnect 断开, send 发送/receive 接收 download 下载/upload 上传, refresh 刷新/synchronize 同步 update 更新/revert 复原, lock 锁定/unlock 解锁 check out 签出/check in 签入, submit 提交/commit 交付 push 推/pull 拉, expand 展开/collapse 折叠 begin 起始/end 结束, start 开始/finish 完成 enter 进入/exit 退出, abort 放弃/quit 离开 obsolete 废弃/depreciate 废旧, collect 收集/aggregate 聚集3) 常量命名全部大写,单词间用下划线隔开,力求语义表达完整清楚,不要嫌名字长。正例: MAX_STOCK_COUNT反例: MAX_COUNT1.5.2 代码格式1) 使用 2 个空格进行缩进正例:if (x < y) { x += 10; } else { x += 1; }2) 不同逻辑、不同语义、不同业务的代码之间插入一个空行分隔开来以提升可读性。说明:任何情形,没有必要插入多个空行进行隔开。1.5.3 字符串统一使用单引号(‘),不使用双引号(“)。这在创建 HTML 字符串非常有好处:正例: let str = 'foo'; let testDiv = '<div id="test"></div>'; 反例: let str = 'foo'; let testDiv = "<div id='test'></div>";1.5.4 对象声明1)使用字面值创建对象正例: let user = {};反例: let user = new Object();2) 使用字面量来代替对象构造器正例: var user = { age: 0, name: 1, city: 3 }; 反例: var user = new Object(); user.age = 0; user.name = 0; user.city = 0;1.5.5 使用 ES6,7必须优先使用 ES6,7 中新增的语法糖和函数。这将简化你的程序,并让你的代码更加灵活和可复用。必须强制使用 ES6, ES7 的新语法,比如箭头函数、await/async , 解构, let , for...of 等等1.5.6 括号下列关键字后必须有大括号(即使代码块的内容只有一行):if, else, for, while, do, switch, try, catch, finally, with。正例: if (condition) { doSomething(); } 反例: if (condition) doSomething();1.5.7 undefined 判断永远不要直接使用 undefined 进行变量判断;使用 typeof 和字符串'undefined'对变量进行判断。正例: if (typeof person === 'undefined') { ... } 反例: if (person === undefined) { ... }1.5.8 条件判断和循环最多三层条件判断能使用三目运算符和逻辑运算符解决的,就不要使用条件判断,但是谨记不要写太长的三目运算符。如果超过 3 层请抽成函数,并写清楚注释。1.5.9 this 的转换命名对上下文 this 的引用只能使用'self'来命名1.5.10 慎用 console.log因 console.log 大量使用会有性能问题,所以在非 webpack 项目中谨慎使用 log 功能二、Vue 项目规范(一) Vue 编码基础vue 项目规范以 Vue 官方规范 (https://cn.vuejs.org/v2/style-guide/) 中的 A 规范为基础,在其上面进行项目开发,故所有代码均遵守该规范。请仔仔细细阅读 Vue 官方规范,切记,此为第一步。2.1.1. 组件规范1) 组件名为多个单词。组件名应该始终是多个单词组成(大于等于 2),且命名规范为KebabCase格式。这样做可以避免跟现有的以及未来的 HTML 元素相冲突,因为所有的 HTML 元素名称都是单个单词的。正例:export default { name: 'TodoItem' // ... };反例:export default { name: 'Todo', // ... } export default { name: 'todo-item', // ... }2) 组件文件名为 pascal-case 格式正例:components/ |- my-component.vue反例:components/ |- myComponent.vue |- MyComponent.vue3) 基础组件文件名为 base 开头,使用完整单词而不是缩写。正例:components/ |- base-button.vue |- base-table.vue |- base-icon.vue反例:components/ |- MyButton.vue |- VueTable.vue |- Icon.vue4) 和父组件紧密耦合的子组件应该以父组件名作为前缀命名正例:components/ |- todo-list.vue |- todo-list-item.vue |- todo-list-item-button.vue |- user-profile-options.vue (完整单词)反例:components/ |- TodoList.vue |- TodoItem.vue |- TodoButton.vue |- UProfOpts.vue (使用了缩写)5) 在 Template 模版中使用组件,应使用 PascalCase 模式,并且使用自闭合组件。正例:<!-- 在单文件组件、字符串模板和 JSX 中 --> <MyComponent /> <Row><table :column="data"/></Row>反例:<my-component /> <row><table :column="data"/></row>6) 组件的 data 必须是一个函数当在组件中使用 data 属性的时候 (除了 new Vue 外的任何地方),它的值必须是返回一个对象的函数。 因为如果直接是一个对象的话,子组件之间的属性值会互相影响。正例:export default { data () { • return { • name: 'jack' • } } }反例:export default { data: { • name: 'jack' } }7) Prop 定义应该尽量详细必须使用 camelCase 驼峰命名 必须指定类型 必须加上注释,表明其含义 必须加上 required 或者 default,两者二选其一 如果有业务需要,必须加上 validator 验证正例:props: { // 组件状态,用于控制组件的颜色 status: { • type: String, required: true, • validator: function (value) { • return [ • 'succ', • 'info', • 'error' • ].indexOf(value) !== -1 • } }, // 用户级别,用于显示皇冠个数 userLevel:{ type: String, required: true } }8) 为组件样式设置作用域正例:<template> <button class="btn btn-close">X</button> </template> <!-- 使用 `scoped` 特性 --> <style scoped> .btn-close { background-color: red; } </style>反例:<template> <button class="btn btn-close">X</button> </template> <!-- 没有使用 `scoped` 特性 --> <style> .btn-close { background-color: red; } </style>9) 如果特性元素较多,应该主动换行。正例:<MyComponent foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" />反例:<MyComponent foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" foo="a" bar="b" baz="c"/>2.1.2. 模板中使用简单的表达式组件模板应该只包含简单的表达式,复杂的表达式则应该重构为计算属性或方法。复杂表达式会让你的模板变得不那么声明式。我们应该尽量描述应该出现的是什么,而非如何计算那个值。而且计算属性和方法使得代码可以重用。正例:<template> <p>{{ normalizedFullName }}</p> </template> // 复杂表达式已经移入一个计算属性 computed: { normalizedFullName: function () { • return this.fullName.split(' ').map(function (word) { • return word[0].toUpperCase() + word.slice(1) • }).join(' ') } }反例:<template> <p> {{ fullName.split(' ').map(function (word) { • return word[0].toUpperCase() + word.slice(1) }).join(' ') }} </p> </template>2.1.3 指令都使用缩写形式指令推荐都使用缩写形式,(用 : 表示 v-bind: 、用 @ 表示 v-on: 和用 # 表示 v-slot:)正例:<input @input="onInput" @focus="onFocus" >反例:<input v-on:input="onInput" @focus="onFocus" > 2.1.4 标签顺序保持一致单文件组件应该总是让标签顺序保持为 <template> 、<script>、 <style>正例:<template>...</template> <script>...</script> <style>...</style>反例:<template>...</template> <style>...</style> <script>...</script>2.1.5 必须为 v-for 设置键值 key2.1.6 v-show 与 v-if 选择如果运行时,需要非常频繁地切换,使用 v-show ;如果在运行时,条件很少改变,使用 v-if。2.1.7 script 标签内部结构顺序components > props > data > computed > watch > filter > 钩子函数(钩子函数按其执行顺序) > methods2.1.8 Vue Router 规范1) 页面跳转数据传递使用路由参数页面跳转,例如 A 页面跳转到 B 页面,需要将 A 页面的数据传递到 B 页面,推荐使用 路由参数进行传参,而不是将需要传递的数据保存 vuex,然后在 B 页面取出 vuex 的数据,因为如果在 B 页面刷新会导致 vuex 数据丢失,导致 B 页面无法正常显示数据。正例:let id = ' 123'; this.$router.push({ name: 'userCenter', query: { id: id } });2) 使用路由懒加载(延迟加载)机制{ path: '/uploadAttachment', name: 'uploadAttachment', meta: { title: '上传附件' }, component: () => import('@/view/components/uploadAttachment/index.vue') },3) router 中的命名规范path、childrenPoints 命名规范采用kebab-case命名规范(尽量vue文件的目录结构保持一致,因为目录、文件名都是kebab-case,这样很方便找到对应的文件)name 命名规范采用KebabCase命名规范且和component组件名保持一致!(因为要保持keep-alive特性,keep-alive按照component的name进行缓存,所以两者必须高度保持一致) // 动态加载 export const reload = [ { path: '/reload', name: 'reload', component: Main, meta: { title: '动态加载', icon: 'icon iconfont' }, children: [ { path: '/reload/smart-reload-list', name: 'SmartReloadList', meta: { title: 'SmartReload', childrenPoints: [ { title: '查询', name: 'smart-reload-search' }, { title: '执行reload', name: 'smart-reload-update' }, { title: '查看执行结果', name: 'smart-reload-result' } ] }, component: () => import('@/views/reload/smart-reload/smart-reload-list.vue') } ] } ];4) router 中的 path 命名规范path除了采用kebab-case命名规范以外,必须以 / 开头,即使是children里的path也要以 / 开头。如下示例*目的:经常有这样的场景:某个页面有问题,要立刻找到这个vue文件,如果不用以/开头,path为parent和children组成的,可能经常需要在router文件里搜索多次才能找到,而如果以/开头,则能立刻搜索到对应的组件* { path: '/file', name: 'File', component: Main, meta: { title: '文件服务', icon: 'ios-cloud-upload' }, children: [ { path: '/file/file-list', name: 'FileList', component: () => import('@/views/file/file-list.vue') }, { path: '/file/file-add', name: 'FileAdd', component: () => import('@/views/file/file-add.vue') }, { path: '/file/file-update', name: 'FileUpdate', component: () => import('@/views/file/file-update.vue') } ] }(二) Vue 项目目录规范2.2.1 基础vue 项目中的所有命名一定要与后端命名统一。比如权限:后端 privilege, 前端无论 router , store, api 等都必须使用 privielege 单词!2.2.2 使用 Vue-cli 脚手架使用 vue-cli3 来初始化项目,项目名按照上面的命名规范。2.2.3 目录说明目录名按照上面的命名规范,其中 components 组件用大写驼峰,其余除 components 组件目录外的所有目录均使用 kebab-case 命名。 src                               源码目录 |-- api                              所有api接口 |-- assets                           静态资源,images, icons, styles等 |-- components                       公用组件 |-- config                           配置信息 |-- constants                        常量信息,项目所有Enum, 全局常量等 |-- directives                       自定义指令 |-- filters                          过滤器,全局工具 |-- datas                            模拟数据,临时存放 |-- lib                              外部引用的插件存放及修改文件 |-- mock                             模拟接口,临时存放 |-- plugins                          插件,全局使用 |-- router                           路由,统一管理 |-- store                            vuex, 统一管理 |-- themes                           自定义样式主题 |-- views                            视图目录 |   |-- role                             role模块名 |   |-- |-- role-list.vue                    role列表页面 |   |-- |-- role-add.vue                     role新建页面 |   |-- |-- role-update.vue                  role更新页面 |   |-- |-- index.less                      role模块样式 |   |-- |-- components                      role模块通用组件文件夹 |   |-- employee                         employee模块1) api 目录文件、变量命名要与后端保持一致。 此目录对应后端 API 接口,按照后端一个 controller 一个 api js 文件。若项目较大时,可以按照业务划分子目录,并与后端保持一致。 api 中的方法名字要与后端 api url 尽量保持语义高度一致性。 对于 api 中的每个方法要添加注释,注释与后端 swagger 文档保持一致。正例:后端 url: EmployeeController.java/employee/add /employee/delete/{id} /employee/update前端: employee.js// 添加员工 addEmployee: (data) => { return postAxios('/employee/add', data) }, // 更新员工信息 updateEmployee: (data) => { return postAxios('/employee/update', data) }, // 删除员工 deleteEmployee: (employeeId) => { return postAxios('/employee/delete/' + employeeId) },2) assets 目录assets 为静态资源,里面存放 images, styles, icons 等静态资源,静态资源命名格式为 kebab-case |assets |-- icons |-- images |   |-- background-color.png |   |-- upload-header.png |-- styles3) components 目录此目录应按照组件进行目录划分,目录命名为 KebabCase,组件命名规则也为 KebabCase |components |-- error-log |   |-- index.vue |   |-- index.less |-- markdown-editor |   |-- index.vue |   |-- index.js |-- kebab-case 4) constants 目录此目录存放项目所有常量,如果常量在 vue 中使用,请使用 vue-enum 插件(https://www.npmjs.com/package/vue-enum)目录结构: |constants |-- index.js |-- role.js |-- employee.js例子: employee.js export const EMPLOYEE_STATUS = { NORMAL: { value: 1, desc: '正常' }, DISABLED: { value: 1, desc: '禁用' }, DELETED: { value: 2, desc: '已删除' } }; export const EMPLOYEE_ACCOUNT_TYPE = { QQ: { value: 1, desc: 'QQ登录' }, WECHAT: { value: 2, desc: '微信登录' }, DINGDING: { value: 3, desc: '钉钉登录' }, USERNAME: { value: 4, desc: '用户名密码登录' } }; export default { EMPLOYEE_STATUS, EMPLOYEE_ACCOUNT_TYPE };5) router 与 store 目录这两个目录一定要将业务进行拆分,不能放到一个 js 文件里。router 尽量按照 views 中的结构保持一致store 按照业务进行拆分不同的 js 文件6) views 目录命名要与后端、router、api 等保持一致components 中组件要使用 PascalCase 规则 |-- views                            视图目录 |   |-- role                             role模块名 |   |   |-- role-list.vue                    role列表页面 |   |   |-- role-add.vue                     role新建页面 |   |   |-- role-update.vue                  role更新页面 |   |   |-- index.less                      role模块样式 |   |   |-- components                      role模块通用组件文件夹 |   |   |   |-- role-header.vue                        role头部组件 |   |   |   |-- role-modal.vue                         role弹出框组件 |   |-- employee                         employee模块 |   |-- behavior-log                      行为日志log模块 |   |-- code-generator                    代码生成器模块2.2.4 注释说明整理必须加注释的地方公共组件使用说明 api 目录的接口 js 文件必须加注释 store 中的 state, mutation, action 等必须加注释 vue 文件中的 template 必须加注释,若文件较大添加 start end 注释 vue 文件的 methods,每个 method 必须添加注释 vue 文件的 data, 非常见单词要加注释2.2.5 其他1) 尽量不要手动操作 DOM因使用 vue 框架,所以在项目开发中尽量使用 vue 的数据驱动更新 DOM,尽量(不到万不得已)不要手动操作 DOM,包括:增删改 dom 元素、以及更改样式、添加事件等。2) 删除无用代码因使用了 git/svn 等代码版本工具,对于无用代码必须及时删除,例如:一些调试的 console 语句、无用的弃用功能代码。最后规范的目的是为了编写高质量的代码,让你的团队成员每天得心情都是愉悦的,大家在一起是快乐的。参考:本篇内容参考自开源社区,感谢前人的经验和付出,让我们可以有机会站在巨人的肩膀上眺望星辰大海。
文章
移动开发  ·  缓存  ·  JavaScript  ·  前端开发  ·  搜索推荐  ·  API  ·  Go  ·  开发工具  ·  git  ·  HTML5
2023-02-22
【读书笔记】《Effective C#》50条建议笔记整理
@[TOC]前言《Effective C#》是.NET专家Bill Wanger给出我们50条利用C#优点以及特性来写出健壮的,高效的,易于维护的代码的高效法则;自己在阅读完这本书后对这本书中的50条建议较为精华的结论进行整理,方便自己学习的同时分享出来。第一章、C#语言的编程习惯能用的东西为什么要改?因为改了之后效果更好。开发者换用其他工具或语言来编程也是这个道理,因为换了之后工作效率更高。如果不肯改变现有的习惯,那么就体会不到新技术的好处。如果你是从其他语言转入C#的,那么需要学习C#语言自己的编程习惯,使得这门语言能够促进你的工作,而不是阻碍你的工作。第1条:优先使用隐式类型的局部变量优先使用隐式类型var来声明变量而不指明其类型,这样的好处有可以令开发者把注意力更多地集中在名称上面,从而更好地了解其含义。不用去操心程序中使用了不合适的类型,编译器会自动选择合适的类型。好的变量命名可以提高可读性,合适的推导类型会提高开发效率。var HighestSellingProduct = someObject.DoSomeWork(anotherParameter);var类型不能盲目使用,对于int、float、double等数值型的变量,就应该明确指出其类型。第2条:考虑用readonly代替constC#有两种常量,编译期的常量const和运行期的常量readonly。编译期的常量const只能用来表示内置的整数、浮点数、枚举或字符串。编译期的常量const虽然性能高点,但却远不如运行期的常量readonly来的灵活。const关键字用来声明那些必须在编译期得以确定的值,例如attribute的参数、switch case语句的标签、enum的定义等,偶尔还用来声明那些不会随着版本而变化的值。除此之外的值则应该考虑声明成更加灵活的readonly常量。开发者确实想把某个值在编译期固定下来就使用const类型,否则就使用更灵活,兼容性更好的readonly类型。// 编译时常量: public const int Millennium = 2000; // 运行时常量: public static readonly int ThisYear = 2004;第3条:优先考虑is或as运算符,尽量少用强制类型转换使用面向对象语言来编程序的时候,应该尽量避免类型转换操作,但总有一些场合是必须转换类型的。采用as运算符来实现类型转换更加安全可读性更高,而且在运行的时候也更有效率。尽量采用as来进行类型转换,因为这么做不需要编写额外的try/catch结构来处理异常。如果想判断对象是不是某个具体的类型而不是看它能否从当前类型转换成目标类型,那么可以使用is运算符。t = (MyType)st; t = st as MyType; 第4条:用内插字符串取代string.Format()内插字符串以$开头,相比较于String.Format()方法的序号数量和参数个数不相等就会出错的情况,内插字符串代码的可读性更高。内插字符串不像传统的格式字符串那样把序号放在一对花括号里面,并用其指代params数组中的对应元素,而是可以直接在花括号里面编写C#表达式。Console.WriteLine($"The customer's name is {c?.Name ?? "Name is missing"}");第5条:用FormattableString取代专门为特定区域而写的字符串如果程序只是针对当前区域而生成文本,那么直接使用内插字符串就够了,这样反而可以避免多余的操作。如果需要针对特定的地区及语言来生成字符串,那么就必须根据内插字符串的解读结果来创建FormattableString,并将其转换成适用于该地区及该语言的字符串。FormattableString second = $"It's the {DateTime.Now.Day} of the {DateTime.Now.Month} month"; 第6条:不要用表示符号名称的硬字符串来调用APInameof()表达式这个关键字可以根据变量来获取包含其名称的字符串,使得开发者不用把变量名直接写成字面量。使用nameof运算符的好处是 ,如果符号改名了,那么用nameof来获取符号名称的地方也会获取到修改之后的新名字。这种写法可以保留较多的符号信息,使得自动化工具能够多发现并多修复一些错误,从而令开发者可以专心解决那些更为困难的问题。如果不这样做,那么有些错误就只能通过自动化测试及人工检查才能寻找出来。在下面的代码中,如果属性名变了,那么用来构造Property-ChangedEventArgs对象的参数也会随之变化。Public String Name { get { return name; } set { if (value != name) { name = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); } } }第7条:用委托表示回调回调就是这样一种由服务端向客户端提供异步反馈的机制,它可能会涉及多线程(multithreading),也有可能只是给同步更新提供入口。C#语言用委托来表示回调。通过委托,可以定义类型安全的回调。最常用到委托的地方是事件处理,然而除此之外,还有很多地方也可以用。委托是一种对象,其中含有指向方法的引用,这个方法既可以是静态方法,又可以是实例方法。开发者可以在程序运行的时候配置一个或多个客户对象,并与之通信。可以直接用lambda表达式来表示委托。此外,.NET Framework库也用Predicate\<T>、Action\<T>及Func\<T>定义了很多常见的委托形式。由于历史原因,所有的委托都是多播委托(multicast delegate),也就是会把添加到委托中的所有目标函数(target function)都视为一个整体去执行。总之,如果要在程序运行的时候执行回调,那么最好的办法就是使用委托,因为客户端只需编写简单的代码,即可实现回调。委托的目标可以在运行的时候指定,并且能够指定多个目标。在.NET程序里面,需要回调客户端的地方应该考虑用委托来做。第8条:用null条件运算符调用事件处理程序比较下面四种代码的写法public void RaiseUpdates() { counter++; Updated(this, counter); } public void RaiseUpdates() { counter++; if(Update != null) Updated(this, counter); } public void RaiseUpdates() { counter++; var handler = Updated; if(handler != null) Updated(this, counter); } public void RaiseUpdates() { counter++; Updated?.Invoke(this, counter); }第一种写法有个明显的问题:如果在对象上面触发Updated事件时并没有事件处理程序与之相关,那么就会发生NullReferenceException。第二种写法有个隐藏的bug,当程序中的线程执行完那行if语句并发现Updated不等于null之后,可能会有另一个线程打断该线程,并将唯一的那个事件处理程序解除订阅,这样的话,等早前的线程继续执行Updated(this,counter);语句时,事件处理程序就变成了null,调用这样的处理程序会引发NullReferenceException。当然,这种情况较为少见,而且不容易重现。第三种写法没有错,但是.NET开发新手却很难看懂,而且以后凡是要触发事件的地方就都得按这种写法重复一遍才行。第四种写法是最正统的,这段代码采用null条件运算符(也就是?.)安全地调用事件处理程序。该运算符首先判断其左侧的内容,如果发现这个值不是null,那就执行右侧的内容。反之,若为null,则跳过该语句,直接执行下一条语句。有了这种简单而清晰的新写法(第四种)之后,原来的老习惯就需要改一改了。以后在触发事件的时候,都应该采用这种写法。第9条:尽量避免装箱与取消装箱这两种操作装箱的过程是把值类型放在非类型化的引用对象中,使得那些需要使用引用类型的地方也能够使用值类型。取消装箱则是把已经装箱的那个值拷贝一份出来。如果要在只接受System.Object类型或接口类型的地方使用值类型,那就必然涉及装箱及取消装箱。但这两项操作都很影响性能。有的时候还需要为对象创建临时的拷贝,而且容易给程序引入难于查找的bug。值类型可以转换成指向System.Object或其他接口的引用,但由于这种转换是默默发生的,因此一旦出现问题就很难排查。把值类型的值放入集合、用值类型的值做参数来调用参数类型为System.Object的方法以及将这些值转为System.Object等。这些做法都应该尽量避免。第10条:只有在应对新版基类与现有子类之间的冲突时才应该使用new修饰符重新定义非虚方法可能会使程序表现出令人困惑的行为,以导致出现难以排查的bug;虚方法是动态绑定的,也就是说,要到运行的时候才会根据对象的实际类型来决定应该调用哪个版本;应该花时间想想:究竟有哪些方法与属性是应该设置成多态的,然后仅仅把这些内容用virtual加以修饰;唯一一种应该使用new修饰符的情况是:新版的基类里面添加了一个方法,而那个方法与你的子类中已有的方法重名了只有当基类所引入的新成员与子类中的现有成员冲突时,才可以考虑运用该修饰符,但即便在这种特殊的情况下,也得仔细想想使用它所带来的后果。除此之外的其他情况决不应该使用new修饰符。public class BaseWidget { public void NormalizerValues() { // 省略细节 } } public class MyWidget : BaseWidget { public new void NormalizerValues() { // 省略细节 // 只在(运气)情况下调用基类 // 新方法做同样的操作 base.NormalizerValues(); } }第二章、.NET的资源管理.NET程序运行在托管环境(managed environment)中,这对C#程序的高效设计方式有很大的影响。开发者必须从.NET CLR(Common Language Runtime,公共语言运行时)的角度来思考,才可以充分发挥这套环境的优势,而不能完全沿用其他开发环境下的想法。第11条:理解并善用.NET的资源管理机制要想写出高效的程序,开发者就需要明白程序所在的这套环境是如何处理内存与其他重要资源的。与资源管理功能较少的环境相比,.NET环境会提供垃圾回收器(GC)来帮助你控制托管内存,这使得开发者无须担心内存泄漏、迷途指针(dangling pointer)、未初始化的指针以及其他很多内存管理问题。为了防止资源泄漏,非内存型的资源(nonmemory resource)必须由开发者释放,于是会促使其创建finalizer来完成该工作。考虑实现并运用IDisposable接口,以便在不给GC增加负担的前提下把这些资源清理干净。第12条:声明字段时,尽量直接为其设定初始值类的构造函数通常不止一个,构造函数变多了之后,开发者就有可能忘记给某些成员变量设定初始值。为了避免这个问题,最好是在声明的时候直接初始化,而不要等实现每个构造函数的时候再去赋值。成员变量的初始化语句可以方便地取代那些本来需要放在构造函数里面的代码,这些语句的执行时机比基类的构造函数更早,它们会按照本类声明相关变量的先后顺序来执行。public class MyClass { //声明集合,并初始化它。 private List<string> labels = new List<string>(); }有三种情况是不应该编写初始化语句的:第一种情况:对象初始化为0或null的时候,系统在运行时本身就会初始化逻辑,生成指令会把整块内存全都设置成0,初始化会显得多余和降低性能。第二种情况:如果不同的构造函数需要按照各自的方式来设定某个字段的初始值,那么这种情况下就不应该再编写初始化语句了,因为该语句只适用于那些总是按相同方式来初始化的变量。第三种情况:如果初始化变量的过程中有可能出现异常,那么就不应该使用初始化语句,而是应该把这部分逻辑移动到构造函数里面。第13条:用适当的方式初始化类中的静态成员创建某个类型的实例之前,应该先把静态的成员变量初始化好,这在C#语言中可以通过静态初始化语句及静态构造函数来做。静态构造函数是特殊的函数,会在初次访问该类所定义的其他方法、变量或属性之前执行,可以用来初始化静态变量、实现单例(singleton)模式,或是执行其他一些必要的工作,以便使该类能够正常运作。如果静态字段的初始化工作比较复杂或是开销比较大,那么可以考虑运用Lazy\<T>机制,将初始化工作推迟到首次访问该字段的时候再去执行。若是必须通过复杂的逻辑才能完成初始化,则应考虑创建静态构造函数。public class MySingleton2 { private static readonly MySingleton2 theOneAndOnly; static MySingleton2() { theOneAndOnly = new MySingleton2(); } public static MySingleton2 TheOnly { get { return theOneAndOnly; } } private MySingleton2() { } // 剩余部分省略 }用静态构造函数取代静态初始化语句一般是为了处理异常,因为静态初始化语句无法捕捉异常,而静态构造函数却可以。static MySingleton2() { try { theOneAndOnly = new MySingleton2(); } catch { // 这里尝试恢复 } }要想为类中的静态成员设定初始值,最干净、最清晰的办法就是使用静态初始化语句及静态构造函数,因为这两种写法比较好懂,而且不容易出错。第14条:尽量删减重复的初始化逻辑如果这些构造函数都会用到相同的逻辑,那么应该把这套逻辑提取到共用的构造函数中(并且令其他构造函数直接或间接地调用该函数)。这样既可以减少重复的代码,又能够令C#编译器根据这些初始化命令生成更为高效的目标代码。public class MyClass { // 收集数据 private List<ImportantData> coll; // 实例的名称; private string name; // 需要满足new()约束; public MyClass() : this(0, string.Empty) { } public MyClass(int initialCount = 0, string name = "") { coll = (initialCount > 0) ? new List<ImportantData>(initialCount) : new List<ImportantData>(); this.name = name; } }采用默认参数机制来编写构造函数是比较好的做法,但是有些API会使用反射(reflection)来创建对象,它们需要依赖于无参的构造函数,这种函数与那种所有参数都具备默认值的构造函数并不是一回事,因此可能需要单独提供。下面列出构建某个类型的首个实例时系统所执行的操作:把存放静态变量的空间清零。执行静态变量的初始化语句。执行基类的静态构造函数。执行(本类的)静态构造函数。把存放实例变量的空间清零。执行实例变量的初始化语句。适当地执行基类的实例构造函数。执行(本类的)实例构造函数。第15条:不要创建无谓的对象垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象。因为创建并摧毁一个基于堆(heap-based)的对象无论如何都要比根本不生成这个对象耗费更多的处理器时间。在方法中创建很多局部的引用对象可能会大幅降低程序的性能。如果局部变量是引用类型而非值类型,并且出现在需要频繁运行的例程(routine)中,那就应该将其提升为成员变量;要避免的是频繁创建相同的对象,而不是说必须把每个局部变量都转化为成员变量。这两项技巧都可以令程序在运行过程中尽量少分配一些对象第一项技巧是把经常使用的局部变量提升为成员变量.第二项技巧是采用依赖注入(dependency injection)的办法创建并复用那些经常使用的类实例。如果最终要构建的字符串很复杂,不太方便用内插字符串实现,那么可以考虑改用StringBuilder处理,这是一种可变的字符串,提供了修改其内容的机制,使得开发者能够以此来构建不可变的string对象。string msg = string.Format("Hello,{0}. Today is {1}", thisUser.Name, DateTime.Now.ToString());垃圾回收器能够有效地管理应用程序所使用的内存,但是要注意,在堆上创建并销毁对象仍需耗费一定的时间,因此,不要过多地创建对象,也不要创建那些根本不用去重新构建的对象。第16条:绝对不要在构造函数里面调用虚函数在构建对象的过程中调用虚函数总是有可能令程序中的数据混乱。在(基类的)构造函数里面调用虚函数会令代码严重依赖于派生类的实现细节,而这些细节是无法控制的,因此,这种做法很容易出问题Visual Studio所附带的FxCop及Static Code Analyzer等工具都会将其视为潜在的问题。(这两款插件可能会解决问题)第17条:实现标准的dispose模式前面说过(第11条),如果对象包含非托管资源,那么一定要正确地加以清理。这样做虽然有可能令程序的性能因执行finalizer而下降,但毕竟可以保证垃圾回收器能够把资源回收掉。如果你的类本身不包含非托管资源,那就不用编写finalizer,但若是包含这种资源的话,则必须提供finalizer,因为你不能保证该类的使用者总是会调用Dispose()方法。实现IDisposable.Dispose()方法时,要注意以下四点:把非托管资源全都释放掉。把托管资源全都释放掉(这也包括不再订阅早前关注的那些事件)。设定相关的状态标志,用以表示该对象已经清理过了。阻止垃圾回收器重复清理该对象。这可以通过GC.SuppressFinalize(this)来完成。正确实现IDisposable接口是一举两得的事情,因为它既提供了适当的机制使得托管资源能够及时释放,又令客户端可以通过标准的Dispose()方法来释放非托管型的资源。编写finalizer时,一定要仔细检查代码,而且最好能把Dispose方法的代码也一起检查一遍。如果发现这些代码除了释放资源之外还执行了其他的操作,那就要再考虑考虑了。这些操作以后有可能令程序出bug,最好是现在就把它们从方法中删掉,使得finalizer与Dispose()方法只用来释放资源。public class BadClass { //存储全局对象的引用: private static readonly List<BadClass> finalizedList = new List<BadClass>(); private string msg; public BadClass(string msg) { //捕获引用: msg = (string)msg.Clone(); } ~BadClass() { //将该对象添加到列表 //该对象是可达的 finalizedList.Add(this); } }第三章、合理地运用泛型泛型还有很多种用法,例如可以用来编写接口、事件处理程序以及通用的算法,等等。定义泛型类型可能会增加程序的开销,但也有可能给程序带来好处。用泛型来编程有的时候会令程序码更加简洁,有的时候则会令其更加臃肿。泛型类的定义(generic class definition)属于完全编译的MSIL类型,其代码对于任何一种可供使用的类型参数来说都必须完全有效。对于泛型类型来说,若所有的类型参数都已经指明,那么这种泛型类型称为封闭式泛型类型(closed generic type),反之,仅指出了某些参数,则称为开放式泛型结构(open generic type)。与真正的类型相比,IL形式的泛型只是定义好了其中的某一部分而已。必须把里面的占位符替换成具体的内容才能令其成为完备的泛型类型(completed generic type)。第18条:只定义刚好够用的约束条件泛型定义太宽或太严都不合适。你可以用约束来表达自己对泛型类型中的类型参数所提的要求,这些要求对编译器与使用该类的其他开发者都会带来影响。还有一种约束条件需要谨慎地使用,那就是new约束,有的时候可以去掉这条约束,并将代码中的new()改为default()。后者是C#的运算符,用来针对某个类型产生默认值,值类型则为0,引用类型则为null。对于引用类型来说,new()与default()有很大的区别。public static T FirstOrDefault<T>(this IEnumerable<T> sequence, Predicate<T> test) { foreach(T value in sequence) { if (test(value)) return value; } return default(T); }编译器能够保证使用这个泛型类型的人所提供的类型参数一定会满足这些条件。例如你可以规定类型参数必须是值类型(struct)或必须是引用类型(class),还可以规定它必须实现某些接口或是必须继承自某个基类(这当然意味着它必须首先是个类才行)。如果不采用约束来表达这些要求,那么就得执行大量的强制类型转换操作,并在程序运行的时候做很多测试。第19条:通过运行期类型检查实现特定的泛型算法只需要指定新的类型参数,就可以复用泛型类,这样做会实例化出一个功能相似的新类型。但问题在于,其实很多时候在复用泛型类时会出现功能高度相似的时候,这显得完全没有必要,于是开发者需要在复用泛型类同时加上特定的泛型算法,示例代码如下:public ReverseEnumerable(IEnumerable<T> sequence) { sourceSequence = sequence; // 如果序列没有实现IList<T> // originalSequence是null,所以这是可行的 // 实现 originalSequence = sequence as IList<T>; // as的用法在第3条 }开发者既可以对泛型参数尽量少施加一些硬性的限制,又能够在其所表示的类型具备丰富的功能时提供更好的实现方式。为了达到这种效果,你需要在泛型类的复用程度与算法面对特定类型时所表现出的效率之间做出权衡。第20条:通过IComparable及IComparer定义顺序关系.NET Framework引入了两种用来定义执行排序与搜索关系的接口,即IComparable\<T>及IComparer\<T>。前者用来规定某类型的各对象之间所具备的自然顺序(natural order),后者用来表示另一种排序机制可以由需要提供排序功能的类型来实现。IComparable接口只有一个方法,就是CompareTo(),该方法遵循长久以来所形成的惯例:若本对象小于另一个受测对象,则返回小于0的值;若相等,则返回0;若大于那个对象,则返回大于0的值。在.NET环境中,比较新的API大都使用泛型版的IComparable接口,但老一些的API用的则是不带泛型的IComparable接口,因此,实现前者的时候应该同时实现后者。既然非泛型版的IComparable有这么多缺点,那为什么还要实现它呢?这有两个原因。第一个原因很简单:为了保持向后兼容(backward compatibility)。第二个原因在于,这样写,可以满足那些确实需要使用该方法的人,同时又能够把无意中的错误用法拦截下来。第21条:创建泛型类时,总是应该给实现了IDisposable的类型参数提供支持为泛型类指定约束条件会对开发者自身及该类的用户产生两方面影响。第一,会把程序在运行的时候有可能发生的错误提前暴露于编译期。第二,相当于明确告诉该类的用户在通过泛型类来创建具体的类型时所提供的类型参数必须满足一定的要求。如果你在泛型类里面根据类型参数创建了实例,那么就应该判断该实例所属的类型是否实现了IDisposable接口。如果实现了,就必须编写相关的代码,以防程序在离开泛型类之后发生资源泄漏。泛型类本身也可能需要以惰性初始化的形式根据类型参数去创建实例,并实现IDisposable接口,这需要多写一些代码,然而如果想创建出实用的泛型类,有时就必须这么做才行。第22条:考虑支持泛型协变与逆变变体(type variance)机制,尤其是协变(covariance)与逆变(contravariance)确定了某类型的值在什么样的情况下可以转换成其他类型的值。协变与逆变是指能否根据类型参数之间的兼容情况在两个泛型类之间化约。对于以T为类型参数的泛型类型C\<T>来说,如果在X可以转换为Y的前提下能够把C\<X>当成C\<Y>来用,那么该泛型对T协变。如果在Y可以转换为X的前提下能够把C\<X>当成C\<Y>来用,那么该泛型对T逆变。C#语言允许开发者在泛型接口与委托中运用in与out修饰符,以表达它们与类型参数之间的逆变及协变关系。public interface ICovariantDelegates<out T> { T GetAnItem(); Func<T> GetAnItemFactory(); void GiveItemLater(Action<T> whatToDo); } public interface IContravariantDelegates<in T> { void ActOnAnItem(T item); void GetAnItemFactory(Func<T> item); Action<T> ActOnAnItemLater(); } 第23条:用委托要求类型参数必须提供某种方法C#为开发者所提供的约束似乎比较有限,你只能要求某个泛型参数所表示的类型必须继承自某个超类、实现某个接口、必须是引用类型、必须是值类型或是必须具备无参数的构造函数。你可能会要求用户提供的类型必须支持某种运算符、必须拥有某个静态方法、必须与某种形式的委托相符或是必须能够以某种方式来构造,这些要求其实都可以用委托来表示。也就是说,你可以定义相应的委托类型,并要求用户在使用泛型类的时候必须提供这样的委托对象。现在就以Add()为例来谈谈这个问题,首先,创建IAdd接口,其次,编写代码,给类型参数施加约束,规定其必须实现该接口。public static class Example { public static T Add<T>(T left, T right, Func<T, T, T> AddFunc) => AddFunc(left, right); } int a = 6; int b = 7; int sum = Example.Add(a, b, (x, y) => x+y);总之,如果你在设计泛型的时候需要对用户所提供的类型提出要求,但这种要求又不便以C#内置的约束条件来表达,那么就应该考虑通过其他办法(委托)来保证这一点,而不能放弃这项要求。第24条:如果有泛型方法,就不要再创建针对基类或接口的重载版本与基类版本同泛型版本之间的优先顺序相似,接口版本与泛型版本之间的优先顺序也有可能令人困惑。一般来说,在已经有了泛型版本的前提之下,即便想要给某个类及其子类提供特殊的支持,也不应该轻易去创建专门针对该类的重载版本。这条原则同样适用于接口。如果想专门针对某个接口创建与已有的泛型方法相互重载的方法,那么也必须同时为实现了该接口的所有类型都分别创建对应的方法(使得编译器能够把调用该方法的代码解析到合适的版本上面)。第25条:如果不需要把类型参数所表示的对象设为实例字段,那么应该优先考虑创建泛型方法,而不是泛型类用包含大量泛型方法的非泛型工具类实现可能会更加清晰(可读性更高)用户可能会给出很多套符合约束的泛型参数,而C#编译器则必须针对每一套泛型参数都生成一份完整的IL码,用以表示与这套参数相对应的泛型类。public static class Utils { public static T Max<T>(T left, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? right : left; public static double Max(double left, double right) => Math.Max(left, right); // 省略了其他数字类型的版本 public static T Min<T>(T left, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? left : right; public static double Min(double left, double right) => Math.Min(left, right); // 其他数字类型的版本被省略 }这样做的好处:首先,调用起来比较简单。由于编译器会自动判断出最为匹配的版本,因此无须调用方明确指定。其次,对于程序库的开发者来说,这样写可以令将来的工作更加灵活。在两种情况下,必须把类写成泛型类:第一种情况,该类需要将某个值用作其内部状态,而该值的类型必须以泛型来表达(例如集合类);第二种情况,该类需要实现泛型版的接口。除此之外的情况,都应该考虑使用包含泛型方法的非泛型类来实现。第26条:实现泛型接口的同时,还应该实现非泛型接口由于各种各样的原因,开发者还是必须考虑怎样与非泛型的内容打交道;这条建议适用于三项内容:一,要编写的类以及这些类所支持的接口;二,public属性;三,打算序列化(serialize)的那些元素。在绝大多数情况下,如果想给旧版接口提供支持,那么只需要在类里面添加签名正确的方法就可以了。在实现这些接口时,应该明确加以限定,以防用户在本来打算使用新版接口的时候无意间调用了旧版接口。第27条:只把必备的契约定义在接口中,把其他功能留给扩展方法去实现定义接口的时候,只把必备的功能列出来就行了,而其他一些功能则可以在别的类里面以扩展方法的形式去编写,那些方法能够借助原接口所定义的基本功能来完成自身的任务。这样做使得实现该接口的人只需要给少数几个方法编写代码,而客户端则既可以使用这几个基本方法,又可以使用基于这些方法所开发出来的扩展方法。有一个问题需要注意:如果已经针对某个接口定义了扩展方法,而其他一些类又想要以它们自己的方式来实现这个扩展方法,那么就有可能产生奇怪的结果。在实际的编程工作中,应该保证扩展方法的行为与类里面的同名方法相一致。也就是说,如果想在类中以更为高效的算法重新实现早前所定义的扩展方法,那么应该保证其行为与之相同。保证了这一点,就不会影响程序正常运行。第28条:考虑通过扩展方法增强已构造类型的功能编写应用程序时,可能需要使用一些采用特定类型参数构造的泛型类型,例如可能需要使用List及Dictionary<EmployeeID,Employee>等形式的集合。之所以创建这种形式的集合,是因为应用程序要向集合中放入特殊类型的元素,因而需要专门针对这样的元素给集合定义一些特殊的功能。// 下面以IEnumerable<int>为例来列举其中的几个: public static class Enumerable { public static int Average(this IEnumerable<int> sequence); public static int Max(this IEnumerable<int> sequence); public static int Min(this IEnumerable<int> sequence); public static int Sum(this IEnumerable<int> sequence); // 省略其他方法 }若能将这些方法实现成针对某个泛型类型或泛型接口的扩展方法,则会令那个以特定参数而构造的泛型类型或接口具备丰富的功能。这样做还可以最大限度地将数据的存储模型(storage model)与使用方式相解耦。第四章、合理地运用LINQLINQ的一个目标是令语言中的元件能够在各种数据源上面执行相同的操作。合理地运用LINQ更加顺畅地处理各种数据源,如果有需要的话,还可以创建自己的数据提供程序(data provider)。第29条:优先考虑提供迭代器方法,而不要返回集合迭代器方法是一种采用yield return语法来编写的方法,它会等到调用方请求获取某个元素的时候再去生成序列中的这个元素。// 下面是个简单的迭代器方法,用来生成由小写英文字母所构成的序列: public static IEnumerable<char> GenerateAlphabet() { var letter = 'a'; while(letter <= 'z') { yield return letter; letter++; } }生成该元素的操作只有在调用方真正使用这个元素时才会发生。只有当调用方真正用到序列中的某个元素时程序才会通过那个对象创建该元素。这使得程序在调用生成器方法(generator method)时只需要执行少量的代码。缺点:给迭代器方法传入了错误的参数,那么这个错误要等到程序真正使用函数的返回值时才能够暴露,而无法在传入错误参数的时候就直接以异常的形式表现出来。有没有哪种场合是不适宜用迭代器方法来生成序列的?其实这些问题应该留给调用迭代器方法的人去考虑,你不用刻意去猜测别人会怎样使用你创建的这个方法,因为他们可以自己去决定如何使用该方法所返回的结果。有了这样的方法,开发者就可以自由选择是通过ToList或ToArray将整个序列都提早生成出来,还是通过你所提供的方法逐个生成并处理序列中的每个元素。第30条:优先考虑通过查询语句来编写代码,而不要使用循环语句与采用循环语句所编写的命令式结构相比,查询语句(也包括实现了查询表达式模式(query expression pattern)的查询方法)能够更为清晰地表达开发者的想法。// 循环语句 private static IEnumerable<Tuple<int, int>> ProduceIndices() { for (var x = 0; x < 100; x++) for (var y = 0; y < 100; y++) yield return Tuple.Create(x, y); } // 查询语句 private static IEnumerable<Tuple<int, int>> QueryIndices() { return from x in Enumerable.Range(0, 100) from y in Enumerable.Range(0, 100) select Tuple.Create(x, y); }这两种写法看上去差不多,但是后一种写法(查询语句)在问题变得复杂之后依然能够保持简洁。命令式的写法则必须创建存储空间来保存中间结果。还有一条理由也能说明查询语句比循环结构要好,因为前者可以创建出更容易拼接的API。如果你怀疑查询式的写法在某种特定情况下运行得不够快,可以通过.AsParallel()方法来并行地执行这些查询。编写循环结构时,总是应该想想能不能改用查询语句来实现相同的功能,如果不行,那再想想能不能改用查询方法来写。第31条:把针对序列的API设计得更加易于拼接针对整个集合中的每一个元素执行操作,那么程序的效率会很低。把通用的IEnumerable\<T>或针对某种类型的IEnumerable\<T>设计成方法的输入及输出参数是一种比较少见的思路,因此,很多开发者都不会这样去做,但是这种思路确实能带来很多好处。迭代器方法会等调用方真正用到某个元素时才去执行相应的代码,与传统的命令式方法相比,这种延迟执行(deferred execution,参见第37条)机制可以降低算法所需的存储空间,并使算法的各个部分之间能够更为灵活地拼接起来(参见第40条)。// 为了演示迭代器方法的好处,笔者先举一个简单的例子,然后用迭代器方法改写。 public static void Unique(IEnumerable<int> nums) { var uniqueVals = new HashSet<int>(); foreach(var num in nums) { if(!uniqueVals.Contains(num)) { uniqueVals.Add(num); Console.WriteLine(num); } } } // 为此,可以考虑改用迭代器方法来实现: public static IEnumerable<int> UniqueV2(IEnumerable<int> nums) { var uniqueVals = new HashSet<int>(); foreach (var num in nums) { if (!uniqueVals.Contains(num)) { uniqueVals.Add(num); yield return num; } } }迭代器方法真正强大之处在于它可以把多个步骤拼接成一整套流程。如果能把复杂的算法拆解成多个步骤,并把每个步骤都表示成这种小型的迭代器方法,那么就可以将这些方法拼成一条管道,使得程序只需把源序列处理一遍即可对其中的元素执行许多种小的变换。第32条:将迭代逻辑与操作、谓词及函数解耦要想把这种算法内部的某些逻辑开放给调用方去定制,只能将这些逻辑表示成方法或函数对象,并传给表示该算法的那个外围方法。具体到C#来说,就是要把那个可供定制的内部逻辑定义成delegate。匿名的委托主要有两种习惯用法,一种是用来表示函数,另一种是用来表示操作。这样做的的好处主要在于可以把迭代序列时所用的逻辑与处理序列中的元素时所用的逻辑分开。public static IEnumerable<T> Transform<T>(IEnumerable<T> sequence, Func<T,T> method) { // null检查序列和方法被省略 foreach(T element in sequence) { yield return method(element); } } // 写好这个方法之后,可以用下面这行代码对序列中的每个整数取平方,从而令这些平方值构成新的序列: foreach (int i in Transform(myInts, value => value * value)) Console.WriteLine(i);public static IEnumerable<Tout> Transform<Tin, Tout>(IEnumerable<Tin> sequence, Func<Tin, Tout> method) { // null检查序列和方法被省略 foreach (Tin element in sequence) yield return method(element); } foreach (string s in Transform(myInts, value => value.ToString())) WriteLine(s); 第33条:等真正用到序列中的元素时再去生成在前面的建议有提到过,就是强调迭代器yield return的用法。static IEnumerable<int> CreateSequence(int numOfElements, int startAt, int stepBy) { for (int i = 0; i < numOfElements; i++) { yield return startAt + i * stepBy; } }在消费该序列的代码真正用到某个元素时再去生成此元素是一种很好的做法,因为如果整个算法只需执行一小部分即可满足消费方的要求,那么就不用再花时间去执行其余那一部分了。这样做可能只会小幅提升程序的效率,但如果创建元素所需的开销比较大,那么提升的幅度也会很大。第34条:考虑通过函数参数来放松耦合关系如果使用委托或其他一些通信机制来放松耦合关系,那么编译器可能就不会执行某些检查工作了,因此,你需要自己设法来做这些检查。设计组件时,首先还是应该考虑能否把本组件与客户代码之间的沟通方式约定成接口或者采用委托来描述本组件所要使用的方法,那么用起来会更加灵活。根据具体的情况来选择是接口还是委托。设计组件时,首先还是应该考虑能否把本组件与客户代码之间的沟通方式约定成接口。如果有一些默认的实现代码需要编写,那么可以考虑将其放入抽象基类中,使得调用方无须重新编写这些代码。如果采用委托来描述本组件所要使用的方法,那么用起来会更加灵活,但开发工具对此提供的支持也会更少,因此,你需要编写更多的代码才能确保这种灵活的设计能够正常运作。第35条:绝对不要重载扩展方法在第27与28条说过,针对接口或类型来创建扩展方法有三个好处:- 第一,能够为接口实现默认的行为; - 第二,能够针对封闭的泛型类型实现某些逻辑; - 第三,能够创建出易于拼接的接口方法。通过扩展方法来编写默认代码是专门针对接口而言的,如果要扩展的是类,那么还有更好的办法可供选用。滥用或误用扩展方法很容易使方法之间产生冲突,从而令代码难于维护。扩展方法并不是根据对象的运行期类型而派发的,它依据的是编译期类型,这本身就容易出错,再加上有些人又想通过切换命名空间(作者很不推荐这么做)来切换扩展方法的版本,这就更容易出问题了。如果你发现自己正在编写很多个签名相同的扩展方法,那么赶紧停下来,把方法的签名改掉,并考虑将其设计成普通的静态方法,而不要做出那种通过切换using指令来影响程序行为的设计方案,因为那样会令开发者感到困惑。第36条:理解查询表达式与方法调用之间的映射关系LINQ构建在两个概念之上:一是查询语言(query language)本身,二是该语言与查询方法之间的转换关系。在设计某个类时,你必须清楚由系统所提供的那些查询方法是否合适,自己能不能针对当前这个类实现出更好的版本。完整的查询表达式模式(query expression pattern)包含11个方法。编译器只能保证你创建的接口方法符合语法规定,但无法保证它能满足用户的要求。如果你觉得自己可以利用该类内部的某些特性编写出比默认方式更为高效的实现代码,那么就必须保证该类完全遵从查询表达式模式所做出的约定。第37条:尽量采用惰性求值的方式来查询,而不要及早求值每迭代一遍都产生一套新的结果,这叫作惰性求值(lazy evaluation),反之,如果像编写普通的代码那样直接查询某一套变量的取值并将其立刻记录下来,那么就称为及早求值(eager evaluation)。首先通过一段代码来理解惰性求值与及早求值之间的区别:private static IEnumerable<TResult> Generate<TResult>(int number, Func<TResult> generator) { for(var i = 0; i < number; i++) { yield return generator(); } } private static void LazyEvaluation() { WriteLine($"Start time for Test One: {DateTime.Now:T}"); var sequence = Generate(10, () => DateTime.Now); WriteLine("Waiting.... tPress Return"); ReadLine(); WriteLine("Iterating..."); foreach (var value in sequence) WriteLine($"{value:T}"); WriteLine("Waiting.... tPress Return"); ReadLine(); WriteLine("Iterating..."); foreach (var value in sequence) WriteLine($"value:T}"); }通过这种办法可以很有效地了解C#系统如何对LINQ查询求值。如果代码写得较为合理,那么程序只需检查序列的开头部分即可,因为它可以在找到所需的答案时停下来。编写算法的时候,如果能把那种需要处理整个序列的操作放在合适的时机去执行,那么算法可能会执行得很快,反之,则有可能耗费极长的时间。在个别情况下,你可能确实想给序列中的值做一份快照,这时可以考虑ToList()及ToArray()这两个方法,它们都能够立刻根据查询结果来生成序列,并保存到容器中。其区别在于,前者用List\<T>保存,后者用Array保存。总之,与及早求值的方式相比,惰性求值基本上都能减少程序的工作量,而且使用起来也更加灵活。在少数几种需要及早求值的场合,可以用ToList()或ToArray()来执行查询并保存结果,但除非确有必要,否则还是应该优先考虑惰性求值。第38条:考虑用lambda表达式来代替方法涉及查询表达式与lambda的地方应该用更为合理的办法去创建可供复用的代码块。var allEmployees = FindAllEmployees(); // 寻找第一批员工: var earlyFolks = from e in allEmployees where e.Classification == EmployeeType.Salary where e.YearsOfService > 20 where e.MonthlySalary< 4000 select e; // 找到最新的人: var newest = from e in allEmployees where e.Classification == EmployeeType.Salary where e.YearsofService< 20 where e.MonthlySalary< 4000 select e; 你可以把查询操作分成许多个小方法来写,其中一些方法在其内部用lambda表达式处理序列,而另一些方法则可以直接以lambda表达式做参数。把这些小方法拼接起来,就可以实现整套的操作。这样写既可以同时支持IEnumerable与IQueryable,又能够令系统有机会构建出表达式树,以便高效地执行查询。第39条:不要在Func与Action中抛出异常例如下面这段代码,要给每位员工加薪5%:var allEmployees = FindAllEmployees(); allEmployees.ForEach(e => e.MonthlySalary *= 1.05M);假如这段代码在运行过程中抛出异常,那么该异常很有可能既不是在处理第一位员工之前抛出的,又不是在处理完最后一位员工之后抛出的,导致你无法掌握程序的状态,因此必须把所有数据都手工检查一遍,才能令其保持一致。要想解决此问题,你可以设法做出强异常保证,也就是在这段代码未能顺利执行完毕的情况下,确保程序状态(与执行之前相比)不会出现明显的变化。需要仔细定义相关的函数及谓词,以确保方法所订立的契约在各种情况下都能得到满足,即便是在发生错误的情况下也是如此。首先你的做法可以过滤到那些可能加薪失败的员工(比如不在数据库内),但是这并不彻底。其次,你可以先复制一份,等到副本可以全部成功加薪之后,再赋给原对象,但是,开销会变大。同时使算法的拼接性变差。与一般的写法相比,用lambda表达式来编写Action及Func会令其中的异常更加难以发觉。因此,在返回最终结果之前,必须确定这些操作都没有出现异常,然后才能用处理结果把整个源序列替换掉。第40条:掌握尽早执行与延迟执行之间的区别声明式的代码(declarative code)的重点在于把执行结果定义出来,而命令式的代码(imperative code)则重在详细描述实现该结果所需的步骤。//命令式写法 var answer = DoStuff(Method1(), Method2(), Method3()); //声明式写法 var answer = DoStuff(()=>Method1(), ()=>Method2(), ()=>Method3());总之,只有当程序确实要用到某个方法的执行结果时,才会去调用这个方法。这是声明式写法与命令式写法之间的重要区别。如果把两种写法混起来用,那么程序可能就会出现严重的问题。这两种写法之间最重要的区别在于:前者必须先把数据算好,而后者则可以等到将来再去计算。如果采用第一种写法(命令式),那么必须提前调用相关方法,以获取该方法所计算出的数据,而不像第二种写法(声明式)那样,可以按照函数式编程的风格,用包含该方法本身的lambda表达式来暂时代替这个方法,等真正要用到该方法的执行结果时再去计算。在某些情况下,把这两种求值方式混起来用的效果是最好的。也就是说,其中某些结果可以尽早计算并缓存起来,而另一些结果则等到用的时候再去计算。编写C#算法时,先要判断用数据(也就是算法的结果)当参数与用函数(也就是算法本身)当参数会不会导致程序的运行结果有所区别。在难以判断的情况下,不妨优先考虑把算法当成参数来传递,这样做可以令编写函数的人更为灵活,因为他既可以采用惰性求值的方式稍后再去调用该算法,也可以采用及早求值的方法立刻获取该算法的执行结果。第41条:不要把开销较大的资源捕获到闭包中闭包(closure)会创建出含有约束变量(bound variable)的对象,但是这些对象的生存期可能与你想的不一样,而且通常会给程序带来负面效果。如果算法使用了一些查询表达式,那么编译器在编译这个方法时,就会把同一个作用域内的所有表达式合起来纳入同一个闭包中,并创建相应的类来实现该闭包。这个类的实例会返回给方法的调用者。对于迭代器方法来说,这个实例有可能是实现了迭代逻辑的那个对象中的成员。只有当该实例的使用方全都从系统中移除之后,它才有可能得到回收,这就会产生很多问题。如果程序从方法中返回的是一个用来实现闭包的对象,那么与闭包相关的那些变量就全都会出现在该对象里面。你需要考虑程序此后是否真的需要用到这些变量。如果不需要使用其中的某些变量,那么就应该调整代码,令其在方法返回的时候能够及时得到清理,而不会随着闭包泄漏到方法之外。第42条:注意IEnumerable与IQueryable形式的数据源之间的区别IQueryable\<T>与IEnumerable\<T>这两个类型在API签名上面很像,而且前者继承自后者,因此很多情况下它们可以互换。var q = from c in dbContext.Customers where c.City == "London" select c; var finalAnswer = from c in q orderby c.Name select c; // 迭代被省略的最终答案序列的代码 var q = (from c in dbContext.Customers where c.City == "London" select c).AsEnumerable(); var finalAnswer = from c in q orderby c.Name select c; //迭代最终答案的代码IQueryable\<T>内置的LINQ to SQL机制,IEnumerable\<T>是把数据库对象强制转为IEnumerable形式的序列,并把排序等工作放在本地完成。有些功能用IQueryable实现起来要比用IEnumerable快得多。用IEnumerable所写的那个版本必须在本地执行,系统要把lambda表达式编译到方法里面,并在本地计算机上面运行,这意味着无论有待处理的数据在不在本地,都必须先获取过来才行。用IQueryable实现出来的版本则会解析表达式树,在解析的时候,系统会把这棵树所表示的逻辑转换成provider能够操作的格式,并将其放在离数据最近的地方去执行如果在性能与健壮这两项因素之间更看重后者,那么可以把查询结果明确转换成IEnumerable,这样做的缺点是LINQ to SQL引擎必须把dbContext.Products中的所有内容都从数据库中获取过来。可以使用AsEnumerable()与AsQueryable()进行相互转换。IQueryable更适合远程执行(数据库)。第43条:用Single()及First()来明确地验证你对查询结果所做的假设如果你要确保查询结果里面有且仅有一个元素,那么就应该使用Single()来表达这个意思,因为这样做是很清晰的。只要查询结果中的元素数量与自己的预期不符,程序就会立刻抛出异常。var answer = (from p in somePeople where p.FirstName == "Larry" select p).Single();由这段代码的写法可以看出,开发者希望能够查到一位名叫Larry的人。var answer = (from p in somePeople where p.FirstName == "Larry" select p).SingleOrDefault(); 如果你想表达的意思是要么查不到任何元素,要么只能查到一个元素,那么可以用SingleOrDefault()来验证,然而要注意,如果查到的元素不只一个,那么该方法也会像Single()那样抛出异常。有的时候,你并不在乎查到的元素是不是有很多,而只是想取出这样的一个元素而已,这种情况下,可以考虑用First()或FirstOrDefault()方法来表达这个意思。除了这些方法之外,尽量不要用别的方法来获取查询结果中的特定元素,而是应该考虑通过更好的写法来寻找那个元素,使得其他开发者与代码维护者能够更为清晰地理解你想找的究竟是什么。第44条:不要修改绑定变量编译器创建的这个嵌套类会把lambda表达式所访问或修改的每个变量都囊括进来,而且原来访问局部变量的那些地方现在也会改为访问该嵌套类中的字段。如果在定义查询表达式的时候用到了某个局部变量,而在执行之前又修改了它的值,那么程序就有可能出现奇怪的错误,因此,捕获到闭包中的那些变量最好不要去修改。第五章、合理地运用异常程序总是会出错的,因为即便开发者做得再仔细,也还是会有预料不到的情况发生。令代码在发生异常时依然能够保持稳定是每一位C#程序员所应掌握的关键技能。本章中的各条会讲解怎样通过异常来清晰而精准地表达程序在运行中所发生的错误,而且还会告诉大家怎样管理程序的状态才能令其更容易地从错误中恢复。第45条:考虑在方法约定遭到违背时抛出异常如果方法不能够完成其所宣称的操作,那么就应该通过异常来指出这个错误, 同时用异常来表示程序在运行过程中所遇到的状况要比用错误码更好。与采用错误码相比,通过异常来报告错误是更加恰当的做法,因为这样做有很多好处。- 错误码必须由调用该方法的人来处理,而异常则可以沿着调用栈向上传播,直至到达合适的catch子句。 - 此外还有一个好处,就是异常不会轻易为人所忽视。如果没有适当的catch子句能够处理异常,那么应用程序就会(明确地)终止,而不会悄无声息地继续运行下去,以防数据受损。由于异常本身也是类,因此,你可以从其中派生自己的异常类型,从而表达出较为丰富的错误信息。由于异常并不适合当作控制程序流程的常规手段,因此,还应该同时提供另外一套方法,使得开发者可以在执行操作之前先判断该操作能否顺利执行,以便在无法顺利执行的情况下采取相应的措施,而不是等到抛出了异常之后再去处理。第46条:利用using与try/finally来清理资源如果某个类型用到了非托管型的系统资源,那么就需要通过IDisposable接口的Dispose()方法来明确地释放。拥有非托管资源的那些类型,都实现了IDisposable接口,此外还提供了finalizer(终结器/终止化器),以防用户忘记释放该资源。using语句能够确保Dispose()总是可以得到调用。如果函数里面只用到了一个IDisposable对象,那么要想确保它总是能够适当地得到清理,最简单的办法就是使用using语句,该语句会把这个对象放在try/finally结构里面去分配。凡是实现了IDisposable接口的对象都应该放在using语句中或者try块中去实现,否则就有可能泄露资源。SqlConnection myConnection = null; // 示例: using (myConnection = new SqlConnection(connString)) { myConnection.Open(); } // Try / Catch块: try { myConnection = new SqlConnection(connString); myConnection.Open(); } finally { myConnection.Dispose(); }如果你不清楚某个对象是否实现了IDisposable接口,那么可以通过as子句来安全地处置它://正确的修复 //对象是否支持IDisposable object obj = Factory.CreateResource(); using (obj as IDisposable) Console.WriteLine(obj.ToString());Dispose()方法并不会把对象从内存中移除,它只是提供了一次机会,令其能够释放非托管型的资源。最好是把这样的对象包裹在using语句或try/finally结构里面,总之,无论采用什么样的写法,你都要保证这些资源能够正确地释放。第47条:专门针对应用程序创建异常编写应用程序(或程序库)时,必须把那些需要用不同方式来处理的情况设计成不同的异常类型。第一,并不是所有的错误都必须表示成异常。至于什么样的错误应该表示成异常,什么样的错误不必表示成异常,则没有固定的规律可循。但笔者认为:如果某种状况必须立刻得到处理或汇报,否则将长期影响应用程序,那么就应该抛出异常。第二,并不是每写一条throw语句就要新建一种异常类。应该仔细想想,能不能创建一种新的异常类,以促使调用方更为清晰地理解这个错误,从而试着把应用程序恢复到正常状态。之所以要创建不同的异常类,其原因很简单,就是为了令调用API的人能够通过不同的catch子句去捕获那些状况,从而采用不同的办法加以处理。//以Exception类为例子 //默认构造函数 public Exception(); //创建一个消息 public Exception(string); //使用消息和内部异常创建。 public Exception(string, Exception); //从输入流创建 protected Exception(SerializationInfo, StreamingContext);一旦决定自己来创建异常类,就必须遵循相应的原则。这些类都要能够追溯到Exception才行,如果从Exception中派生子类,那么应该创建四个构造函数,以便与上述四者相对应。异常转换(exception translation),用来将底层的异常转化成高层的异常,从而提供更贴近于当前情境的错误信息(有利于调式)。在某些情况下,确实有必要抛出异常,此时应该专门做一些处理,而不要把你在调用核心框架时由.NET Framework所产生的那个异常原封不动地抛出去。第48条:优先考虑做出强异常保证异常所做的保证分成三种,即基本保证(basic guarantee)、强保证(strong guarantee)及no-throw保证(不会抛出异常的保证)。在这三种态度中,强保证是较为折中的,它既允许程序抛出异常并从中恢复,又使得开发者能够较为简便地处理该异常。应用程序中有很多种操作都会在未能顺利执行完毕的情况下令程序陷入无效的状态。这些状况很难完全顾及,因为没有哪一套标准的流程能够自动地应对它们。为此,你可以考虑做出强异常保证来避开其中的很多问题。强异常保证这种做法规定:如果某操作抛出异常,那么应用程序的状态必须和执行该操作之前相同。也就是说,这项操作要么完全成功,要么彻底失败。除了基本保证与强保证之外,还有一种最为严格的保证,叫作no-throw保证。它指的就是字面上的意思,即保证方法肯定能够运行完毕而绝对不会从中抛出异常。对于大型的程序来说,要求其中的所有例程都达到这种地步是不太现实的,但在其中的某几个地方确实不能令方法抛出异常,比方说,finalizer(终结器/终止化器)与Dispose就是如此。你可以把那种较为复杂的方法包裹在try/catch结构里面去调用,从而将该方法所抛出的异常吞掉,以此来做出no-throw保证。还有一个地方也应该做出no-throw保证,那就是委托目标(delegate target)。笔者再说一遍:包括事件处理程序在内的各种委托目标都不应该抛出异常,如果抛出,那么触发事件的那段代码就无法做出强异常保证。finalizer、Dispose()方法、when子句及委托目标是四个特例,在这些场合,绝对不应该令任何异常脱离其范围。如果在拷贝出来的临时数据上面执行完操作之后想用它把原数据替换掉,而原来的数据又是引用类型,那么要多加小心,因为这可能引发很多微妙的bug。第49条:考虑用异常筛选器来改写先捕获异常再重新抛出的逻辑如果改用异常筛选器来捕获并处理异常,那么以后诊断起来就会容易一些,而且不会令应用程序的开销增大。异常筛选器是针对catch子句所写的表达式,它出现在catch右侧那个when关键字之后,用来限定该子句所能捕获的异常:var retryCount = 0; var dataString = default(String); while(dataString == null) { try { dataString = MakeWebRequest(); } catch(TimeoutException e) when(retryCount++ < 3) { WriteLine("Operation timed out. Trying again"); //再次尝试前暂停。 Task.Delay(1000 * retryCount); } }采用异常筛选器来写,那么诊断信息里面就会带有程序的状态,从而令你能够判断出问题的根源。.NET CLR对带有when关键字的try/catch结构做了优化,使得程序在无须进入该结构时其性能尽量不受影响。如果异常筛选器无法处理某个异常,那么程序就无须展开调用栈,也不用进入catch块,这使得其性能要比先捕获再重新抛出的办法更高,总之无论如何,也不会比它差。使用了异常筛选器之后,可以调整原有的异常处理代码,把多余的判断逻辑去掉,只用catch子句来捕获你能够完全应对的那些异常。如果仅通过异常的类型不足以判断出自己到底能不能处理该异常,那么可以考虑给相关的catch子句添加筛选器,使得程序只有在筛选条件得以满足时才会进入这个catch块。第50条:合理利用异常筛选器的副作用来实现某些效果系统在寻找catch子句的过程中会执行这些筛选器,而此时,调用栈还没有真正展开(于是,不妨利用这一特性来实现某些效果)。可以把catch(Exception e)when log(e){}这样的写法随时套用到已有的代码中,因为它并不会干扰程序正常运行。如果合理地利用异常筛选器所引发的某些副作用,那么很容易就能观察到程序究竟是在什么样的状况下抛出异常的。注意本章图文内容均来源于《Effective C#:改善C#代码的50个有效方法》一书, 自己整理收集,方便学习参考,版权属于原作者。
文章
SQL  ·  开发框架  ·  算法  ·  .NET  ·  Java  ·  编译器  ·  API  ·  C#  ·  数据库  ·  开发者
2023-02-22
...
跳转至:
PolarDB开源社区
996 人关注 | 272 讨论 | 195 内容
+ 订阅
  • 源码解读:PolarDB-X中的窗口函数
  • [版本更新]PolarDB-X v2.2.1 生产级关键能力开源升级
  • PolarDB-X 全局Binlog解读之HA篇
查看更多 >
开发与运维
5786 人关注 | 133444 讨论 | 319646 内容
+ 订阅
  • jupyter 输出向量自动省略了中间的值,如何查看完整的向量值
  • Vue3+TypeScript学习笔记(七)
  • Vue3+TypeScript学习笔记(五)
查看更多 >
数据库
252947 人关注 | 52319 讨论 | 99303 内容
+ 订阅
  • 关于常见设计模式的那些事 | 2.5W字长文
  • 谷歌大数据的三驾马车
  • Java的运行时数据区域
查看更多 >
人工智能
2875 人关注 | 12395 讨论 | 102743 内容
+ 订阅
  • jupyter 输出向量自动省略了中间的值,如何查看完整的向量值
  • torch 如何实现两点分布采样,要求采100个样本,其中20个样本为数字1,80个为数字2
  • pytorch 如何生成指定位置、尺度参数的随机高斯矩阵,并指定这个随机矩阵的形式
查看更多 >
云原生
234326 人关注 | 11614 讨论 | 47415 内容
+ 订阅
  • rocketmq-spring : 实战与源码解析一网打尽
  • vmware 设置 系统环境
  • CSS块格式化上下文(Block Formatting Context,BFC)
查看更多 >