暂时未有相关云产品技术能力~
假设int数组中有1,2,3,4....直到100万个数字,大约占4MB的空间。每个数字都存储它和前一个数字的差值,差值都是1,一个数字1的话可以用一个bit存储,因为一个bit的存储范围是0-1,本来用32个bit存储一个数字,现在用1个bit来存储。100万个数字只用100万个bit,原本是3200万个bit,压缩倍率是32倍。如果数据量是32T,压缩之后就变成1T了,从1T中检索的效率是从32T中检索效率的32倍。这是一个极端特殊的情况,因为实际中id不会重复且不会连续,如果不是连续的,那么差值就不是1。倒排表里面存储的是id,这里面数字不是连续的,但一定是有序的,从小到大的,在索引创建的时候排好的序。6个数字,一个数字占4个字节,也就是会占用24个字节。计算差值,得到的差值列表是1个bit取值范围是0-1;2个bit是0-3,能存储4个数字;3个bit,就是2的3次方为8,取值范围是0-7。8个bit能存储256个数字。自定义类型来存储数字。看差值列表中最大的数字是227,用7个bit是否可以存储,7个bit能存储的数值最大是127,显然存储不下227,只能用8个bit来存储,因为8个bit最大能存储255。当前这个数组中的每个数字只用8个bit来存储就可以了即6个数字,48个bit,6个字节。原本这6个数字需要24个字节,现在只需要6个字节,压缩为原本的1/4。继续优化...227用8个bit存储,但是2用2个bit存储就可以了,因为2个bit存储范围是0-3。把这个数值切开,前面的大数用8个bit来存储,后面的小数用4个bit来存储。先别管从哪里切,就看哪边的数字间隔比较大,比如前面的数字由227一下子到了2,后面的数字都比较小,前面的数字用8个bit存储,后面的数字取决于它的最大值。后面的数最大是30,5个bit(取值范围是0-31)可以存储的下,也就是后面的数组每个数字用5个bit就可以存储。截止目前将一个大的数组拆分成了2个小的数组,前面的数组每个数字用8个bit来存储,后面的数组每个数字用5个bit来存储。jdk中定义了用32个bit来存储一个int类型的数据,用64个bit来存储一个long类型的数据。自定义定义一个用5个bit来存储的类型叫α,定义一个用8个bit来存储的类型叫β,类型的定义也要占用一个字节的空间。如果对每个数字都定义一个类型,那么定义的类型就太多了,就会占用很多的空间。假如说73和227都用8个bit的α来存储,本身α这个类型就占用一个字节。接下来计算下这个差值列表[73,227,2,30,11,29]一共占多少空间?73和227使用8个bit的自定义类型β来存储,β类型占1个字节,每个数字占8个bit即1个字节,2个数字占2个字节,共占3个字节。后面的数组2,30,11,29,用5个bit存储的自定义类型α来存储,α类型占1个字节,每个数字占5个bit,4个数字是20个bit即3个字节,共4个字节,所以这个数组一共占3+4=7个字节。按照每个数字都用一个自定义类型来存储看看会占用多少空间?73用7个bit来存储(在计算机操作系统层面,数据存储的最小单位是字节,一个字节是8个bit,这里使用7个bit,其实并没有省出空间来,实际占用的还是8个bit,这里就假设占用7个bit),这个定义类型占1个字节;227用8个bit来存储,自定义类型占一个1字节;那么[73,227]这个数组共占用1B+7b+1B+1B共3个字节+7个bit,也就是说不但没有节省空间,反而浪费了好几个bit空间,也就是说这个数组没有必要拆分的那么细,把这些数据浮动不大的数字放在一起,把这些数据较小的放在一起,是最适合的。倒排索引的过程然后对当前词项进行规范化(比如大写字母开头的Are转换成are、China和chinese转换成china(这是两个词,希望转换成一个词,降低检索成本)、's转换成is、过去分词转换成现在分词(made转换成make、kidding转换成kid))、去重、字典排序(按照abc..),最终的结果存在词项字典(term dictionary)有序的词项字典,存储的是所有去重之后的结果,当然存储并不是按照二维表格的方式存储的。Posting List 是倒排表,存储的是包含了当前词项的id集合。TF是该词项出现的频率。磁盘格式化1、容量是选择要格式化的磁盘。2、文件格式:• exFAT格式windows、linux、macos都有该文件格式,缺点就是每个格子都比较大,使用该格式,如果是大文件还可以,如果是小文件,就会占用大量的磁盘空间。• NTFS格式3、单元就是一个data page空间大小,exFAT默认是128KB,NTFS默认是4KB一个文件大小若为1KB,没有占满一个4KB大小的格子,该文件占用空间也是4KB。内存检索数据的时候,最少读取磁盘中一个格子的数据,磁盘中一个格子占用空间是4KB,所以内存从磁盘中读取最小的数据单元就是一个4KB大小的格子。搜索引擎的相关指标全文检索的搜索引擎包括百度、搜狗、谷歌;垂直搜索的搜索引擎包括汽车之家、小米有品。• 快高效的压缩和快速的编码和解码• 准确(相关度)两种相关度评分算法 BM25和TF-IDF• 召回率召回率是返回数据丰富度的衡量指标,返回的越多,召回率越高。搜索和检索的区别搜索是要么符合条件,要么不符合条件,没有说部分符合;但检索是有相关度的。这个查询示例中,查询"小米 NFC 手机",id=1的数据中包含2个词项,相关度为2;id=2的数据中包含3个词项,那么id=2的数据和查询需求的相关度更高。
B+TreesB+Trees相比于B-Trees,把非叶子节点中的data部分去掉了,只留下键值和指针,这样做的好处就是每个非叶子节点中就可以存储更多的数据,从而减少树的深度,提高检索效率。数据都放在了叶子节点中。如果磁盘的数据在往u盘中拷贝的时候,如果拷贝的是源码,比如上千个文件,每秒传输速度只有几KB,本来100多M的大小,却需要10分钟或更久。如果只是一个zip压缩包,就会很快,因为zip包是一个文件,一个文件在磁盘中占用的空间是连续的。多个文件在磁盘中的位置是随机分布的,在拷贝的时候会不断的产生io。如果是连续的数据,可以从开始位置读取到结束为止。连续读比随机读要快的多。当对大数据文本做检索的时候B+Trees的性能会直线下降。你检索的这段可能是这种长文本字段,需要对title和content两个字段创建索引。因为B+Trees的要求是单个索引长度尽量小,所以这种场景就没有办法使用B+Trees了,因为当建立索引的字段都是文本字段,可能一个索引就会占用很多个节点,就会导致树的深度无限深,iO次数无限多,性能极差。索引可能会失效。精确度也很差。所以MySQL索引是不能解决大数据检索问题的。全文检索索引系统通过扫描文章中的每一个词,对其创建索引,指明在文章中出现的次数和位置,当用户查询时,索引系统就会根据事先创建的索引进行查找,并将查找的结果反馈给用户的检索方式。假设这是一张10亿数据量的表(实际开发过程中10亿的数据不会用数据表的方式存储,这里只是为了说明原理 )。执行这个模糊查询,对product字段进行检索,会造成全表扫描。这种查询的弊端:索引效率慢,准确度差(上面的查询语句,id=2的那条数据查询不出来)建立倒排索引数据表term dictionary:词项字典,这里面存放的是对索引字段product切词、规范化、去重、字典顺序之后的词项。Posting List:存放的是当前词项所在的数据的id集合,id是由小到大有序的。查询需求是"小米 NFC 手机",切词之后得到的第一个词项是小米,去词项字典中检索,第一个数据就命中了小米,就得到了小米所在数据的id集合,然后再获取对应的原始数据。这里面有10亿条数据,我只查询了一次就知道了这10亿条数据里面有哪些数据包含了这个词项。命中索引之后,就做一个标记,标记为命中,这就是倒排索引,当前分别对3个词项进行检索的过程,就是全文检索。百度就是全文检索引擎。倒排索引中的三个数据结构• Posting List倒排表,包含了当前词项的每个数据的id,这是一个int类型的数组,不管id原先是什么类型,这里是int类型。• 词项字典词项字典里面存储的就是创建索引字典的每个词项。• term index这是一个抽象的数据结构,为了加速当前词项检索的,底层是用的FST数据结构。每一条数据如果拆分成若干个词项,每个词项在索引里面是一个新的数据,假如一条数据拆分成5个索引数据行,10亿条数据,索引的行数甚至有可能大于原数据的行数,假如这个索引检索了10亿次还没有查到,所以本身检索也是一个问题。假如包含小米这个词项的数据有100万个数据,一个索引项有100万数据,怎么存储Post List这些数据,本身就是一个问题。Post List是一个int数组,一个int是4个字节Byte,也就是32个bit,如果100万个id就是100万*4B=400万个字节即一个索引所带来的存储成本约等于3.8M。十亿数据就是10亿 * 3.8,所以如何解决这个存储问题呢?怎么节省存储空间,提高检索性能?• 硬件层面访问量高的用SSD,可读可写;访问量不是那么高的用HDD机械硬盘,只读;访问量再低的,就用COLD冻结索引,快照的形式存放,7.x版本的es就支持可搜索快照。当然这个不能解决主要问题。• 软件层面软件层面要节省存储空间,需要考虑的是怎么解决压缩数据的问题。一个int类型占4个字节,也就是32个bit比特,一个bit位就是8个01组成。一个字节8个bit,如果要存一个0,就是8个0;存一个1就是00000001;存一个2就是00000010(进一位)。一个bit能存储的数据就是2的8次方-1,这也是为什么int类型的范围是2的31次方-1,为什么是31呢,因为有一个bit是用来存储+-号的。如果100万个id就是100万*4B=400万个字节即一个索引所带来的存储成本约等于3.8MFOR压缩Frame Of Reference
简介海量数据分析并不一定要用elasticsearch,但搜索一定要用elasticsearch。elasticsearch是基于文档型的数据结构。百度是全文搜索引擎,搜索的内容不是固定的;京东淘宝是垂直搜索,有明确的搜索目的,站内搜索是垂直领域的一种。搜索引擎包括NLP(自然语言分析处理)、大数据处理、网页处理、爬虫、算法、elasticsearch。elasticsearch除了搜索之外,还可以做大数据分析,ELK就是使用的elasticsearch大数据分析功能。搜索框架技术选型• redisredis不适合做大数据搜索和存储,它是一个内存型数据库。如果开启持久化,性能会下降。• mongodbmongodb适合做大数据存储,可以做搜索引擎,但更适合对数据的管理,不适合做检索。• eses更倾向于搜索。数据写入的实时性并不高。es不支持事务。es和mongodb都是基于文档型数据库。• solr搜索这块,可以和es相比较的是solr,两者都是基于lucene。lucene是单点的。solr是技术比较老的框架。es稳定性不会随着数据量的增大而发生明显的改变,es天生就支持分布式的,solr不一样,并不是天生就支持分布式,需要自己去管理分布式集群,还要使用第三方中间件zookeeper管理集群状态。es也支持数据迁移,比如从solr迁移到es。为什么mysql索引结构不适合做搜素引擎在mysql中这样的一个节点大小是固定的,节点就是寻址最小的数据单元即data page,默认大小是16KB。假设这是一个磁盘,磁盘由很多节点数据单元组成,每个节点包含键值、指针和数据。磁盘和内存之间数据交互的时候,一次性最小取这么一个单位的数据。在图[1]中查找id=7的数据,需要做几次io操作?首先把跟节点16KB放入内存中(在创建索引树的时候就会把根节点放入内存,这里先忽略这点),经过一次io,cpu判断id=7的数据不在这个节点里,根据当前004这个节点去磁盘中寻址,找到006~008这个节点,然后从磁盘加载到内存,这是第二次io操作,cpu判断得知数据在007节点,然后再寻址找到007节点,加载数据到内存,然后读取里面的数据,这是第三次io操作,那么该过程内存和磁盘之间一共产生了3次交互,计算结果就是16KB*3=48KB。任何小的数据一旦用户量过大的时候都是不可以忽略的,所以要尽量减少磁盘io的次数。树的深度不断增大的时候就意味着磁盘io的次数不断的增大。B-Trees非叶子节点里面包含键值、指针和数据。单个节点的大小都是固定的,也意味着单条数据体积越大,单个节点所能装下的数据量越少,需要的节点数量越多,Max Degree是确定的即每一个节点的子节点数量是固定的,所以树的深度就越深,检索路径变长,检索效率越低。类比电梯,人越重,电梯里面装的人数量越少,那么需要的电梯数量越多。所以尽量避免单个节点的数据过大。节点中包含键值、指针和数据。指针即索引,单个索引长度越小越好。键值就是第一列的id数据,除了第一列其他的都是数据即data。如果节点中data不存在的话即节点只包含键值和指针,那么节点中存储的数据个数就会变多,所需要的节点数量就会越少,树的深度也会越小。一个节点空间是16KB,一条数据4KB,可以存储4条数据,1600万的数据需要400万个节点,树的深度越深;若一条数据1KB,那么一个节点可以存储16条数据,1600万的数据需要100万个节点,树的深度变浅。
三级缓存中包含A和B的2个lambda表达式,A和B对象还没有放入缓存中。从容器中查询a一级缓存和二级缓存都没有,三级缓存中虽然没有a对象,但是有ObjectFactory。执行 singletonFactory.getObject()实际上调用的是lambda表达式getEarlyBeanReference(beanName, mbd, bean)。如果有代理对象,则返回代理对象,如果没有代理对象,则返回原始对象。因为xml中并没有给a对象配置aop代理,所以这里返回的是a的原始对象。查看getEarlyBeanReference方法可以看到AOP代理的两种实现方式。为什么删除三级缓存a的objectFactory,因为查找顺序是先查一级缓存,再查二级缓存,最后查三级缓存,二级缓存已经有了a对象,所以三级缓存内容就没有留着的必要了,这也是三级缓存容量比较小的原因。获取a对象的目的是给b对象中的a属性赋值。那么就不需要createBean了,整个逻辑才算结束了。总结1、三个map结构中分别存储了什么对象?一级缓存:成品对象;二级缓存:半成品对象;三级缓存:lambda表达式(getEarlyReference)2、三个map缓存的查找对象的顺序?先找一级缓存,查不到,再查二级缓存,再找不到,找三级缓存。3、如果只有一个map,能否解决循环依赖问题?不能,如果只有一个map,那么成品对象和半成品对象只能放入一个map中,而半成品对象是不能暴露给外部使用的,所以必须要做区分,否则就有可能暴露半成品对象。有人可能说添加一个标识,0=半成品、1=成品,每次取的时候都要判断下这个标识别,很麻烦。4、如果只有两个map,能否解决循环依赖问题?能,但有前提条件。修改源码1把createBean方法中的this.addSingletonFactory(beanName, () -> { return this.getEarlyBeanReference(beanName, mbd, bean); });注释掉,然后添加,earlyearlySingletonObjects.put(beanName,bean)即不往三级缓存中放入ObjectFactory对应的lambda表达式,而是往二级haunch中放入bean对象。修改源码2把getSingleton方法中的这一部分,换成singletonObject=this.earlySingletonObjects.get(beanName); return singletonObject;即不从三级缓存中获取,而是直接从二级缓存获取。运行下发现并没有报错再来运行代码,就会报错:other beans do not use the final version of the bean即其他bean不能使用最终版本的bean。所以只要不包含aop就可以使用二级缓存解决循环依赖问题,但是出现aop之后,就必须要使用三级缓存了。5、为什么三级缓存就可以解决循环依赖中包含的代理对象问题呢?(1)创建代理对象的时候是否需要创建出原始对象?在没有生成代理对象之前就可以生成原始对象了这个方法是创建代理对象的。(2)同一个容器中能否出现两个不同的对象?不能,对象名都叫a,要么是原始对象要么是代理对象,不能同时出现。(3)如果一个对象被代理,那么原始对象和和代理对象应该这么去存储?如果需要代理对象,那么代理对象创建完之后应该覆盖原始对象。在getEarlyBeanReference方法中,会判断是否需要代理对象,如果创建出了代理对象,就需要覆盖原始对象。(4)在对象对外暴露的时候,容器怎么知道什么时候需要被暴露呢?或者说在对象对外暴露的时候,如何准确的给出原始对象或代理对象?因为正常的代理对象的创建是在BeanPostProcessor的后置方法中,在解决循环依赖问题的时候还没有执行到那个地方,所以此时就需要lambda表达式了,类似于一种回调机制,在确定要对外暴露的时候,就唯一性确定到底是代理对象还是原始对象,这也是什么不把对象直接放入二级缓存中,而通过三级缓存lambda表达式的方式来执行的原因。addSingletonFactory这里把lambda表达式放入三级缓存,该表达式的内容是判断是否需要代理对象,若需要则创建,这里并没有真正的去执行该表达式。initializeBean这个方法中的applyBeanPostProcessorsBeforeInitialization方法才是真正的通过后置方法beanPostProcessor创建代理对象,而在处理循环依赖的时候并没有执行到这一步呢。程序没有办法去确定你写的这个对象什么时候被其他对象调用,什么时候需要变成某一个对象的属性,所以把他换成一个lambda表达式,在确定需要对外暴露的时候才执行对象的确定(原始还是代理)。先创建a对象,再创建b对象,需要给b中的a属性赋值,从三级缓存中找到lambda表达式,然后判断是否需要代理对象。lambda表达式相当于回调机制,并不会立刻执行,当你需要给这个属性赋值的时候,你才会去执行。刚开始是原始对象,如果需要被代理,则返回被代理之后的对象。某一个对象的代理只能是一个,如果是多个代理的话,就需要看代理对象的创建顺序了。(5)spring提供了循环依赖的解决方案,那日常工作中是否也会有遇到循环依赖的问题?spring是一个跟业务无关的技术框架,它只能预防一些问题,而不是解决所有问题,就跟我们写代码的时候的异常处理一样,你能判断到一些问题,但不是所有的异常情况你都能全部解决掉的。
二级索引生成文件数据分区![]数据是以分区目录的形式进行组织的,每个分区的数据独立分开存储;横向切分是分片;纵向切分是分区。数据分区合并t0时刻,有三批数据写入。第一批数据是 2021-05-01,因分区键是年-月,则会得到分区目录202105_1_1_0202105表示年月的分区id,第一个1表示最小的blockNum,第二个1表示最大的blockNum,第一批数据,maxBlockNum=minBlockNum=blockNum=1,最后一个0表示合并的次数,此时还未发生合并所以是level=0第二批数据是2021-05-02,则会得到202105_2_2_0,因blockNum=2=minBlockNum=maxBlockNum,还未合并所以level是0第三批数据是2021-06-02,则会得到202105_3_3_0,因blockNum=3=minBlockNum=maxBlockNum,还未合并所以level是0t1时刻,202105的分区发生合并202105_1_1_0和202105_2_2_0合并得到202105_1_2_1分区id不变还是202105最小blockNum合并结果为1,因为一个是1一个是2,取最小值即为1最大blockNum合并结果为2,因为一个是1一个是2,取最大值即为2level结果是1,因为一个是0,另外一个也是0,取两者的最大值再加1即为1所以合并之后的分区目录为202105_1_2_1合并之后,老的分区目录则会处于非激活状态,不对外提供服务,默认8分钟之后,异步被清理。一级索引• primary.idx文件内的一级索引(主键索引)采用稀疏索引实现• 稀疏索引占用的索引存储空间较小。数据量大的场景可以利用primary.idx内的索引数据常驻内存,加快查询速度• 默认索引粒度大小为8192• 每隔一个索引粒度会取该粒度范围内的第一个主键值作为索引保存到primay.idx文件中二级索引(跳数索引)二级索引由数据块按粒度分割后,各部分数据聚合信息构成索引a表示:粒度范围内price*size即总价的最小值和最大值• minmax 存储指定表达式的极值• set(max_rows) 存储指定表达式的不重复值,max_rows表示重复值的个数限制,比如max_rows=10表示只有10个不同的值可重复;0表示无限制• ngrambf_v1 存储一个包含数据块中所有ngram的布隆过滤器,用于字符串的equals、like、in过滤。ngram是统计语言模型的算法,用于分词• tokenbf_v1 跟ngrambf_v1类似,但是它不是用ngrams进行分词,而是使用token,token是非字母数字的符号分割的序列比如分号;• bloom_filter 指定列存储的布隆过滤器数据压缩-- 压缩数据块• bin压缩文件是由多个压缩数据块组成的,而每个压缩数据块的头信息则会基于CompressionMethod_CompressedSize_uncompressedSize公式生成• 压缩方法包含:LZ4、ZSTD、Multiple、Delta多种算法数据压缩-- 压缩方式• 单个间隔数据不超过64KB,则累积到64KB生成下一个压缩块• 单个间隔数据大于64KB,不超过1MB,则直接生成下一个压缩块• 单个间隔数据大于1MB,则直接生成多个压缩快数据标记.mrk标记文件为一级索引和数据文件之间建立关联,主要保存两个信息• 一级索引对应的编号信息• bin压缩数据块的起始偏移量和解压缩块的起始偏移量每个索引粒度内取第一条重新写入。每个索引值都会有一个下标。第一个索引粒度内,使用第一条数据保存到索引池,下标编号为0,对应start0~start1区间。索引文件中保存了编号信息,通过编号信息找到压缩数据的起始偏移量和解压缩数据的起始偏移量MergeTree写入的过程每一批数据写入到数据目录里去,有三种不同的压缩方式,随着压缩文件的生成也伴随着一级索引和标记文件的构建,最终使得压缩文件、标记、索引一一对应。MergeTree的读取过程通过查询语句的filter过滤条件,根据分区索引找到唯一满足的分区目录,进入到分区目录,会根据一级索引来进行过滤,排除掉不符合的一些索引信息,保留索引2和索引3,然后根据二级索引排除掉索引2,那么就只剩索引3这个一级索引,数据标记能够为一级索引和数据文件进行关联,找到对应的压缩块,然后解压缩,然后根据标记中的起始偏移量找到对应的数据,这就是MergeTree的读取过程。如果没有过滤条件则会通过多线程的操作对这些分区目录并行的进行读取,加速查询过程。
ClickHouse核心模块--Column&Field• Column与Field是ClickHouse数据最基础的映射单元• 内存中的每一列数据由一个Column对象表示。Column对象分为接口和实现两部分,在IColunn接口对象中,定义了对数据进行各种关系运算的方法• 在大多数场合,ClickHouse都会以整列的方式操作数据。如果需要操作单个具体的数值,则需要使用Field对象,Field对象代表一个单值。与Column对象的泛化设计思路不同,Field对象使用了聚合的设计模式。• 在Field对象内部聚合了Null、UInt64、String和Array等13种数据类型及相应的处理逻辑。ClickHouse核心模块--DataType&Block• 数据的序列化核反序列化工作由DataType负责。IDataType接口定义了许多正反序列化的方式,它们成对出现• ClickHouse内部的数据操作是面向Block对象进行的,并且采用流的形式• Block对象可以看作数据表的子集。Block对象是由Column、DataType及列名称组成的三元组,仅通过Block对象能完成一系列的数据操作• Block流操作有两组顶层接口,IBlockInputStream负责数据的读取和关系运算;IBlockOutputStream负责将数据输出到下一环节。IBlockInputStream接口有众多实现类,这些实现类大致可以分为三类,第一类用于处理数据定义的DDL操作;第二类用于处理关系运算的各种操作;第三类是与表引擎呼应,每一种表引擎都拥有与之对应的。ClickHouse核心模块--Parser&Interpreter• Parser分析器可以将一条SQL语句以递归下降的方式解析成AST语法树的形式。不同的SQL语句,会经由不同的Parser实现类解析• Interpreter解释器负责解释AST,并进一步创建查询的执行Processor,起到串联整个查询过程的作用,它会根据解释器的类型,聚合它所需要的资源ClickHouse核心模块--Functions&Aggregate Functions• ClickHouse主要提供两类函数,普通函数核聚合函数• 普通函数由IFunction接口定义,拥有数十钟函数实现,普通函数是没有状态的,而是采用向量化的方式直接作用于一整列数据• 聚合函数由AggregateFunction接口定义,聚合函数是有状态的,以COUNT聚合函数为例,其AggregateFunctionCount的状态使用整型Unit64记录,聚合函数的状态支持序列化核反序列化。所以能够在分布式节点之间进行传输,以实现增量计算。MergeTree表引擎家族• PARTITION BY 选填,分区键• ORDER BY 必填,排序键• PRIMARY KEY 选填,表示主键,声明之后会依次按照主键字段生成一级索引,用于加速表查询。如果不指定,那么主键默认和排序键相同。MergeTree允许主键有重复数据• SAMPLE KEY 选填,抽样表达式。用于声明数据以何种标准进行采样,其用于配合SAMPLE子查询使用• INDEX 选填,二级索引,也叫做跳数索引• SETTINGS 选填,用于指定一些额外的参数,例index_granularity(索引粒度),min_compress_block_size(最小的压缩数据块大小)MergeTree存储结构• columns.txt列信息文件,使用文本文件存储,用于保存分区下的列字段信息• primay.idx索引文件,用于存储稀疏索引• [column].bin数据文件,使用压缩格式存储,默认使用LZ4压缩格式,用于存储某一列数据• [column].mrk列字段标记,保存了bin文件中的数据偏移量信息,MergeTree通过标记文件建立了primay.idx稀疏索引与bin数据文件的映射关系• [column].mrk2如果用了自适应大小的索引间隔,则标记文件会以.mrk2命名• partition.dat用于保存当前分区表达式最终生成值• minmax_[column].idx用于记录当前分区字段对应原始数据的最小值和最大值• skip_idx_[column]
简介1、由Yandex开源的高性能OLAP数据库2、采用列式存储结构,拥有高效的数据压缩能力3、通过多核并行处理以及向量执行引擎提升查询能力4、多样化的表引擎用于支撑不同的应用场景5、支持多线程和分布式处理ClickHouse性能100million条数据量下,ClickHouse的单表聚合查询性能非常高,是Greenplum(x2)集群的16倍,是PostgreSQL的10倍,是Mysql的833倍。适用场景• 商业智能• 电信行业数据存储统计• 新浪微博用户行为记录分析• 电子商务用户分析• 金融等领域不适用的场景• 不支持事务• 不擅长根据主键按行粒度进行查询(虽然支持)• 不擅长按行删除数据(虽然支持)• 不擅长按行更新数据(虽然支持)ClickHouse采用列式存储,其格式相对于行存储格式来说,对行粒度查询进行处理稍显劣势。核心特性完备的DBMS功能• DDL:可以动态的创建、修改或删除数据库、表和视图• DML:可以动态查询、插入、修改或删除数据• 权限控制:可以按照用户粒度设置数据库或表的操作权限,保证数据安全• 数据备份与恢复,提供了数据的导入和导出恢复机制• 分布式管理:提供集群模式,能够自动管理多个数据库节点列式存储和数据压缩• 列式存储有效减少查询时扫描数据量• 列式存储比行存储的另一个优势是数据压缩的友好性• 同一个字段拥有相同的数据类型和实现语义,数据重复项的可能性更高• 默认采用LZ4压缩算法(速度较快,但压缩率较低),以及ZSTD压缩算法(速度较慢,压缩率较高)• ClickHouse的LZ4算法在Yandex的生产环境数据压缩比能达到8:1• TPCH数据(无索引),ckdb压缩率达到52%,rocksdb达到61%行式存储是按行读取,比如一个表中有很多列,读取其中三列,就需要对所有列进行一个扫描;列式存储只需要对所需要的三列进行读取,就有效的减少了查询时候的扫描数据量。列式存储是一个字段作为一组数据进行一个存储。向量化执行引擎• 利用寄存器硬件层面的特性,为上层的程序性能带来了指数级的提升• SIMD指令,单条指令操作多条数据,通过数据并行以提高数据的并行操作。它的原理是在CPU寄存器层面实现数据的并行操作。通过数据的并行来提高数据的查询能力和处理能力。多样化表引擎• 为了避免平庸,ClickHouse拥有和Mysql类似的表引擎设计,它拥有4大类30多种表引擎• 每一种表引擎都拥有各自的特点,用户可以根据实际业务场景的要求,选择合适的表引擎使用• ReplacingMergeTree主要用于相同主键进行合并处理比如主键重复的数据有多条,可以在内部合并,最终保留一条多线程和分布式• 多线程如果说向量化执行是通过数据级并行的方式提升性能,那么多线程就是通过线程级并行的方式实现性能的提升,默认CPU核数的一半• 分布式为了利用分布式设计,ClickHouse在数据存取,即支持分区(纵向扩展、利用多线程原理),也支持分片(横向扩展,利用分布式原理,由replica组成)另外ClickHouse提供了本地表和分布式表的概念,本地表相当于数据分片,分布式表是本地表的访问代理,其本身不存储任何数据ClickHouse架构设计多主架构区别于Master-Slave主从架构,ClickHouse采用了Multi-Master多主架构,集群中的每个节点角色对等,客户端访问任意一个节点都能得到相同的效果多主架构具有很多优势,不用区分多主控节点,数据节点和计算节点,集群中的所有节点功能相同,客户端访问每一个节点都能得到相同的效果MergeTree原理解析
一键安装Java#!/bin/bash mkdir -p /opt/jdk/java cd /opt/jdk/java wget http://virde-res.oss-cn-beijing.aliyuncs.com/software/java/jdk-8u181-linux-x64.tar.gz tar zxvf jdk-8u181-linux-x64.tar.gz echo '#Java Env' >> /etc/profile echo 'export JAVA_HOME=/opt/jdk/java/jdk1.8.0_181' >> /etc/profile echo 'export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar' >> /etc/profile echo 'export PATH=$PATH:$JAVA_HOME/bin' >> /etc/profile安装redisdocker run --name mysql -e MYSQL_ROOT_PASSWORD=testDB! -v "/data/nfs-client/mysql/data":/var/lib/mysql -v "/data/nfs-client/mysql/conf.d":/etc/mysql/conf.d -v "/data/nfs-client/mysql/mysql.conf.d":/etc/mysql/mysql.conf.d --restart=always -p 3306:3306 -d mysql:5.7安装mysqldocker run --name mysql -e MYSQL_ROOT_PASSWORD=testDB! -v "/data/nfs-client/mysql/data":/var/lib/mysql -v "/data/nfs-client/mysql/conf.d":/etc/mysql/conf.d -v "/data/nfs-client/mysql/mysql.conf.d":/etc/mysql/mysql.conf.d --restart=always -p 3306:3306 -d mysql:5.7安装 gitlab-runner下载二进制文件sudo curl -L --output /usr/local/bin/gitlab-runner "https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64"安装并启动chmod 777 gitlab-runner-linux-amd64 ./gitlab-runner-linux-amd64 install -u root ./gitlab-runner-linux-amd64 start注册gitlab./gitlab-runner-linux-amd64 register注册好之后,就会多一个runner安装gityum install git自动化部署流程1、开发者提交代码 到指定分支2、根据gitlab.ci文件中的配置,自动触发,提交一个部署任务给gitlab-runner3、gitlab-runner根据gitlab.ci文件的内容,进行执行4个阶段的任务:代码下载并打包,生成镜像、上传镜像库、部署服务使用kuboard访问k8s集群查看集群信息cat /etc/kubernetes/admin.conf配置kuboard访问k8s将上面k8s集群信息添加进入1、在集群下首先创建一个命名空间2、因为服务是从nacos配置中心获取配置,然后注册到nacos服务中心的,所以需要先在configmap配置nacos信息服务对应的yaml文件通过这种方式来读取k8s集群中指定的configmap3、部署服务的第一步就是要下载docker镜像,需要访问docker register,那么则需要配置下docker register密钥信息,如果没有设置register密码就不需要配置。代码文件https://gitee.com/pingfanrenbiji/example
安装K8S环境配置hosts文件vim /etc/hosts #本机ip 本机hostname 172.19.19.5 k8s-master关闭防火墙systemctl stop firewalld systemctl disable firewalld禁用selinuxvi /etc/selinux/config SELINUX=disabled #查看selinux状态 /usr/sbin/sestatus -v安装docker安装必要的一些系统工具sudo yum install -y yum-utils device-mapper-persistent-data lvm2添加软件源信息sudo yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo更新并安装Docker-CEyum makecache --refresh sudo yum -y install docker-ce 发现报错:Problem: package docker-ce-3:19.03.12-3.el7.x86_64 requires containerd.io >= 1.2.2-3, but none of the providers can be installed 因为containerd 版本过低 dnf install container-selinux 通过yum -y install https://download.docker.com/linux/centos/7/x86_64/edge/Packages/containerd.io-1.2.6-3.3.el7.x86_64.rpm 升级即可docker 修改Cgroup Driver以及docker镜像拉取地址cat /etc/docker/daemon.json { "registry-mirrors": ["https://e7l8pkuq.mirror.aliyuncs.com"], "exec-opts": ["native.cgroupdriver=systemd"] }开启Docker服务sudo service docker start安装kubectl下载最新kubectl最新资源curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl"添加执行权限chmod +x ./kubectl移动kubectl至bin路径sudo mv ./kubectl /usr/local/bin/kubectl确认安装的版本kubectl version --client安装minikube下载minikube资源curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 \ && chmod +x minikube添加minikube进入系统命令sudo mkdir -p /usr/local/bin/ sudo install minikube /usr/local/bin/启动 minikube(本文使用的VM安装的centos 8因此设置driver=none)minikube start --driver=none --image-repository=registry.cn-hangzhou.aliyuncs.com/google_containers确认minikube状态minikube status查看集群状态kubectl cluster-info安装 镜像仓库vim restartRegistry.shdocker stop registry docker rm registry docker run -d -p 5000:5000 --name=registry --restart=always \ --privileged=true \ --log-driver=none \ -v /root/registry/registrydata:/var/lib/registry \ registry:2配置非安全访问的仓库IP:端口号vim daemon.json #添加 {"insecure-registries":["172.19.19.5:5000"]}重启docker服务service docker restart一键安装Maven#!/bin/bash wget -P /usr/local/ https://repo.huaweicloud.com/apache/maven/maven-3/3.8.1/binaries/apache-maven-3.8.1-bin.tar.gz tar -xvf /usr/local/apache-maven-3.8.1-bin.tar.gz -C /usr/local echo "export MAVEN_HOME=/usr/local/apache-maven-3.8.1" >> /etc/profile echo "export PATH=\$PATH:\$MAVEN_HOME/bin" >> /etc/profile source /etc/profile mvn -version
建议专门为Serverless应用开发一个子账号这个账号只有函数计算的FullAccess并且只能编程访问。创建之后就会得到AccessKey和Secret,给账号授权:发布、更新函数、管理计费服务的权限。更安全点让这个AK只管理某个服务或服务下的某个函数。这个时候就可以使用自定义策略。自定义策略允许该服务下的所有函数的所有操作。有些开发框架允许你通过YAML去配置其他云服务,并且在你部署时会自动帮你创建或更新这些服务,这个时候就需要Serverless开发账号也需要具体云服务的权限。如何使用日志服务存储函数的日志本质上如何让函数访问日志服务,也就是前面所提到的云服务授权。首先需要创建一个角色。ARN是角色的唯一标识,角色扮演的时候就会使用到ARN。在A账号的函数中访问B账号的OSS文件,可以通过角色扮演来实现第三个场景。• 账号A 扮演角色RoleForServerlessApp• 账号访问凭证 token1• 将token1 注入到函数上下文• 扮演角色RoleForAccountA• 访问凭证token2• 使用token2访问账号B的对象存储从上下文context当中获取临时访问凭证token1小结云厂商主要通过主账号、角色、权限策略等方式来实现云上资源的访问控制,通过访问控制,能实现分权、云服务授权、跨账号授权等云上资源管控需求。实际工作中,对于用户访问权限要遵循最小授权原则。Servreless安全问题Serverless安全面临的挑战软件开发者专注产品功能的实现;完全不用考了底层服务器操作系统以及软件运行环境;无须为服务和操作系统安全安全补丁。• 应用所有者负责云内部的安全性包括客户端、存储在云中的数据、传输中的数据、应用(函数)、身份认证、访问控制、云服务配置• Serverless提供商负责云本身的安全性操作系统、虚拟机、容器、计算、存储、数据库、网络、地域、可用区、边缘节点• 攻击面越来越广由于Serverless中函数的数据来自多种数据源,这就极大增加了攻击面,特别是当数据源消息结构非常复杂时,传统的Web防火墙方式就很难对数据进行校验。• 攻击方式越来越复杂除了传统的DDos攻击、数据注入等攻击方式,Serverless还面临着事件注入,流程劫持等新的攻击方式。• 可观测性不足日志、监控指标、链路追踪等方式。因为Serverless对开发者来说是屏蔽了底层基础设施且应用是由分布式的函数组成,所以Serverless应用的可观测性比传统应用更复杂• 传统安全测试方法不适用当Serverless应用依赖了第三方服务或云服务,虽然在单元测试时这些依赖可以被模拟,但进行安全测试却不能模拟。传统的DAST(动态应用程序安全性测试)工具对Serverless应用不适用,主要是扫描HTTP接口进行安全测试,但很多Serverless数据源都不是Http触发器,所以DAST攻击很难对非http Serverless应用进行扫描。SAST(静态应用程序的安全性测试)这类工具主要是通过分析代码语法、结构、接口、控制流等检测程序等漏洞。Serverless使用了很多触发器和云服务,很多静态分析工具并没有考虑这种情况,所以就很容易出现误报。IAST(交互式应用程序安全性测试)这类工具通过将流量代理到测试服务器等方式进而可以得到更高的准确率、更低的误报率。这种方式对非http接口或依赖了云服务的Serverless应用依旧不适用,很难进行流量代理。• 传统安全防护方案不兼容传统的安全防护方案基于Serverless的应用没有办法访问物理机或虚拟机,所以没有办法随机部署传统的安全层,比如端点防护、Web防火墙大多数传统安全防护方案与Serverless架构不兼容。Serverless安全的主要风险• 函数事件注入数据注入是最常见的安全风险,比如SQL注入、XSS注入。不过在传统应用中,这些数据注入都是用户输入的数据,而Serverless应用的数据不局限于用户输入。比如API的请求参数,还有大量的触发器,提供了丰富的数据源。这些数据源都可以触发Serverless函数的执行。这些不同数据源的数据会以参数的形式传给Serverless函数,这些函数的输入、不同数据源的通信协议、编码方式、也都不尽相同。丰富的数据源不仅增加了潜在的攻击面,也增加了安全防护的复杂性。因为这些数据可能包含了攻击者的输入或者其他危险的输入,开发者很难判断哪些是正常数据哪些是危险数据。比如在传统应用当中可以通过消息头请求参数来判断数据是否危险,但是在Serverless架构中的非http数据源就很难通过简单的请求消息判断了。• 身份认证无效Serverless架构的应用是由几十甚至上百个函数组成,每个函数实现特定的业务功能,这些函数组合完成整体业务逻辑,一些函数可能会公开其Web API,需要进行身份认证,另一些则可能只允许内部访问,所以不用进行身份认证。为每个函数或事件源提供合理的身份认证机制• 应用配置不安全通常这些配置放在云厂商提供的配置中心,甚至直接存放在云存储中。在云上运行的应用,尤其是Serverless应用,经常会使用到很多应用配置,另外你还会基于配置来实现环境区分,功能开关逻辑。对于配置中心或存放应用配置的云存储授权不当,这很可能造成应用敏感信息泄漏,或没有权限的用户不小心修改配置导致应用无法运行。• 用户或角色权限过高Serverless应用应该秉持最小权限的原则,也就是仅给函数提供其执行时所必需的权限。很多开发者或团队为了方便,就为函数设置了统一的较大的权限,如果一个应用中函数权限过高,那单个函数的漏洞可能造成系统级灾难。• 函数日志和监控能力不足虽然云厂商都对函数提供了日志和监控功能,但这些功能都很新,提供的能力也有限。要想利用这些功能来可视化Serverless架构的运行情况还非常困难。函数日志和监控能力的不足,就会导致面临攻击时你就很难针对Serverless攻击进行报警,也很难通过日志去分析、排查解决问题。• 第三方依赖不安全Sererless函数的执行环境是安全隔离的环境。很多时候为了完成业务逻辑,函数就要依赖第三方软件包,开源库甚至通过API调用第三方远程Web服务。如果函数的第三方依赖不安全,也很可能导致函数不安全。• 敏感信息泄漏随着应用规模和复杂性的增长,应用需要维护未来越多的敏感信息,比如访问凭证(AccesskeyId、AccessKeySecret和SeccrityToken)、数据库密码、加密密钥等。常见的错误做法是:把这些敏感信息简单的放在项目配置文件中,随代码一起上传。• DDoS和资损DDos几乎成了每个暴露在互联网上的服务面临的主要风险之一,比如大量恶意运行函数造成资损。使函数一直挂起直到超时。代码示例由于boundary属性完全在请求方的控制下,恶意的用户就可以利用boundary构造请求数据给函数发起DDo是攻击。当函数传入这个字符串的时候,构造正则表达式,解析请求体,由于效率很低,就会一直卡在这里,进而造成cpu使用率持续100%,如果这段代码放在Lambda执行,函数最终会超时而被Lambda强制结束。攻击者就会对这个函数发起大量恶意的请求,进而生成大量的函数实例,直到超过函数最大并发和实例数限制,并且每个函数实例都会持续的运行进而超时,进而导致其他用户无法正常访问。由于函数是由实际使用时长计费的这也会增加你的费用。• 函数执行流程操纵通过操作函数执行流程,攻击者可以破坏应用逻辑并且还可以利用该方式绕过访问控制、提升用户权限、甚至发起DDos攻击。在传统应用中很常见,但在Serverless应用中风险更大。Serverless应用是由很多离散的函数组成,这些函数按特定顺序编排到一起,形成整体应用。如果函数依赖其他云服务存储状态,那么状态存储和共享的过程也有可能称为攻击的目标。假设有个应用需要对上传到云存储的文件进行加密。函数1是校验文件完整性、检查文件大小是否为64KB,消息内容为待发送的文件名;函数2由消息触发器触发执行;函数2接收到消息之后,从消息内容解析文件名称,然后对文件进行加密。函数2中恶意的方式:1、云存储没有合适的访问控制,绕过文件完整性校验而直接上传文件,导致用户恶意上传大量文件。2、如果消息队列没有设置合适的访问控制,任何用户都可以发送大量文件上传消息,使得函数2恶意执行。• 错误处理不当1、由于Serverless的调试方式还比较有限,所以很多开发者喜欢直接在FaaS平台上打印函数运行的日志,以及冗长的错误信息;如果一些敏感信息没有被清除,可能导致敏感信息泄漏在日志中或日志中记录了详细的错误堆栈,暴露代码的漏洞。2、代码处理不当,导致程序被挂起,一直无法运行结束。不管是传统应用还是Serverless架构的应用,都存在安全风险,因此你要先深入了解应用架构中的风险点,这样才能对症下药,解决问题。
对几种编程语言进行冷启动性能测试,分别使用这几种语言来实现helloword函数,然后给函数设置定时触发器,每半个小时执行一次,这样函数执行时基本就是冷启动了。使用链路追踪来采集冷启动耗时,最后统计每个语言的耗时以及不同内存下的冷启动时间。• 函数计算中PHP冷启动最快,Node.js、Python次之,Java最慢• Java冷启动耗时大约是Node.js或PHP的三倍,Node.js、Python、PHP的冷启动耗时基本在1s内• 随着内存增加,冷启动耗时逐渐缩短除此之外也针对Lambda进行测试,Node.js冷启动耗时最短,大概200毫秒,性能是函数计算的3~4倍。国产的Faas产品还有很高的提升空间。小结• 尽量选择Node.js、Python、PHP等冷启动耗时短的语言编程• 为函数设置合适的内存、内存越大,冷启动耗时越短,但成本也越高,所以要设置一个合适的内存• Serverless性能优化的一些实践方案:提前给函数预热、减少代码体积减少不必要的依赖、为函数设置并发、为函数设置合适的内存、使用预留资源、执行上下文重用,选择冷启动耗时少的编程语言。• 除了自己的优化之外,Serverless性能还有一部分需要提供服务的云厂商去优化,比如各种编程语言的运行时,比如Java冷启动耗时很长,这个优化也只能优化云厂商了,开发者很难去优化,只能尽量避免。访问控制:如何授权访问其他云服务权限问题比如没有权限访问发布函数、没有权限访问其他云服务直接使用具有AdministratorAccess权限的访问凭证去部署应用设置管理云资源,这是非常不安全的。当企业规则逐渐变大,企业中有不同角色的成员。为了云上资源的安全性,你就需要为不同角色配置不同权限,限制不同成员能够访问的云资源。不同云厂商的访问控制产品• AWS IAMAWA Identity and Access Management• 阿里云 RAMResource Access Management不同云厂商的实现细节可能有所差异,但工作原理基本一致。设计访问控制系统如果你是一个云产品的架构师,那你要怎么设计一个访问控制系统,实现这样几个很常见的需求呢?(实现分权、云服务授权、跨账号授权)分权如何使不同成员拥有不同的权限?比如运维同学才能购买云产品;Serverless开发同学只能使用Serverless产品而不能购买;财务同学只能使用费用中心查看账单等。云服务授权如何使云服务能够访问某个云资源?比如只允许函数计算读对象存储中的文件而不能删除或创建。跨账号授权如何使其他账号能够访问你的云资源?比如某个大型企业有两个云账号,其中一个云账号A是用来开发生产的,另一个B用于审计,存储所有日志,那么A如何使用B里面的日志?通过子账号、角色和权限策略来实现云上的访问控制当创建一个云账号的时候,你的账号就是主账号,主账号有所有的权限,而你可以使用主账号来创建子账号和角色。子账号一开始创建的时候是没有任何权限的,你可以通过给子账号添加权限策略来为子账号授权。权限策略权限策略就是一组访问权限的结合。系统策略• AdministratorAccess管理所有云资源的权限• AliyunOSSFullAccess管理对象存储OSS的权限、OSS存储桶以及文件的增删改查• AliyunOSSReadOnlyAccess只读访问对象存储OSS的权限,只能读取OSS存储桶以及文件,不能创建和修改在子账号和权限的基础之上,就可以给不同的成员创建子账号,给他们不同的权限,这样就达到了分权的目的。有两种使用子账号的方式• 控制台访问通过子账号登录控制台管理云资源• 编程访问在代码中使用子账号的AK来调用云产品的API进而管理云资源当我们资源数量越来越多时,通常会通过编程的方式来使用和管理云资源。一个应用包含大量函数的时候,通常会使用开发框架创建、更新、发布函数。开发框架的本质就是通过编程的方式来管理函数。当使用Fun或Serverless framework等工具去部署函数的时候一定要记着使用子账号的AK,并且要为子账号使用最小化的权限。除了子账号访问控制还有一个重要的功能就是角色。角色角色是一个虚拟用户,必须被某个具体用户(子账号、云服务等)扮演使用,可以通过添加权限策略为角色授权。同时创建角时,需要指定角色能够被谁扮演即角色的可信实体。角色可信实体包括云账号、云服务以及其他身份提供商。要想使用账号A访问账号B的OSS角色,先为账号B创建一个角色RoleReadOSSAccess,将角色的可信实体设置为账号A,这样账号A就可以使用自己的AK扮演账号B的RoleReadOSSAccess角色,进而读取账号B的OSS。基于角色扮演的方式,你就可以实现云服务授权和跨账号授权了。怎么通过权限策略给用户或角色授权?从形式上来讲权限策略就是有特定语法的Json字符串,可以通过配置Json字符串来实现授权。权限策略分为两种:系统策略、自定义策略。系统策略是云厂商内置的,预先定义的Json配置,通常包括AdministratorAccess以及各个云服务的完全访问和只读权限,但是有时候系统权限可能没有办法满足你的需求或者想在一个访问策略里面包含访问多个云服务的权限,那就可以使用自定义权限策略。不同云厂商权限策略语法几乎一样。通过权限策略给用户或角色授权
Serverless自动弹性伸缩的特性让应用具备了无限扩展的能力,但基于FaaS的实现也带来了很大的副作用,即冷启动,冷启动是代码在处理业务功能之前的额外开销。既然冷启动会影响Serverless的性能,能否应用低延迟高并发场景呢?Serverless的性能优化核心就是减少冷启动深入理解Serverless冷启动是优化Serverless应用性能的前提。函数启动过程示意图冷启动经过多个步骤,耗时较长。使用链路追踪工具比如AWS的X-Ray,阿里云的链路追踪,Lambda,函数计算可以查看冷启动耗时执行时间从而判断和分析Serverless应用的性能。函数计算链路追踪截图• InvokeFunction 表示函数执行总时间• ClodStart 表示函数冷启动时间• PrepareCode 表示函数冷启动过程中下载代码或下载自定义镜像的时间• RuntimeInitialize是执行环境启动的时间,包括启动容器和函数运行环境• Invocation 表示执行函数时间上图说明这是执行冷启动的过程,因为热启动没有ClodStart、PrepareCode、RuntimeInitialize。整个函数执行耗时553ms,冷启动486ms,由此可见,冷启动对函数性能影响很大。什么时候函数是冷启动或者热启动呢函数第一次执行的时候一定是冷启动,但后面的请求不一定都是热启动,这与触发函数执行的事件是串行还是并行有关。串行访问以http触发器为例,如果所有用户的请求都是串行的,则只有第一次的请求是冷启动。通过charles等工具来模拟用户连续请求该接口的情况。把并发设置为1发起100个请求,函数就会被顺序调用100次,这100个请求就是串行的。第一个请求耗时700多毫秒,该请求的响应体中返回了requestId,根据requestId在链路上查询到的第一个请求确实是冷启动。后面的请求只耗时了40毫秒左右,都是热启动。对于某些性能要求不高的厂家来说,这100个请求中,只有一个请求是冷启动,影响的用户是1%也是可以忍受的。并发访问前10个请求都是冷启动。网站一天之内流量有波峰波谷的,流量突增(比如团购订餐业务,可能每天中午、晚上流量突增;促销活动,在活动开始前流量突增;社交软件,遇到重大新闻时流量突增)就意味着FaaS平台不得不添加更多的实例来支持更大的并发并且新增实例时都会有冷启动,这就对用户体验有较大影响了。如何优化Serverless的性能• 避免函数冷启动• 减小代码体积• 提升函数吞吐量• 选择合适的编程语言避免冷启动对函数进行预热预热就是指你通过定时任务,在真实请求到来之前对函数发起请求,使函数提前初始化。真实请求就是使用已经初始化过的函数实例去执行代码。比如11点58通过定时任务请求需要预热的API,12点的时候真实的请求使用热启动的函数。使用特定的请求头来标记预热请求,这样就可以把它和正常的用户请求分开,还可以不对热请求做任何处理。函数预热函数预热是彻底消除冷启动时间,代价就是需要维护预热的逻辑并且提前预热的函数并没有处理用户的请求而持续的占用资源。使用预热的场景是否使用预热方案,既要考虑业务场景,也要平衡性能和成本,如果你的应用对延迟要求很高,比较秒杀业务,就可以使用预热功能。使用预留资源此外有些Faas平台(比如函数计算)也提供了预留资源的功能,可以为你的函数实例持续保留。需要手动去创建释放函数运行的资源。使用预留资源,就不用发预热请求了,但使用成本会高些。减少代码体积函数冷启动的第一个步骤就是下载代码。减少代码体积,可以避免引入不必要的依赖,不要加载不需要的代码。对SDK进行精简,对代码进行压缩,甚至只构建需要执行的代码。对Node.js来说尤为重要,因为Node.js的依赖目录node_moudles通常体积非常大,非常冗余。可以使用ncc构建代码依赖,减少体积提升性能。大多Faas都支持单实例多并发为了提升函数的吞吐量,一个实例可以同时处理多个请求,能够减少函数实例的生成进而减少冷启动。假设3个并发请求同时需要处理,当函数并发为1的时候,FaaS平台就会生成3个函数实例来处理这3个请求,每个函数实例处理一个请求需要经过3次冷启动。当函数并发为10,则只会生成一个函数实例,来处理3个并发。单实例单并发单实例单并发的请求下函数实例只能处理一个请求,处理完毕,才能处理下一个请求。单实例多并发单实例多并发可以同时处理多个请求。从实例的生命周期也可以看的出来,单实例多并发,实例执行时间更短,这样成本自然也就更低。单实例处理多个请求代码示例第一个请求到来,函数冷启动,会先下载代码,然后初始化一个Node.js的运行环境,接下来就会执行初始化代码,主要就是引入mysql依赖包,初始化数据库连接,最后执行handler方法。第二个请求到来函数就是热启动了,这个时候函数就会重复使用上一个运行环境,会重复使用上次的执行上下文,包括mysql依赖,数据库连接。热启动的时候只会执行handler函数,基于热启动的特点可以把初始化逻辑都放在handler函数之外,只需要第一次冷启动来执行初始化就能提升函数性能。而单函数多并发就进一步的放大了这个特点,基于单函数多并发Serverless应用就可以更好的支持低延迟高并发场景。除了为函数设置并发减少冷启动次数之外,不同编程语言的冷启动时间也不尽相同,Node.js和Python比Java等静态语言启动时间少很多。Java冷启动慢主要是因为需要启动庞大的虚拟机并且将所有的类加载到内存中初始化。选择冷启动时间短的编程语言,可以大幅提升应用性能
http服务中定义了一个接口 访问/返回golang版本。当基于容器实现自定义运行时函数计算会将容器的http请求转发到/路由。本地测试-启动Http服务alpine是最小体积的golang运行环境。构建并上传镜像• runtime的值等于custom-container表示该函数是自定义容器• 通过customContainerConfig来自定义容器镜像部署+测试小结• FaaS平台提供了有限的编程语言及版本的支持,使用自定义运行时,可以自定义编程语言进行开发• 自定义运行时原理是在函数中实现一个Http服务,FaaS平台将触发器事件转发到你的Http服务• 可以通过将运行时上传到FaaS,在bootstrap中定义启动命令来实现自定义运行时• 可以通过自定义容器镜像来实现任意编程语言的自定义运行时自定义运行时是Serverless应用中非常重要的一个功能,可以突破Faas平台运行环境的限制,可以使用FaaS平台所不支持的编程语言进行开发。基于容器实现自定义运行时你可以很方便的安装依赖,因为依赖都打包到了镜像中。还可以平滑的将原有系统或传统应用平滑迁移到Serverless架构。单元测试单元测试是保证代码质量和应用稳定性的重要手段。使用Serverless的难点• Serverless架构是分布式的,组成Serverless应用的函数是单独运行的,这些函数集合到一起组成分布式架构,你需要对独立函数和分布式应用都进行测试。• Serverless架构依赖很多云服务,比如各种FaaS、BaaS等,这些云服务很难在本地模拟• Serverless架构是事件驱动的,事件驱动这种异步工作模式也很难在本地模拟。Serverless单元测试准则越上层测试速度越慢,成本越高,所以应该写更多的单元测试。Serverless应用依赖很多云服务,函数参数也与触发器强相关。• 准则一:将业务逻辑和依赖的云服务分开,保持业务代码独立,使其更易于扩展和测试• 准则二:对业务逻辑编写充分的单元测试保证业务代码的正确性• 准则三:对业务代码和云服务编写集成测试,保证应用的正确性示例代码保存用户信息,保存成功后并发送欢迎邮件,这段代码的业务逻辑没有和FaaS服务分开,单元测试依赖数据库和邮件服务,这些服务都需要发送网络请求。代码重构把存储数据和发送邮件的业务逻辑单独拆分到user类中,并且为user类提供构造函数,注入db和mailer依赖。
使用自定义运行时支持自定义编程语言Serverless应用的函数代码都是在FaaS当中运行的,但是在目前为止也只能选择FaaS平台所选择的编程语言开发应用。FaaS平台所支持的编程语言有限,函数计算只支持Node.js 8和12、java和python,那么在使用不支持的语言或不支持小版本的时候就需要使用自定义运行时。自定义运行时的原理运行时(Runtime)是程序运行时所依赖的环境。FaaS中的运行时就会创建函数所指定的运行环境,比如说函数计算的Node.js运行时,那就包括了Node.js运行环境以及一些内置的模块比如ali-oss、tablestore,除此之外还有Java运行时、Python运行时。自定义运行时就是自己在FaaS自定义一个运行时环境,比如TypeScript,使用TypeScript来编写代码并且部署到FaaS平台上运行。先来回顾下FaaS的运行原理在FaaS当中运行时被预先定义,比如创建函数的时候可以指定runtime:nodejs12,接下来用户通过创建触发器驱动函数执行之后,Faas就会以nodejs12作为运行时来创建函数实例,函数代码也就在nodejs12这个运行时环境中执行。怎么才能让函数在自己定义的运行时环境中执行?安装依赖的本质就是要把函数运行所需要的依赖都打包上传至FaaS中,那能不能把函数的运行时也打包上传到Faas当中呢,也让Faas运用你上传的运行时来执行你的代码呢?FaaS平台的自定义运行用TypeScript编写代码把代码和TypeScript的运行时都上传至FaaS中,然后通过特定的配置让FaaS通过自定义的TypeScript运行时来运行你的代码runtime:custom告诉FaaS你使用的是自定义运行时bootstrap ts-node index.js告诉FaaS函数启动时使用ts-node运行index.jsFaaS平台在运行函数时会有很多参数,这些参数怎么传递给自定义运行时呢?本质上是远程数据通信问题。最简单点的在自定义运行时中实现一个Http服务,FaaS平台通过http请求把数据传递给自定义运行时。自定义运行时就是使用自定义编程语言实现的Http服务,需要为你的Http服务指定一个启动命令。boostrap文件示例通用的做法把启动命令保存在名字叫做boostrap文件中,FaaS平台在创建函数实例的时候会执行boostrap文件,启动http服务。把所有请求和参数都转发到Http服务中,让Http服务处理所有的请求。自定义运行时的实现源码示例https://gitee.com/pingfanrenbiji/serverless-class/tree/master/07实现一个TypeScript运行时,TypeScript为jS代码增加了类型系统,可以大大提升代码的可读性和可维护性。大多数FaaS平台都不支持TypeScript,如果你想用一个TypeScript去编写一个Serverless应用,通常把代码编译成JavaScript再运行,很明显没有直接执行TypeScript代码高效。如果想要直接运行TypeScript的代码,可以通过ts-node来实现,所以可以基于ts-node来执行一个TypeScript的运行时,这样就可以直接使用TypeScript来编写Serverless应用了。首先在本地创建一个TypeScript项目,然后安装必要的依赖,为了把依赖都上传到FaaS,需要将ts-node等相关的依赖都安装到项目的node目录中。自定义运行时需要实现一个http服务来接收FaaS平台的请求。所以接下来使用TypeScript来编写一个Http服务这段代码启动了一个http服务,监控9000端口。然后在本地测试,通过在安装在项目中的ts-node命令来运行这段代码。你还要创建一个boostrap文件在文件当中添加启动命令,这样让FaaS知道如何启动自定义运行时。添加函数计算的template.yaml配置• runntime的值必须为custom• handler属性这里没有实际的意义,但是必须要填写将自定义运行时部署到函数计算其他无法直接安装在安装目录的编程语言,比如golang和最新版本的Node.js自定义运行时又该怎么实现呢?如果要沿用TypeScript这种自定义启动命令的方式就需要把golang和代码打包,但是golang是直接安装在操作系统上的,依赖系统环境好像无从下手?实现一个golang运行时将运行环境和代码打包,这种思想是不是和容器技术很像?容器技术就是将应用和运行所依赖环境打包为镜像,这样应用就可以轻松迁移和部署。那能不能把golang的运行环境构建成docker镜像,然后让FaaS平台能支持自定义容器就能实现任意编程语言的运行时了。FaaS平台也提供了自定义容器的能力先构建一个容器镜像,然后通过函数的配置告诉FaaS平台使用你的容器镜像,在函数执行的时候FaaS平台会拉取容器镜像并启动容器执行代码。与前面的TypeScript运行时一样在自定义容器镜像中也需要实现一个Http服务,用来接收FaaS平台所有的请求。需要准备一个镜像仓库用来存放你的镜像。函数计算目前只支持容器镜像服务中的镜像,所以你需要构建自定义运行时镜像然后上传到容器镜像服务中,你可以提前在容器服务中创建一个命名空间或镜像仓库,创建完毕之后记住仓库地址。
• Resources 具体的资源对象• Resources.*.Type 资源类型远程调试通过fun invoke functionName命令对函数进行远程调试也可以添加--local进行本地调试,在本地调试之前先安装docker,通过docker在本地启动一个代码运行环境来执行代码,而不是直接模拟函数参数,这样的好处是更接近FaaS平台的运行环境。应用部署通过fun deploy进行应用部署。开发框架的意义在于帮助开发者提升Serverless应用的开发效率。小结• 与FaaS、BaaS等基础技术一样,Serverless开发框架也是Serverless领域中非常重要的一部分• 一个优秀的Serverless开发框架,可以让开发者很容易开发一个Serverless架构的应用,也能让企业轻易将现有业务演化到Serverless架构• Serverless开发框架需要具备的基本能力是应用管理、应用调试和应用部署。Serverless应用怎么安装依赖?Serverless应用代码都是独立的函数,不涉及其他依赖,而在实际进行应用开发时,大部分情况下都会有第三方依赖。上述代码引入了mysql的依赖。为什么安装依赖很困难?主要是因为它运行在FaaS平台上,而FaaS平台的运行环境由云厂商提供且预制开发者只能进行有限的定制。没有服务器怎么登录服务器执行安装依赖的命令?函数在被触发执行的时候会生成函数实例。Serverless应用的依赖本质上是每个函数代码的依赖。函数实例的实体就是容器,容器的实现方案可以是Docker等。FaaS通过函数来隔离每个函数实例,也通过容器实现函数运行时的内存和cpu限制,比如给函数分配128M内存,函数实例所对应的容器内存资源只有128M。运行环境编程语言是你创建函数时指定的某个具体版本的编程语言,由FaaS平台提供。内置模块内置模块就是该编程语言的一些内置模块比如Node.js中的http.js为了让开发者使用更方便,FaaS平台一般还会默认安装一些依赖,比如函数计算的Node.js默认提供了aliyun-sdk模块。函数实例创建时,会从存储服务中将你的代码下载下来并加载到运行环境中。Serverless应用代码函数实例中你能控制的只有函数代码,函数代码需要安装依赖实现方案就是将依赖安装到应用内部,将依赖和代码一并打包部署到FaaS平台当中。实践难点大多数编程语言的依赖通常安装在全局系统目录,比如maven工具,安装在项目之外的系统目录中。• 安装依赖过程中可能涉及代码编译,环境不统一会导致编译产物有差异• 系统依赖通常不可移植,应用运行时依赖一些系统级别的动态链接库和软件• 安装依赖的难度依次递增的编程语言:Java、Node.js、Python在Faas中部署的Java应用就是编译后的jar包而不是原始代码。如何为Java应用安装依赖虽然部署jar包不用关心依赖了,但也带来了问题:部署前需要先编译。通常本地开发的时候Java的依赖安装在本地的.m2/home/repository目录下,编译的时候根据本地的jdk版本和本机的repository编译代码,如果有多个同学同时开发部署一个应用,大家电脑上安装的jdk版本或依赖包可能不完全相同,就会导致每个人编译出来的jar包不同,甚至部署到FaaS上的jar包无法正常运行。想要解决编译环境问题就要有统一的构建机不让开发同学直接编译部署Java代码。开发Java的时候可以通过pom.xml文件来定义代码的依赖,部署Java应用的时候需要将Java的业务代码和pom.xml都上传到构建机,在构建机上统一安装依赖编译代码得到一个可以直接执行的jar包,然后再将jar包部署到FaaS平台。总的来说Java是编译型语言,只要统一编译环境就不要关心依赖的问题,可是对于Node.js解释型语言怎么安装管理依赖呢?Node.js安装依赖项目依赖可以通过package.json管理,在代码中可以通过require方法引入某些依赖。依赖的查找路径是先查询项目目录再查询系统目录。这种查找依赖所在路径的方式得益与Node.js包管理机制。要让运行在FaaS中的Node.js代码能够找到依赖,解决方法就是避免使用全局依赖,把所有的依赖都安装在node_modules中,然后把node_modules和代码一同部署在FaaS上。对于Node.js来说,除了可以直接安装在node_moudles中的JS依赖外,还会使用C++来编写一些Node.js扩展那么这些C++的扩展怎么安装呢?先把C++代码编译为.node文件,再把编译产物和代码一同部署在FaaS平台。代码示例https://gitee.com/pingfanrenbiji/serverless-class/tree/master/06使用C++编译之后的可执行文件需要和FaaS平台的运行环境兼容,不然就没有办法运行,所以跟Java一样,编写C++扩展的时候,需要使用统一的构建机,并且构建机的运行环境和Faas平台的运行环境保持一致。依赖包体积太大,导致函数无法部署的问题FaaS平台对代码体积有限制,函数计算限制是100MB,限制本质上是为了提高函数性能,因为FaaS构建函数实例的时候需要从文件存储当中下载代码,代码体积过大的话,耗时就会越来越长,这就会影响函数启动速度。在Node.js中代码体积问题尤为常见。node_moudles包体积经常会非常大,虽然说node.js的npm生态为编程带来了便利,但node_modules嵌套层级冗余代码多模块当中还包含了很多测试用例和源代码,这就很容易导致代码体积快速膨胀。可能大部分时候引入一个新的代码依赖真正需要执行的代码就几行但是包的体积却有几MB,部署函数到FaaS上的时候就会造成不必要的资源浪费。想办法减少模块的体积在编译的过程当中分析代码的依赖,只把需要使用的模块和需要引入的函数构建打包,丢弃无用的代码,对代码进行压缩,这个过程和前端使用的webpack构建的redis.js的过程一样,要实现Node.js代码的编译也可以使用webpack来实现。Vercel是基于webpack开发的ncc工具,使用ncc可以把复杂的Node.js项目编译为单个文件并且去掉不必要的依赖,很大程度上就减少了代码的体积。https://gitee.com/pingfanrenbiji/ncc总的来说Node.js安装依赖都是比较方便的,因为是它的依赖可以轻松的安装在项目目录当中,但对于Python这种依赖需要安装在系统目录当中的编程语言到底要怎么去安装依赖呢?Python安装依赖pip是当前python当中最流行的包管理工具,所以通常会使用pip来安装依赖。给Python项目安装依赖比较麻烦的地方在于使用pip安装的依赖通常会散落在系统的各个文件中比如Python类库和动态链接都会放在/{user}/lib/python/site-packages、可执行文件都会放在/user/bin目录下,想要将所有文件都存放在同一个目录当中就会比较麻烦,pip提供了多种把依赖安装在指定目录的方案。方法一:使用--install-option参数可以让你精确的控制某个类型的依赖安装路径。将模块安装到指定目录把所有目录安装到指定目录或者--prefex=${pwd}当前目录,这样更方便依赖的管理。有一个很大的问题就是在安装的过程中需要根据源码重新的构建依赖,如果某一个依赖包没有源码那就没有办法使用了,而且install-options参数设置比较复杂。方法二:--target参数这个是简单些的方式。target参数可以将依赖直接安装到当前目录,不会产生lib/python3.7/site-packages子目录结构,适合依赖较少的情况下。方法三:使用virtualenv把python包安装到一个独立的虚拟环境中,这样就可以在虚拟环境中为代码安装所有的依赖,形态上你的代码和依赖就完全在同一个目录中了,这样就可以把依赖和代码一同部署到FaaS上了。好处是不会污染全局环境不会把包管理相关的工具都本地化。缺点就是会增加包的体积。Python解析依赖的路径但是对python来说把包安装到当前目录还不够,还需要修改python解析依赖的路径,可以通过sys.path查看python解析依赖的路径。如果你的依赖直接安装在当前目录还好,因为sys.path包含了当前目录。代码当中可以直接引入依赖,但通常不会将依赖安装在当前目录,因为这样会导致当前目录下的文件十分混乱,而是将他们安装在当前目录的lib/python3.7/site-packages目录下。将python依赖包目录添加到sys.path中需要在代码中修改sys.path,将子目录加入到sys.path之后,你才能使用其中的依赖模块。小结• 不同编程语言包管理机制不同,安装依赖的方式也不尽相同,但本质上,都是需要将依赖安装到应用项目中,并且随项目一起部署到FaaS平台。• 开发框架也在解决安装依赖的问题,让用户尽可能的最低成本完成应用的开发。• Serverless应用的代码依赖和系统依赖都需要安装在项目中和应用代码一起部署到FaaS平台• FaaS对代码体积大小有限制,所以最好要精简依赖体积• 如果代码或依赖需要编译,则编译环境需要和FaaS运行环境兼容,不然编译后的产物可能无法运行。
接下来介绍两个开发框架• Serverless Framework• 函数计算FunServerless Framework• Serverless Framework是最完善的开发框架之一,不仅实现了前面应用开发、调试、部署等基础问题,还实现了多个Serverless平台的支持,支持AWS、谷歌、阿里云、腾讯云等公有云Serverless平台,还支持CloudFlare、OpenWhisk、K8S等私有Serverless平台。• Serverless Framework使用Node.js开发的• provider 具体的Serverless平台(通过该命令实现不同平台的支持,命令大多相同,不同平台功能的完整性有差异)• key AWS账号的aws_access_key_id• secret AWS账号的aws_secret_access_key应用配置• 通过yaml配置来创建或更新资源• 使用aws s3存储桶• 通过配置代码来描述基础设施应用调试在将函数部署到Lambda之前,Serverless现在本地将代码打包,最终代码是一个压缩包,路径是.serverless/{servername}.zip也可以单独部署某一个函数serverless deploy function -f functionnameServerless framework 还提供日志查询功能;Fun是阿里云函数计算团队开发维护的一个Serverless开发框架,因为也支持函数计算。函数计算Fun配置完成后 Fun将账号信息存储在~/.fcli/config.yaml中也可以将账号信息配置在项目根目录.env中这个优先级更高。初始化一个项目
前文回顾传统应用部署的特点如果应用很长时间没有使用,大部分资源都是浪费的。如果想要按需使用,首先需要初始化虚拟机,再初始化应用运行环境,然后在启动应用,整个过程耗时达到分钟级别,对于业务而言这显然是不可接受的。而基于运行环境的重用能够实现毫秒级的热启动。• 直接进行处理无需启动应用• 一直消耗硬件资源Serverless的优势• 应用的百毫秒启动• 资源利用率和业务性能的平衡Serverless的特点• 组成Serverless应用的函数是事件驱动的但也可以直接用API调用函数• 函数可以同步调用或异步调用,定时触发器函数是异步调用的,异步调用函数建议主动记录并处理异步调用结果• 函数的启动过程分为下载代码、启动容器、启动运行环境、执行代码四个步骤,前三个步骤称为冷启动,最后一个步骤是热启动• 执行上下文重用可以提高Serverless应用性能,但在编写代码时要注意执行上下文重用带来的风险• 函数并发限制导致函数执行时间延迟• 执行上下文重用导致每次处理的都是同一份数据如何提高应用开发调试和部署效率?在实际应用中,绝大多数应用由很多函数组成,应用部署的时候需要将所有函数统一部署,并且函数运行依赖Faas环境,这样导致函数代码不能直接在本地运行,这样的一些限制给应用开发调试和部署流程带来了挑战。新的挑战• 函数太多如何管理• 本地开发时如何进行调试• 代码开发完成后如何部署什么是Serverless开发框架Serverless的开发框架不是传统的Express.js、Spring Boot等代码框架,而是集成Serverless思想、贯穿Serverless应用从开发到上线全流程的工具。基于Serverless开发框架很容易开发一个Serverless架构的应用,企业也能轻松的把现有的业务演化为Serverless架构。除了底层的Faas和Baas基础设施,上层的开发框架也是非常重要的一部分,直接决定了开发者使用Serverless的成本。从框架开发者的角度和思考如何去设计Serverless开发框架Serverless开发框架所应具备的基本功能:应用管理、应用开发、应用调试、应用部署。从形态上来看,Serverless开发框架可以抽象为一个平台或服务,用终端工具或控制台的方式提供给开发者使用。应用管理应用的管理主要是函数的管理,各个Faas平台也考虑到了这一点,比如函数计算的服务功能、Lambda的应用功能。你可以把同一个应用的函数都在同一个服务下创建。一个服务代表一个应用。那怎么描述服务和函数的关系呢?因为它们是静态的,不会在代码运行的时候改变,可以使用yaml文件或js文件来表示。在创建函数的时候,也需要指定函数的入口、编写语言、触发器。假设终端工具是一个可执行脚本,可以提供Serverless init命令自动为用户创建一个管理Serverless应用的yaml配置文件,有了应用的配置文件之后,开发者就需要开发代码了。为了近一步的简化用户操作,可以提供代码的开发模版,开发者基于模版一键生成Serverless应用。这样开发者就可以在本地用自己喜欢的编辑器来开发代码了。也可以提供一个Web IDE,为开发者初始化本地运行环境,这样开发者就不用在本地安装编辑器就能开发,也不用关心运行环境的差异。各个Faas平台也都提供了自己的Web IDE,比如AWS Cloud9、腾讯云函数计算的Code Studio(基于VS code开发的,微软19年5月份发布了vs studio online,让开发者轻松实现web ide)目前体验最好的是Cloud9,但相比本地开发还是有一定差距的。应用调试应用调试非常麻烦,想要调试这个函数需要手动构建event,context,callback参数来测试函数的运行状态,而麻烦点在于这些参数依赖Faas环境,不同的运行环境不同触发器的入参都有差异。如何让开发者方便调试• 远程调试将代码部署到Faas平台,然后直接调用FaaS平台的接口执行函数,再得到函数运行日志及返回结果。• 本地调试由开发框架模拟函数运行时环境,构造函数参数来执行函数。本地调试效率更高。对于Serverless开发框架来说,这两种调试方式都需要,也就是需要实现serverless invoke和serverless local invoke两个命令。根据yaml的配置解析出应用的服务名称和函数列表然后调用FaaS平台的接口来创建和更新服务和函数。在创建函数的时候,FaaS当中的函数代码通常以压缩包的方式生成在文件的存储服务中。部署函数之前现在本地把代码压缩成.zip文件。部署应用的时候,将代码部署到FaaS当中进行调试,可能每次代码改动都会影响到线上服务,如果有版本控制就可以避免。账号设置与多平台支持把账号信息保存到用户自己的磁盘上,这样应用部署的时候就可以从磁盘读取账号信息,以用户账号调用FaaS平台接口,这样就可以把函数部署到用户自己的云账号下面。一个优秀的Serverless开发框架需要支持不同的云平台和厂商的支持,因为不能限制用户只使用某一种云服务。Serverless开发框架最好还要抹平不同Serverless平台的差异,让应用能够在不同Serverless平台中进行平滑迁移,甚至让开发者使用一个开发框架、一套开发流程就能够实现多云部署。要支持多种Serverless平台需要在开发框架上去配置各个不同的平台,但现在各个平台都没有一个统一的标准,这是难点之所在。所以在创造一个开发框架的时候首先要抽象出平台各个公共的部分,然后制定一套你的开发标准,再以适配器的方式适配不同的Serverless。通过一个Serverless命令对开发者提供所有服务,贯穿Serverless应用开发到线上的全流程,这其实是主流Serverless开发框架的设计原理。
浏览器和Nodejs也是事件驱动的,本质上都是将用户的操作抽象为事件,由事件是监听器监听事件,然后驱动程序执行,只是不同技术的驱动模型实现不同。• 对于Faas平台来说一方面可以通过事件来触发,另一方面可以直接调用API来执行(Faas平台都提供了执行函数的API)• Faas函数的两种调用方式:同步调用和异步调用调用方式同步调用Faas平台收到函数调用之后会立即给函数分配运行环境来执行函数。使用函数计算Node.js SDK来同步调用函数这是一个函数同步调用的实例• 其中handler中的x-fc-invocation-type用来表示同步和异步• event是事件对象,使用sdk的时候可以自定义事件对象同步执行的结果异步调用异步调用无法直接获取返回结果,适用于运行时间比较长的场景。对于函数计算来说,定时触发器就是异步调用的。异步调用的结果异步调用怎么重试?Faas平台会默认帮你有限次数的重试,但大部分情况下不能只靠Faas所提供的功能。对于异步调用,如果你关心调用结果的正确性,可以为函数配置“异步调用目标”,将调用结果发送到消息队列或其他服务中,通过监听消息判断得到异步执行结果。不管函数是同步还是异步执行的,都会有一个默认超时时间60s,否则对于Faas平台持续运行的函数会一直占用资源无法释放。如果1分钟内的日志量很大,导致查询时间很长,函数执行时间比如3分钟或者更长那么设置超时时间为10分钟,但是运行函数会越来越多。Faas默认只会存在100个运行中的实例,超过之后,事件队列就会等待其他实例执行完毕之后再生成新的函数实例。对于案例由于函数并发的限制,如果函数执行时间过长,那么使用new Date()获取时间就会有问题你以为在12点的时候执行,可能实际在12点10分的时候执行,所以就不能通过new Date来获取当前代码执行时间,而是应用从函数触发器对象当中获取函数被触发执行的时间。因为定时触发器是异步调用的,需要为函数设置调用目标,对于异常的调用结果进行处理。不过由于这个问题需要函数并发超过限制的时候才会出现,所以没有第一时间发现问题,为未来埋下隐患。如果这个问题不能解决,那么很有可能处理的数据是不准确的。那现在知道了函数并发限制是怎么造成的,函数上下文启用是怎么回事呢?这就涉及到函数的生命周期了生命周期函数启动过程整个函数的运行过程分为4个阶段:下载代码、启动容器、初始化运行环境、运行代码。只有当Faas接收到触发器事件之后才会启动并运行函数。下载代码Faas平台本身不存储代码,而是放在对象存储中,需要执行函数的时候从对象存储中将函数代码下载并解压,所以Faas平台对代码包的大小进行限制,通常代码包不会超过50M。启动容器Faas会根据函数的配置启动对应的容器,然后使用容器。初始化运行环境初始化运行环境,分析代码依赖,执行用户初始化逻辑。初始化入口函数之外的代码等,最后才是运行代码,调用入口函数执行代码。当函数第一次执行的时候会经过完整的四个步骤,前三个过程称为冷启动,最后一个过程称为热启动。整个冷启动流程耗时达到百毫秒级别。函数运行完毕之后,运行环境保留2-5分钟(和具体云厂商有关)。如果这段时间内函数需要再次执行,那么Faas平台就会使用上次的运行环境,这就是执行上下文重用。函数的这个过程也被称为热启动,热启动的耗时完全是启动函数的耗时。当一段时间内没有请求的时候,函数运行环境就会被释放,直到下次执行到来,直接从冷启动开始初始化。函数的请求示意图请求1和请求3是冷启动,请求2是热启动。函数执行完毕之后销毁运行环境,虽然对首次函数执行的性能有损耗,但却极大的提高了资源利用效率,只有在需要执行代码的时候需要初始化运行环境,消耗硬件资源。如果函数每分钟都执行,则函数几乎都是热启动的,也就是会重复使用之前的执行上下文。执行上下文就包括函数的容器环境和入口函数之外的代码,但是在实时处理日志的案例中就会出现问题了,由于执行上下文重用所以代码中除入口函数handler之外的代码都会在函数第一次运行的冷启动中执行,后面函数执行的时候都会使用第一次函数执行时已经初始化完毕的值。这也就是为什么函数每次处理得到的都是同一份数据。解决这个问题的方法就是让处理时间不被初始化就可以了,将处理时间的代码放在入口函数当中。
Java• 上图代码中入口函数handler是回调函数,其他函数都是async函数• callback的第一个参数是错误信息,第二个参数是返回值,如果函数运行正常没有报错,callback第一个参数返回空值。触发器及事件对象Faas接收到触发器产生的事件之后会根据触发器信息生成event对象,以event为参数调用函数Http触发器http触发器会根据http请求和请求参数生成事件,然后以参数形式传递给函数。那么http函数触发器的入口函数中的request和response参数具体由哪些属性组成?http触发器request参数通过以下这种方式获取request中的body信息API网关触发器用API网关接收http请求,然后产生事件,将事件传递给Faas,Faas将函数执行完毕之后,将函数返回值传递给API网关,API网关再将返回值包装为Http响应返回给用户Lambda API网关触发器event参数示例http触发器使用起来更简单,API网关触发器功能更加丰富比如ip黑名单,流量控制等定时触发器小结• 每个云厂商都实现与自己相关的触发器,比如文件存储触发器、消息触发器、数据库触发器• 触发器决定了一个Serverless应用如何提供服务也决定了代码应该如何编写• 丰富的触发器可以让应用的功能更加强大,不同的触发器的额外属性不同也给编程带来了麻烦• 将业务逻辑拆分到入口函数之外,保持入口函数的简洁,这样业务代码就与触发器无关了日志输出在Serverless中,日志输出和传统应用的日志输出没有太大差别,只是日志的存储和查询方式变了• 默认使用日志服务存储日志,日志服务包含日志采集和分析产品并设置报警项。• 各个云厂商的Faas使用自己的日志服务来存储函数日志。• Faas平台也提供了基本的函数监控,包括函数的运行时长,内存使用异常处理• 虽然在Serverless中一个函数的异常,只会影响这一次函数的运行,不会使得整个系统崩溃,但是在编写代码时,也需要充分考虑程序的异常,保证代码的健壮性,进一步提升系统稳定性。• 代码编辑可以在云端进行,而不仅仅在本地进行。• 应用的组成是单个独立的函数而不是所有功能的集合体。• 除了具体基本的函数能力之外,还需要提供便利的开发工具,丰富的触发器,完善的日志监控等其他服务集成,这些是在做技术选型的时候需要考虑的问题。执行上下文重用问题案例分析为函数设置一个定时触发器,每分钟执行一次。这段代码在本地执行是没有问题的,每次执行函数都会获取一个新的时间,然后查询数据。但在Serverless中就存在两个比较严重的问题:• 函数并发限制• 执行上下文重用这个案例的现象是每次查询的结果都是一样的。要想深刻的理解问题原因呢需要先了解Serverless的运行原理Serverless运行原理Serverless运行原理本质上就是函数的运行原理接下来从三个角度来叙述下。函数调用链路:事件驱动函数执行在案例中设置了一个定时触发器,函数每分钟都会执行一次,这是因为定时触发器会产生一个事件,Faas平台会接收各种事件,当时间来临的时候会根据事件属性来执行函数,这个过程叫事件驱动。
Faas产品对比图• Faas平台都支持Node.js、Python、Java等编程语言,也都支持Http触发器和定时触发器• 各个厂商的Faas都支持与自己云产品相关的触发器 例如:函数计算支持阿里云表格存储触发器• Faas的计费都差不多,且每个月都提供一定的免费额度,每个月前100万次执行免费,前40万GB-s免费(函数每秒消耗的内存大小,例如1GB-s 函数在1GB内存的规格上执行一秒钟所消耗的内存大小),超过免费额度之后,基本上是执行一万次一分钱,执行1GB-s 0.0031617分钱。用Faas整体的费用会非常便宜,对于小应用来说,几乎是免费的• 国外开发者会经常使用Lambda,相关的第三方产品和社区也会更加完善。国内经常使用函数计算,因为函数计算使用方式更符合国内开发者的习惯。怎么用Faas去开发一个Serverless应用?编写一个所有人都可以访问的Helloword接口,并根据请求参数进行响应客户端请求该接口的代码接口开发流程一、传统应用开发流程代码开发->初始化服务器(安装Node.js、Nginx)->启动Node.js Server(node index.js)->解析域名->配置Nginx二、Serverless应用开发流程代码开发->代码部署(Faas函数)->创建触发器为函数配置http触发器,Faas自动帮你初始化运行环境,http触发器会自动为你提供一个测试域名1、函数计算方式:在函数计算控制台上新建一个函数注意:会用到Http触发器,函数计算的Http触发器要在创建函数时就指定• request是请求信息,可以从中获取到请求参数• response是响应对象,可以用它来设置Http响应数据• context是函数上下文创建触发器用该APi Endpoint对函数进行测试,通过curl命令测试一下如果用浏览器访问,response header中强制添加content-disposition: attachment字段,此字段会使得返回结果在浏览器中以附件的方式打开,此字段无法覆盖,使用自定义域名将不受影响。2、Lambda方式• 在Lambda控制台上先创建一个函数,为函数添加api网关触发器,没有直接提供http触发器来实现通过http请求触发函数执行。Lambda也会默认提供一个测试域名• 与http触发器不同的是,api网关触发器入参是event,event对象就是http请求信息http触发器和api网关触发器区别函数计算的http触发器请求处理方式和express.js框架处理请求更加类似,而api网关触发器是普通函数开发Serverless应用的基础知识• Serverless应用是由一个个函数组成的,与main函数对应的就是Faas中的入口函数,一般名为handler即index.js文件中的handler函数• Faas函数可以包含多个源文件,然后按照编程语言的模块机制相互引入定义hello.js sayHello函数将业务逻辑拆分到入口函数之外函数定义• 函数定义本质上是由云厂商、触发器和编程语言等多个条件决定的。标准的函数定义是function(event,context),event是事件对象(Serverless中的触发器被称为事件源)• 部分特殊函数触发器的定义可能与标准的也不一样,比如http触发器是对标准函数触发器的进一步封装,主要是为了方便对http请求进行处理函数上下文包含函数运行时的一些信息不同编程语言开发函数Node.js和python开发是最简洁的。Node.js开发人员也比较多,前后端都很容易上手,在Serverless中最受欢迎。Node.js回调函数在Node.js中,仅Lambda的入口函数支持异步async写法,其他Faas平台的入口函数只能是同步写法,对于异步函数只能使用回调函数实现,所以入口函数有第三个参数callbackPython
Serverless时代Serverless指搭建和运行不需要服务器管理的一种概念。前面三种电商网站部署方式,都属于Serverful架构Serverless实现• Faas(函数即服务)提供了运行函数代码的能力,并且具有自动弹性伸缩• Baas(后端即服务)将后端能力封装成了服务,并以接口的形式提供服务• 从物理机到虚拟机,从容器到Serverless,都是去服务器的过程• 有了Iaas则不再关注物理机;有了Pass,不再需要关注操作系统;有了容器,不再需要关注运行环境;而Serverless的出现,不再需要关注传统的运维Serverless概念广义的Serverless架构是指构建和运行软件时不需要关心服务器的一种架构思想,基于Serverless思想实现的架构就是Serverless架构广义的Serverless解决的问题• 容灾备份• 弹性伸缩• 日志监控广义的Serverless和Serverful的架构区别• 资源分配不用关心应用运行的资源,只提供一份代码就行• 计算方式按实际使用量计费,计费粒度也精确到了毫秒级• 弹性伸缩可以快速根据业务并发扩容到更多的实例,甚至允许缩容到零实例状态来实现零计费狭义的Serverless基于Faas和Baas的架构,是一种计算和存储分离的架构,狭义的Serverless是Faas和Baas的组合Faas服务举例说明:定义要给Faas接口• 客户端代码定义启动一个web服务 访问该服务的list接口• Faas接口代码当把函数部署到Faas,然后访问接口时,会发现得到的结果永远都是1,这是Faas的一个特点:无状态Baas服务Baas本质上就是把后端功能封装起来,以接口的形式提供服务常见的Bass产品由阿里云表格存储、消息中间件等,这些服务都可以通过API进行访问举例说明:使用表格存储实现pv统计功能什么不是Serverless先介绍下Paas的特点Paas(平台即服务)是云计算虚拟机时代的主要形态之一• 付费标准按资源付费,而不是按实际使用量付费• 弹性伸缩只能针对底层的服务器进行扩缩容。而Serverless的弹性伸缩是请求级别的,扩容速度更快,资源利用率更高。K8S是否为Serverless?K8S是介于Serverful和Serverless之间的产物• Kubernetes本身也不是Serverless,只是在概念方面有些类似,它是一种容器编排技术,基于K8S能很方便的进行Pod的管理,并且实现应用的弹性伸缩。Pod是应用和运行环境的结合,也不用关心服务器。• 从运维的角度看,主流的K8S服务提供商,提供的都是K8S集群托管和运维服务• 开发者可以非常方便的管理集群中的硬件、存储、pod等资源,但是上层应用的运维和调度还是需要开发者自己来调度的,K8S也无法做到按代码执行的次数和实际消耗的资源来计费,还是和传统的Serverful一样按资源数量计费云原生云原生是指原生为云设计的架构模式。Serverless几乎封装了所有底层资源调度和运维工具,让你更加容易的使用云计算基础设施,这样极大的简化了云服务器编程• Serverless是云原生的一种实现• 云原生的另外一种实现是KubernetesServerless的优点• 不用运维• 弹性伸缩• 节省成本• 开发简单• 降低风险• 易于扩展Serverless的缺点• 依赖第三方服务使用云厂商所提供的serverless产品比如FaaS和BaaS,这样就会使得业务和第三方云厂商绑定,若要换一个云厂商那么迁移成本太大。这是优点也是缺点• 底层硬件的多样性应用在Faas上运行,由于底层硬件的多样性和不确定性,云厂商可以灵活的选择服务器来运行你的代码,让运行环境对应的物理环境变的不同,甚至有的函数运行在不同代的cpu上;如果代码不依赖底层cpu,那影响不大,否则可能不同的cpu的性能有差异;如果代码必须运行在某种cpu或gpu,那就需要云厂商提供这种能力。云厂商也会最小化的平衡利用资源的效率和成本• 应用性能瓶颈函数运行前,先初始化函数运行的环境,这个过程需要消耗一定的时间,因为函数不是持续在线,而是运行的时候才启动。从资源利用率上,这种模式可以节省资源;从应用性能上来看,这样会降低性能,而且还要靠云厂商实现性能优化,如果你的应用对性能非常敏感,就需要考虑怎么优化性能了• 函数通信效率低传统的mvc架构模式中,view层的方法调用model层方法都是在内存中运行的,而在Serverless中,函数与函数之间就完全独立了,如果两个函数之间数据有依赖,需要函数通信交换数据,这就需要函数与函数之间的调用了,相比之前的内存调用,数据交互效率明显低很多,这个问题的本质是Faas还没有比较好的数据通信协议或方案• 开发调试复杂想在本地开发,调试Serverless应用比较复杂小结• 广义上讲Serverless是一种架构思想• 狭义上讲Serverless是FaaS和BaaS的组合• Serverless架构主要特点是按量付费、弹性伸缩、不用运维
Serverless架构兴起• 主流云服务商推出Serverless相关的云产品和新功能 : AWS lambda、阿里云函数计算、腾讯云云函数• 各种关于Serverless的商业和开源产品也层出不穷: Serverless Framework、OpenFaas、KubelessServerless为什么这么火?云计算的发展史就是Serverless的兴起史 包括物理机时代、虚拟机时代、容器时代、Serverless时代物理机时代分时操作理论1995年 分时操作系统理论:通过时间片轮转的方式把一个操作系统给多个用户使用云计算的定义1997年云计算定义:一种新的计算范式,其中计算的边界将由经济原理决定,而不仅仅是技术限制。云计算不只是虚拟机技术,还是云服务商提供计算资源,使用者购买计算资源开发一个网站需要经历的步骤物理机时代,网站上线和稳定运行面临的最大问题就是服务器等硬件问题虚拟机时代虚拟化x86服务器的虚拟化产品使虚拟机逐渐普及。通过虚拟机化技术,可以把一台物理机分割成多台虚拟机提供给多用户使用充分利用硬件资源,而且速度和弹性也远超物理机Iaas(基础设施即服务)出现了很多虚拟化的云厂商和产品 比如阿里云ECS,这种云计算形态也叫作Iaas(软件即服务)虚拟机时代网站部署架构• 云数据库有专门的服务器,并且还提供了备份容灾比自己在服务器上安装数据库更稳定性能更强• 对象存储能无限扩容,不用担心磁盘不够了• 服务器就只负责处理用户的请求• 把计算和存储分离开来,即降低了系统负载,也提升了数据安全性• 单机应用升级为了集群应用,通过负载均衡,会把用户流量均匀分配到每台服务器上容器时代Docker容器技术代替了虚拟化技术,云计算进入容器时代。容器就是把代码和运行环境打包在一起,这样代码就可以在任何地方运行。当容器多的时候就出现了容器编排技术Kubernetes。容器时代网站部署架构容器时代面临的问题• 需要去规划节点和Pod的CPU、内存、磁盘等资源• 需要编写复杂的YAML去部署Pod、服务、需要经常排查Pod出现的异常
递归调用parse_expr传入一个优先级即目前操作符栈栈顶的优先级是个数字先判断是否为一个数据 比如num或字符串sizeof也是一个数据 返回整数值或者是identitify 比如变量或者是函数调用 function call无论是什么 它返回的是一个数据然后把数据存在ax寄存器中如果数据压栈的话 就会压在真正的内存stack空间里面这样就实现了数据栈 data stack操作符压栈是通过递归调用的函数栈实现的处理完数据之后 再处理优先级最高的符号(然后是*和&和!和~(位的取反)然后是正号和负号最后是前置的++和--以上是一元操作符的处理过程 然后递归的调用该过程+正号其实什么都不需要做 直接忽略即可处理完一元操作符就会处理二元操作符即上图while循环通过优先级爬上的算法来处理假设遇到的token是比已经在栈上的更大的新的优先级更大才能进入while循环否则先把栈里面的操作符先处理掉如果新的比操作符栈顶的操作符优先级更高进入while循环内部 根据二元操作符的顺序来处理具体的内容假设表达式是a=3+5一开始是a变量 进入parse_expr先处理a变量 结果放入ax寄存器ax是数据栈data statck的栈顶然后发现是=赋值操作符 入栈即取a的地址 然后把地址压栈然后通过调用parse_expr 解析3+5先处理num 3ax就变成3了然后+优先级比赋值符更高 所以进入while循环while循环里面就会找到+(And)此时数据栈中是a和3操作符栈中是=和+然后是5 入栈 ax即数据栈的栈定为5此时数据栈中是a和3和5然后把操作符中的+出栈pop数据栈中的3和5出栈 调用add命令然后结果放入ax为8然后把栈中的地址(比如data区230230)拿出来 把ax中的值存入地址中去代码生成c语言通过各种函数和全局变量组成通过函数的定义以及函数的调用来理解整个函数代码生成逻辑main函数涉及到整个程序的入口和出口问题通过它来理解参数和局部变量是怎么定义和使用的上面的代码中没有全局变量定义一个全局变量int global;在main函数中赋值 global=1;基于这样的例子来理解代码生成的核心思想:解析完了源代码生成了相应的vm指令之后在正式开始执行代码之前会得到1、在code区中有要执行的指令即源代码2、还有data区的数据除此之外 其他的都处于初始化的状态即还没有数据的状态比如stack区和register 这些都是在代码执行之后才开始有数据的还有一个就是symbol tablesymbol table在代码解析(生成)完成之后就没有任何作用了它的作用在代码的定义和使用这2部分衔接生成的过程比如int add() 这行代码是add函数的定义int ret 这行代码已进入add定义的解析的过程在解析这行的时候就已经完成了parse到gencode的过程但是在使用add的时候是在第10行这个时候关于解析需要的中间数据就存放在symbol table中当把整个代码全部解析完 sybmot就没有用了代码生成其实就是生成code区和data区这2个区的数据对应到x86的汇编asm里面的两块区域一个text 这里面有很多指令 比如mov add另一个是就是data 里面有全局变量、字面量的内容这2块区域就对应着code区和data区想要把c4改造成像x86一样生成text和data文件 然后在加载文件也是可以的 即虚拟机是一块单独的代码 编译器也是一块单独的代码add函数的代码如果有全局变量的定义 也不会对生成的代码区的指令有影响的变量名叫什么是不重要的 只要在使用的时候能找到定义的变量在data区所对应的地址即可code区生成的东西其实就是函数定义函数定义内部其实就是各种语句 各种逻辑对于代码中的字面量以及全局的变量都会定义在data区对于局部变量的定义完全是没有任何代码对应的LEA -1 对应到ret = a+b 左半部分main函数开始调用add函数的指令是:从准备参数开始 第一个参数1 第二个参数2call add函数对应的地址解析到add的时候会通过符号表找到对应的地址就可以精确的生成call add地址这样的指令然后看add生成的代码 它其实就是保存ret的地址因为它是局部变量 保存它跟栈相对的位置比如是全局变量 比如global这里就不会用lea了因为lea是基于bp地址如果是全局变量的话 可以指定load immediatily一个地址然后push到栈里面 等待后面去存储把结果存储到这个地址中去LEA 3是第一个参数1 把它装到栈里面去LEA 2是第二个参数2然后把栈里面的1和ax=2相加把结果存在目前栈保存的地址中去这个地址就是前面存起来的ret的地址相当于现在的结果就在ret里面了return的时候是返回的第一个局部变量即LEA -1然后把结果存在ax中return就是把ax数据返回给了上一层然后回到这里去清理参数main也是一样的逻辑首先会加载一个string(c语言中是char*,char指针 它的数据其实是一个地址)在data区的地址然后准备printf的参数然后准备add的参数然后调用add这个就是要生成的代码区的所有数据然后来详细讲一下定义部分和使用部分代码解析和生成的细节这个符号表中包含了前面代码中所有会遇到的符号包含if、else、while关键字类型关键字 int、char语句关键字returnnative call关键字 比如 printf然后是代码自定义的关键字main是在预处理关键字的时候就会把它存储进去因为要提前记住main在symbol table的地址回头根据这个地址就可以找到main的函数入口再开始执行最终生成的代码接着就是在函数定义、全局变量定义写进symbol table的比如在解析到add指令的时候 就会把add写入symbol table如果发现它是一个function 会把目前的地址写入这个地址其实就是在生成的过程中 code区所处的指针位置是什么 那么函数地址就是什么比如code区是空的(0~max) 在解析第一个函数的时候生成的第一个指令就是要为局部变量生成栈空间比如有一个局部变量就会生成NVAR 1指令后续就是这个函数的执行逻辑 一直到ret这个函数里生成的第一条指令就是就是函数在symbol table中value的值如果要call这个函数的时候 就是把pc call到这个地址执行NVAR 1指令即可在函数定义的时候 关键要存储这个函数的初始地址在使用(call)的时候 只需要找到add这个identity对应的token value地址即可全局变量在定义的时候留一个地址那这个地址就是data区为它分配的一个地址比如解析到int global变量的定义0到pc指针之间就是为global预留的空间为了能在使用的时候精确找到这个地址 就把它存在symbol table中在使用的时候 只需要把地址加载到ax里面即可(IMM 430128744)ax就指向了这个地址空间如果给这个地址空间存储东西 或者 想调用它的值都可以通过SI\LI(int)或SC\LC(char)等这些基础的指令根据这个地址对global的变量做一些操作对局部变量解析的时候 比如add中的ret 也是需要知道它的地址就可以了地址是相对于它在栈基的位置使用的时候是以bp为分割点的bp之前有函数的返回地址和参数定义bp下面是局部变量的地址 比如ret就可以知道每个局部变量在栈中和bp的相对位置在symbol table中存的就是这样的原生序号比如参数a存的就是0参数b存储的是1使用的是LEA 基于bp位置 ,用bp的位置减去序号比如第一个参数a 3-0=3LEA 3就可以调到第一个参数对于第一个局部变量ret ,3-4=-1 即LEA -1具体看下如何用代码实现上述定义和使用的过程当token为{的时候 那么是函数调用先处理参数 ,参数也是一个表达式add(1,2)这种情况参数是字面量add(add(1,2),2) 这种情况参数又是一个表达式所以递归调用parse_expr 求表达式值这个值在ax寄存器中然后把值push到栈中准备一个参数列表 有多少个值 就push多少个while循环之后 就把值push到栈中了接下来是两种情况一个是native call 那么就用对应的指令 直接调用对应的指令就是symbol table中的value那么就直接生成代码对应的指令就好了如果是定义的函数function那么代码区就会call identitfiy对应的token value即function的地址call里面就会存返回值即call完了之后的地方然后又会处理bp处理完之后 pc指针又会回到被调用的地方调用完之后 就会执行清理的代码把栈中的参数清理掉即栈里面的1、2就会被清理掉DARG i ,i是有多少个参数i就是几如果是num 就会当作一个字面量来处理字面量生成代码直接是LOAD IMM就直接加载到ax中去然后就是处理非枚举变量(局部变量和全局变量)如果是局部变量就是LEA bp的序号-局部变量的序号如果是全局变量 就是Load IMM 全局变量的地址在使用的时候就需要根据类型如果类型是char 就load char 把char加载进来如果是整数 就load int如果是指针 就什么都不用load ,ax里面就应该是它的地址Load IMM这个地址之后 这个指针里面拿到的就是地址parse stmt怎么做代码生成的前面说了如何用指令来表示if while逻辑的过程但是在代码解析的过程中 如何生成对应的指令呢如果解析的是if语句 if(a<0){}else{}parer_expr 解析if后面表达式的值 存放在ax中根据ax中的值 来决定跳转的位置如果ax是0 则这个表达式是false如果是false的话 则跳转到else的位置来执行首先在代码区生成的指令是JZ第二个就是JZ到false这个点位但目前还不知道false点位因为还没有解析if{}中的逻辑不过没有关系 先用变量b把这个点位存储起来等后面再确定这个点位 再回写到这个地方先空着parse_stmt去解析if{}语句快然后把true相关的指令就全部都执行完了如果a是大于0的 if(a>0){}else{}JZ不会跳转 直接执行true的指令如果true指令执行完了之后 不能接着执行 因为下面就是flase的指令了需要直接跳转到if结束的地方即JUMP一个位置这个位置是哪个 还不知道 因为false还没有解析还是要把要JUMP的地方先空出来 存一个变量c空出来之后 就进入了false的解析地址了就知道了fasle的点位在哪里 就是JUMP之后的地方这个就是false语句开始的地方所以这是时候把code的位置存回变量b假设这个位置是430430 那么JUMP b就是JUMP 430430然后开始解析false的statementfalse解析完之后 就是代码退出的地方退出的地方假设是430450那么这个位置就是true statement直接JUMP的位置这个时候就会这个end if的点位存回变量c以上就是if的parse和codegen的过程if之后再说下whilewhile(a>0){}在判断了while token之后先把这个时候 code区的点位存储下来 存到变量a中为什么需要存储这个 因为循环没有结束的话 需要跳转回来所以code区的最后一个指令肯定是一个JUMP a的位置这样才能有一个循环然后开始解析while里面表达式的值每次都要parse 因为a每次都在不断的变化 否则就while死循环了解析表达式的值存ax如果这个时候ax的值为0了while就结束了 需要跳转出while循环即JZ end while 那个点位 但目前还不知道先存在变量b中while里面的语句块也是通过同样的方式 parse_stmt解析最后JUMP a的位置假设是430430JUMP完之后code的位置假设是430450然后把430450回写到变量b以上就是关键语句的解析最后是表达式求值的代码生成过程首先看下减法 a-b的形式因为已经解析到-减法这个符号了-前面的已经解析好了并且在ax里面了在解析的过程中是这么个顺序然后把减号忽略掉然后把ax中的值push到栈里面假设a的值是5 把5push到栈中然后去解析第二个参数第二个参数也是一个表达式 不管是单独的变量还是字面量或者是函数调用返回的结果解析这个表达式如果解析的表达式它的优先级大于或等于乘号假设是a-b*5先把b*5解析完了 然后再做减法根据优先级爬山算法首位的符号是乘号b的值在ax中 假设是3那么在生成的代码中除了刚刚的那个push之外然后就是解析-减号后面的代码解析完了 然后相减这里有一些跟类型系统相关的一些逻辑包括后面的++和{}都有这样的问题如果说是两个指针相减 point-point相当于求2个指针地址的相对位置由于地址是8个byte 64位的求出相减之后的结果需要除以8假设地址相差2位 相减结果就相差16个byte把ax=16 push到栈中这个时候把栈中的16除以8ax=22就是a-b的结果如果指针减一个正常的整数呢即需要指针往后挪几位挪的这个位数最终在地址值中相应的乘以8比如指针的值是430430加1位 下一位的地址是430438真正加之前需要把加的值乘以8这个就是指针加减比如字面量的过程两个正常的整数相减 直接sub一下就完了把第一个参数push到栈中第二个参数b在ax中了然后直接sub就可以得到ax结果了后置的加加相当于目前ax里面的值加1加完1之后再存回ax中去中括号[]左边的值一定是一个地址比如a[3]相当于这个地址加3之后再给它求值 *(a+3)对指针做一个相应的加加完之后再把值load进来最后看下cpc源码的main函数看下编译器主干逻辑首先需要加载所需要编译的源码比如cpc hello.c然后把虚拟机做初始化 主要是内存和寄存器该申请内存的申请内存该设置为0的设置为0然后准备cpu这个语言所需要的所有的关键字这里面包含了main然后就是parse方法这里面会处理全局定义包括变量和函数变量就直接处理了函数会调用两个子函数 一个是parse_params 参数列表另一个是parse_function函数体parse_function会调用一个很关键的parse_statement处理各种功能语句语句里面调用parse_expr做表达式求值这个就是整个parse和codegen的过程在这样的一个调用链中完成的调用parse函数完成之后 就生成了code区所有指令以及data区的所有的全局变量也好各种字面量也好write_as把code区的所有指令输出最后执行code区的所有指令然后看下怎么找到main函数的入口因为main入口并不是在code区0的位置在做keyword解析的时候会拿到main在symbol table的指针它的value就是main在code区的地址pc先指向这个位置 后续执行的时候就是从main的入口位置开始执行的main的参数怎么传进来的呢cpc hello.c xx xx xxcpc编译器源码第一个参数之后的位置就是所编译代码的main参数返回值是stack区max-2的位置return了之后 pc就会修改成这个地址这个地址按理说是一个地址 但它实际上是一个push指令那么就会执行push即把ax push到stack中最终调用exit命令exit命令就是把栈里面的值当作exit的参数退出整个程序就是这么退出的所以在栈的最开始初始的时候存入exit、push、main参数、main的返回地址再接着就是main的函数栈当main的函数栈结束之后就可以退出代码了现在就知道了整个函数的入口和出口整个过程就是把pc指令拿出来然后跟着指令是什么做对应的操作pc是不断的++的如果有JMP pc也会有一些跳转这个就是代码生成之后运行的主干逻辑结尾最近三篇文章介绍了:C语言编译器概要设计思路一虚拟机指令集&栈与函数调用从最开始的虚拟机指令集以及函数调用的实现逻辑到parse逻辑(词法分析、语法分析、表达式求值)最后是代码生成核心的思想和逻辑
parse整个program终结符要么是var_decl中的enum、type要么是func_decl中的type所以看上图代码如果是enum的话 它可能有名字的比如enum myenum{"A","B","C"}如果没有名字 则接着就是{了然后使用parse_enum解析A,B,C这些内容如果不是枚举 那么就是类型开头的 要么是Int要么是Char要么是指针类型 比如Int* 或 Int**(指针的指针)这里的代码是先解析最基础的Int和char先解析获得基础类型如果token没有遇到;或}就持续的解析如果是变量的话 它的终结是;如果是函数的话 它的终结是}接着看代码如果token是*号的话就把类型转换为指针如果后面还是* 即有很多个*因为有可能是指向指针的指针每多一个指针 就把这个类型多加一个指针的标识符PTR有多少个PTR就有多少个指针这样的话 类型就解析完了 可能是int、char、enum指针、指向指针的指针接着检查token是否是一个identifier且全局定义 不能重名所以检查token是一个新的identifier此时知道了它的名字 但还不知道是个变量还是个函数如果接下来是一个{ 左括号的话那么就解析函数符号表中的class就可以明确定义为Fun函数的地址就是目前code区的地址+1然后把左括号处理掉 然后解析参数 然后处理右括号 然后左边大括号然后就是函数体了否则就解析这个变量符号表中的class是全局变量类型全局变量的value是data区的地址局部变量的value是在栈的相对地址地址赋了值之后 得往后移动一位假设是64的空间 地址移动每次都会加8 即8个byte共64个bit如果定义中有多个变量的话 比如int * a,b,c用逗号处理掉a是一个指向int的指针b是一个变量c也是int变量通过逗号的方式 一层一层去处理以上就是解析整个代码题program的过程接着看下解析函数的分支代码中有一个ibp 就是bp基栈的位置通过ibp这个全局变量来保存它因为在解析fun的过程中 会相应的生成vm指令vm指令是根据栈的目前的位置来生成的ibp就是目前这个函数base point基栈的地址去解析0个或多个局部变量[local_var_decl]然后解析代码体 {statement}局部变量必须在函数的上面 因为是one pass过程while循环里面是解析base类型 int|char里面是处理局部变量如果有同名的全局变量的话 需要hide下class是局部变量value是相对于bp的位置bp的位置是ibp 所以value的值是ibp++第一个就是bp+1第二个就是bp+2解析完了局部变量之后 就需要生成相应的代码了需要为局部变量new stack framei-ibp 就表示有多少个局部变量相当于是while循环执行的次数代码区中就会有一个NVAR 2在遇到}之前 就调用parse_stat函数不断的解析语句解析完所有语句之后 进行return但有可能语句本身是一个return语句那么在语句里面就解析完了如果这个函数返回是void没有return语句 需要补充一个return语句最后把前面遮蔽的全局变量恢复解析语句的代码语句一共有6种可能:if语句、while语句、return语句、{语句、一个分号;的空语句、纯粹的表达式只有个分号(比如a=3这是一个表达式只是不返回任何东西 所以就认为a=3是终结的语句)表达式求值与优先级爬上• 语句(statement)if else;while,return语句是告诉计算机到底做什么事情不返回值• 表达式(expression)2+3,4*5表达式的目的是求值 有返回值有一种情况很难说是语句还是表达式a=3 把3赋值给a 即赋值语句c语言语法: if (a=3;!=0){ xxx }else{ xxx }这个时候发现a=3其实是返回值了返回的值是a变量本身的值3!=0满足条件 会执行else中的代码a=3这个赋值语句其实是一个赋值表达式 有返回值即a本身操作符优先级3+4*3-5在编译器做表达式求值的时候需要知道优先级以及通过优先级来实现求值操作符分为3种• 一元(优先级最高)1 ++(前置++) --2 + -3 ~ !4 & *5 ()前4个优先级是一样的 第5个优先级最高例如 *++a对于一元的操作符来说 哪个最贴近符合所修饰的变量就会先处理它即++先处理 再处理*号即一元操作符按出现顺序的逆顺序来的第2个 + - 不是二元中的+ - 而是正负号的意思比如-++a 先对a进行++ 然后取负号• 二元(优先级次之)1 = 优先级最低2 ?: || (逻辑运算)3 | & ^ (位运算)4 == > < >= <= << >>5 + -6 * / %7 [] 求值 比如a[3] 优先级最高这7种情况 优先级依次递增比如赋值表达式(优先级最低)的求值过程-*&++a++ = xxxxx(比如这个是很复杂的表达式)先求前面的表达式-*&++a++ 它是一个地址空间= 赋值符号后面是表达式会求出一个值地址空间的类型和值的类型需一样整个表达式的最终结果就是这个地址空间中的值• 三元?:条件操作符 类似简写的if else优先级爬山算法3+3*4-5在解析表达式的时候会把遇到的数据和操作符缓存下来直到遇到的操作符比缓存下来的操作符优先级更低比如已经缓存了操作符+ *和数据3、3、4遇到一个-(减号) 这个优先级比*号低就把目前缓存中的操作符比目前-号更高的即*拿出来处理掉即 4和3的操作符是*乘号 结果是1212这个数据又被放入栈中此时栈中的数据是3和12符号是+-和+号的优先级在同一个层级但-号比+号的优先级更高所以把-号放入缓存 此时操作符就有了+-然后把5放入数据栈此时表达式结束了 就把操作符做出栈处理12和5 和 - 操作符 出栈 相减是7此时数据是7和3 操作符是+再把7、3数出栈 操作符+出栈最后的结果10在放入栈中再举一个复杂的例子++a++=i==0?2+3:45看优先级爬上算法怎么处理这个表达式第一个操作符是一元操作符根据顺序的反方向处理先把*放入操作符栈中++也是一元操作符也入栈了第一个num是a变量 放入数据栈中此时数据栈中是a ,操作符栈中是*和++假设这个a变量是int *aa指针假设i==0a后面的操作符是++比缓存即操作符栈中的操作符优先级更低所以在这操作符入栈之前需要把操作符栈中的优先级更高的操作符出栈 即操作符栈中的++出栈数据栈中只有一个a 对于一元操作符来说只需要从数据栈中出一个所以先处理++a假设a在内存中的位置是230230假设地址是8个bytes 64 bit的地址空间a+1其实就是加8 变成了230238a的位置从230230变成了230238此时数据栈中是新地址的a、符号栈中是*后置++ 比*的优先级还要低然后把*拿出来(pop)对a的地址求值此是操作符栈中就空了数据栈中就是a这个地址代表的内存空间然后后置++进入操作符栈然后遇到赋值操作符=比操作符栈中的++优先级低先把操作符栈中已有的优先级更高的操作符出栈然后对内存地址230231中的值由0变成1所以此时数据栈是*a这个地址中的值变成了1然后赋值操作符入栈 此时操作符栈中就只有一个赋值操作符然后下个是数据i 放入数据栈然后是相等操作符==它的优先级比赋值=更高 可以放入操作符栈接下来数据0放入数据栈然后是?: 三元操作符它的优先级比=更低不能直接入操作符栈需要先把0和i数据出栈==操作符出栈 比较此时结果是true 入数据栈三元操作符入栈那此时数据栈中还剩a和true操作符栈中还剩=和?:此时true和?:出栈那么结果是2+3表达式将2 入数据栈+优先级比=高 入栈3入数据栈此时整个表达式就结束了把2、3和+出栈计算将5入栈此时数据栈还剩5和a操作符栈是=把a地址中的值由1变成5此时数据和操作的缓存栈都是空的整个表达式求解完了接下来看代码实际怎么实现的这个过程
parser 词法分析含义将源代码中人类可读但计算机不可读的字符对它做一些基本的分析告诉计算机这些字符是什么需要识别的内容关键字首先识别跟语言相关的关键字比如 if、else、while每个语言都有每个语言的特色包括语言的类型系统 会识别类型关键字int、char、char*(char封装的指针)还比如goto关键字定义的变量、函数比如 int a变量,int add(int a,int b)函数字面量比如 a=3 a如果是整数 那么3就是一个int的字面量或叫number字面量比如字符串字面量 printf("hello world")操作符+-*/(=>{[停用词没有意义 实际不需要解析 直接跳过的比如注释符号、空格、换行那编译器如何识别上述内容呢?词法解析里面唯一的方法 tokenize这个方法会去读源码的字符这个方法做分词分词完了之后 输出它是什么类别、在类别中具体的内容它的返回值叫token和token value这个方法的返回值类型是void通过全局变量来定义token和token value通过修改全局变量来告诉parser的其他部分读到的源码字符串是什么类别、具体内容是什么parser接下来就可以做词法分析 比如生成相应的vm指令如果解析出来的变量或字符 发现是个函数的时候这个变量或函数 会有一个声明或定义的地方也会有一个使用的地方需要保证声明和定义需要在使用之前在声明和定义的地方就已经可以对这个变量和函数做出很充分的解析知道它是什么类别 也知道它各种的属性解析完之后 需要把这些内容存放在一个地方即某个table中在使用的时候 比如使用add函数 就需要去table中找到这个函数读出相应的属性 对它做语法的分析和代码的生成都需要定义过程中解析出来的各种属性存中间变量的地方是symbol table使用场景中再遇到这个符号的时候 能读到在声明定义的过程中已经解析好的属性值symbol table(符号表不属于词法分析而属于语法分析)有哪些内容token只有在变量和函数的时候才需要使用symbol table其他情况不需要symbol table 比如乘号*关键字也需要特殊处理下 因为关键字也可能是很长的字符串在关键字keyword之外就是变量和函数所以token分为2类 一类是keyword 一类是idkeyword的token值就是keyword具体的值如果是程序员自己定义的变量或字符串把它的token统一定义成id(identitier)hashname字符串压缩成的数字只是为了后续加速查找的过程直接比较hash值,hash值相同的情况下取name这是一个优化的实现如果是用户定义的变量或函数的话比如add是一个函数function还是变量(局部变量还是全局变量)如果是关键字的话 也有一种特殊情况 比如是printf(属于system call)还有可能是number 比如用了枚举 enum {A,B,C} ABC看起来是符号但实际上它表示的是A是0,B是1,C是2如果是number或变量的话 value值可能是具体的数值 比如0,1如果不是number类型或char类型 它可能是指针类型比如430430地址假设函数中的全局变量装的是字面量 比如hello world字面量是装在data区的那么value里面装的就是data区的地址比如230258如果是一个函数的话 可能是一个函数地址pc最终会跳转到这个函数地址 比如是code区的地址123123所以value是什么 取决于class是什么 也取决于type是什么还有3个属性 Gclass、Gtype、GValue这就涉及到c语言的feature 局部变量的遮蔽比如有一个全部变量 char* a又有一个函数 里面有一个局部变量 int a那么在解析这段函数的时候 需要知道函数中的a是局部变量a而不是全局变量 achar * a //行1 function(){ int a //行2 }在执行函数之前 已经解析过行1了a已经有了属性对应的class、type、value了token、hash、name可能是一样的但class、type、value属性是不一样的在解析函数的时候 需要把之前全局解析出来的属性暂存到G开头的这3个属性中 Gclass、Gtype、Gvalue在解析局部变量a这个属性值赋值到class、type、vlaue中等函数全部解析完了,退出这个函数的时候 需要把这个全局遮蔽的全局属性又得写回至目前它的属性里函数之外的a还是原来全局的那个a通过暂存全局属性的方式来实现局部遮蔽的featrue依照符号表来看下tokenize的代码实现(词法分析最核心的逻辑)首先从源码中把字符取出来然后字符指针往后移src++如果发现是一个换行符会把全局变量line++这个是为了debug用的 没什么实际的含义就是当debug报错信息的时候 源代码多少行有什么样的语法错误只要遇到宏就完全忽略然后就是处理符号identifierc语言里面是有规定的由大写字母小写字母下划线和数字组成以这种字符开头的就知道它是一个符号处理符号的过程 就会计算它的hash值因为它已经是个符号了 那么token一定是id所以先用token来暂存下它的hash值然后通过while循环读取它的hash值和name然后就知道了这么一个identifier然后去symbol table中暂存的所有的identifier对比看下 是否是以前已经解析过的一个个的找 先比较hash再比较名字如果hash不同就直接跳过了如果找到了就把找到后的结果直接返回就行了在真正对代码做parser之前 会把所有的keyword全部调用一遍tokenize也就是说在调用parser方法 已经把所有的keyword解析完了存进了symbol table里面了所以这个时候遇到了keyword一定能够在symbol table中找到找到了就直接返回了如果是一个没有遇到过的用户写的变量或方法就新增一个符号 这个符号的名字就是它的字符串它的hash就是刚刚算出来的hash它的token就是这个id这个时候为什么不解析它的class、type、value呢因为这个时候还不知道比如看到了一个int a刚读完a后面无论遇到一个分号还是括号 这个时候已经停下来了但不知道它到底是个什么如果是括号 它可能是个函数如果是个分号的话 那有可能是个变量这个时候仅仅知道它是a那a具体是什么呢 这是在语法解析的时候需要关心的解析完了identifier之后 需要解析num如果它是0-9开头的那就是一个num这个num有可能是十进制、十六进制、八进制的num的值可以直接算出来即token_val如果是双引号开头或是单引号开头那它就是一个字符串或字符如果是字符串的话 就把字符串全部读取出来存到data区 ,data区开头的地址就是token value如果是一个字符呢比如A 它的token value就是它本身即A这个字符的ascii码然后还剩下operator先处理下特殊符号/除号,如果2个除号一起//就是注释符如果是注释符就把内容忽略掉再比如++如果一个+它就是add如果是连续的两个加那它就是自增以上就是词法分析中最核心的逻辑先从identifier开头再解析字面量里的数字、string和char最后解析运算符举一个简单的例子 来讲解整个词法分析的过程首先会提前把关键字处理掉 存到symbol table中去比如这里是char(枚举的变量)、int、return对应的name就是 "char" "int" "return"由于这3个关键字不是系统函数所以这些属性class、type、value是不关心的然后是真正解析它的内容首先遇到一个*号后面在文法分析的时候会发现它是一个指针类型但在目前的词法分析的时候 直接返回token就是一个*号然后是空格是忽略的然后发现一个identifier 小写字母a后面发现是一个分号 那么这个identifier长度是1在后面的文法分析的时候 会发现它的class是一个全局变量它的类型是一个char型的指针value值还不知道 还没有用到 还未给它赋值然后再解析int 发现它是一个关键字然后空格跳过然后是add 一个新的id长度是3位 一直到括号才能终结查询的过程并且add在字符表中没有 所以加入进去它的class类型是一个函数返回值是一个int在边解析的过程中会生成vm指令所以在解析到add的时候该生成的code指令都已经在逐步生成过程中了所以pc就已经移动到了add代码的位置了后续就是add代码的具体内容了所以add的函数地址就是生成code的过程中pc的地址然后解析 又发现一个a在语法分析里面 可以在符号表中找到一个a了需要对符号表中的a做个遮蔽也就是把符号表中的a的class、type、value全部存在Gclass、Gtype、Gvalue中然后会把目前分析出来的值放入class、type、vlaue中首先class是一个局部变量type是intvlaue还不知道当解析完这个函数的时候 会把Gclass、Gtype、Gvalue还原回去然后会找到一个b 它也是identifier对应的name是 "b"它也是一个局部变量class类型也是int值也是不知道的ret也是一样的符号也会正常解析比如;、()但不会进入符号表在语法分析解析完之后 会得到一个符号表文法分析与递归下降概念• 语法 : 单词怎么组合成一个句子语法最小单元是单词每个单词会有不同的形态比如run ran running其实它们的词素lexcme是一样的在分析一门语言的时候首先要定位到这个语言的词素是什么然后看这些词素之间是怎么组成一个句子的一个组织的规则就是语法syntax有些分词器比如lexer会把词素提取出来一个句子里面有哪些词素 比如 I am ok词素有I,am,oktoken和lexcme(词素)区别int a;int b;a和b是两个不同的词素是同一类别的词素 这一类的词素就叫tokena和int是不同类的词素这种由程序员自定义的变量名和方法名 叫identifier 简称id它其实就是一个token 代表好多不同的词素在syntax词的层面 它们的作用是相同的所以把它们理解为相同的tokentoken本身的含义就是代号的意思在做语法分析的时候真正关心的是token 而不是词素i++;这个完全符合语法意思是让i这个值 自增加1这个里面有多少种token呢?i是id,2个加号也是一个token,分号也是一个token由三种token组成的句子 符合语法• 语义语法背后实际的含义int i; i=0; i++;这个代码语法没有问题,语义也没有问题,什么情况下语义有问题呢?float i; i=2.15; i++;这个时候i++就有问题了 不能对浮点数做自增计算i++只能对int/char/char*(指针)做计算浮点数指针也是可以的float*相当于指针地址往后移一位所以在语义层面i++是错误的哪怕在语法层面符合规范• 文法 program文法是语法的合集 比如s=as | bs' s'=a | 空符|表示或的意思S表示开始符这个代码的整体就是S要解析这段代码就从S开始后面的每一行叫过程a和b这些小写字母叫终结符是不可再生的可以简单理解为是一个token或词素解析到它这就停了文法把语言中所有的语法规则表示清楚了根据文法表达式可以生成这个语言所有的可能的文本即这个语言所有的可能的文本都可以通过这个文法解析C4(c语言的子集)文法enum {A,B,C}; char * a; int add(int a,int b){ int ret; ret=a+b; return ret; } int main(){ int tmp; tmp=add(3,4); return 0; }在语义层面必须要有main方法 但语法层面是无所谓的根据这段代码来推导下它的文法是怎样的program= {var decl} | {func decl}全局变量的声明或函数的声明 也可能是多个用{}表示一个或多个这2个是没有带终结符的如果直接通过这种方式调用的话 会有一些问题需要用带终结符的定义替换掉 不带终结符的定义让每一个规则都以终结符开头这样每一层解析都至少会解析掉一部分的token剩下的代码会越来越少 这样的话 递归会有一个尽头上面代码中变量有2种情况 一个是枚举一个是普通的变量的声明上面代码中没有结构体 没有数组 都是通过指针的方式去实现var_decl = enum decl|type Id;这个类型type不管是普通类型还是指针类型这个是变量所有的可能性func_decl= type Id(param){ statement }param=...; statement=...;所有函数都有返回类型、函数名、参数..enum decl=enum[Id]{ Id[,Id] }可能有Id即enum可能有个名字 {}里面可能有一个Id或多个Id使用递归下降 根据文法解析代码parse_S(token){ if token == 'a' { parse_S(token++); }else if token == 'b' { parse_S'(token++); }else{ print(sytax_error); } } parse_S'(token){ if token == 'a' { succ; }else if token == 'end of file' { succ; }else{ print(sytax_error); } }在词法分析的基础上把文法分析出来了拿到了token 看是否符合S的解析规则如果符合的话 就逐步解析剩下的部分剩下的部分也可能是一个S 那就递归的调用这个S的解析方法去解析剩下的部分由于每一层解析都至少调了一个终结符每次都会处理调一个token那么剩下的token会越来越少即它的解析是有终点的如果通过递归下降的方法 去解析一个代码需要把文法里的每一个规则 都要写一个对应的parse_xx的函数 把代码逐行解析每一个token 直到EOF递归下降的文法program = {global_decl}program是一个开始符 它的定义是很多个全局定义global_decl=var_decl | func_decl全局定义有可能是变量的定义也可能是函数的定义var_decl = type[* ] Id [','[*] Id]';'|enum变量的定义可能包含这些;func_func = type[* ] Id (param_decl) '{' { statement} '}'param_decl = type[* ] Id [',' type ['*'] Id]statement = if_stmt | while_stmt | return_stmt | empty_stmt | normal_stmtnormal_stmt = experssion ';'type = char | IntId = ...experssions = ...最外层的parse须直接解析到enum或tpye开头的然后再逐层解析如果是var_decl就解析变量相关的如果是func_decl就解析函数相关的解析func_decl的过程中也需要解析param和statementstatement又需要解析到表达式expressions递归下降的终点就是解析到表达式但实际上没有使用递归下降来解析的 因为它的文法比较复杂所以使用了C4的一个方法 叫优先级爬山为了在递归下降的过程中解析到表达式停止需要有一个方法叫 parse_express(){}当递归下降发现后面是一个表达式的时候调用parse_express去解析这个表达式这个函数怎么实现的 递归下降不需要关心的相当于把表达式当成了终结符对于递归下降来讲 它遇到终结符就停止了也相当于遇到表达式就停止了表达式通过parse_express这个函数单独实现这个函数使用了优先级爬上的算法
add栈中保存了返回地址参数一般放在main栈的最底下 也可以定义在add栈里面main栈的栈基base point 即bp局部变量ret最后就是栈顶当add调用结束之后 直接回到bp位置所有的局部变量就不需要了然后把add栈中的bp放到main栈中的bp中去返回值给pc然后代码区就会有一个跳转这就是一个函数调用的过程通过一个后进先出的一个空间的抽象极大的简化不同的函数栈之间的内存的关系但不是一个非有不可的概念 只是这个概念大大简化了维护成本是否一定要有函数调用呢有一个Brain-Fuck语言 <>+=[],.整个代码里面全都有这8种符号组成 可读性为0读这个代码需要你的大脑很痛苦的运算所以叫Brain-Fuck这种语言其实就是假设有2个纸带第一条纸带 装语言本身就是代码第二条纸带 装数据的有个探针只在这个数据的某个起始位置小于号就是探针左移一格大于号就是探针右移动一格加号就是探针所指的这个数据的地方默认值是0 加号就是把它加1变成1了减号就是减1变成0了左括号就是代码区会根据一个条件去判断是否要跳转条件是你现在这个探针所指的位置是否等于0如果不等于0 就直接执行如果等于0 就跳转到对应的右括号右括号的意思是当指针所指的位置数据等于0它就直接继续执行如果不等于0 就跳到对应的左括号左括号相当于是定义的虚拟机指令的JZ右括号相当于JNZ逗号就是从IO设备输入一个数句号就是输出一个数如果基于这样简单的语法 如何实现两数相加首先逗号输入一个数 探针在初始位置就是data区的第一个位置比如输入的是3探针右移然后又输入一个数 比如是4然后一个左括号判断探针所指的这个值是否为0不为0就不跳转就进行下一位下一位就是左移 探针又移回了给它加1 ,3就变成了4加1之后 探针右移右移之后下一个代码是减4-1=3此时探针在3的位置 然后判断它是否为0不为0 就跳转到对应的左括号然后又继续执行左移左移之后又继续加 4变成5右移就是减 3变成2又是循环跳转 直到减为了0为0了之后 就不跳转了(JNZ)执行下一个代码 EOF即结束了最终的效果就是把4加到了3上面得到结果是7这就是brain fuck语言 怎么实现一个加法的过程这种语言是目前最接近图灵机的一种语言所以没有函数调用也能完成这些复杂的运算通过函数调用可以简化但在brain fuck里面没有stack概念 如果想实现函数调用也是相当复杂的如果多了一个纸带 那么就很容易实现函数调用有了函数调用之后 整个主要逻辑的编码就会变得非常简单所以就明白了 为什么要有代码区、数据区、stack区 这样的分区设计c、执行完了之后 需要知道return结果的返回值计算完之后 把结果存在某个寄存器中 比如约定存在通用寄存器ax中再回到调用处的时候ax里面的值就是被调用函数的返回值然后把ax赋值到retd、返回地址函数调用(跳转)相关指令CALLRETURNNVAR:new statck frame for variableDARG:delete statck frame for argument还是以上面 main调用add的方法举例首先main在调用之前需要准备好两个参数假设这里是stack区 从大到小前面的main栈先不管了但接下来的2个地方用来存放2个参数假设一个是3一个是4接下来调用add方法了代码区的情况main代码区和add代码区是code区中连续的代码区 为了方便说明 分开画的add代码区的地址是430430main代码区就是Call 430430call完之后 会对这个地方做一个清理 DARG2假设call位置的地址是430420参数也会占用一个地址 430424需要返回的这个地址是430428执行完add方法之后 需要把pc寄存器返回到430428执行下一条代码pc就会等于430430这个地址里面的数然后把sp下移了一位结果存到了430428这个地址把返回地址存进去了执行完add之后 要返回的时候通过这个地方就能知道要返回到哪了接下来看看add函数中的vm指令首先需要做nvar 给函数的局部变量申请stack frame的一些初始空间这个初始空间首先要存bp地址存的是老bp地址因为一旦跳转了之后 进入这个新的栈 那这个新的位置就是bp了老bp其实就是main的bp也得存进来因为回头要恢复这个栈的原貌
指令集save&loadIMM全称load immidiatily立即加载数据到寄存器LEAload effective address加载地址LC/LI/SC/SIload char/load int:将char和int加载到寄存器save char/save int:将char和int从寄存器加载到内存PUSH将寄存器的数据推到栈顶stack peek举例ax是通用寄存器pc是代码区指针program counter指向当前正在执行的指令这些指令背后的直接操作数也会存在代码区比如IMM 2 把2这个数字这个常量(字面量)加载到ax寄存器解析完这个指令放入code区当读到IMM之后 PC就会下移指向2的位置代码中的op变量就会装刚刚读到的指令IMMax =(dereference)实际地址所在的内容即2把它放在ax寄存器中会有一个后置的pc++操作即继续移到下一步LEA指令在x86里的作用,举例说明:有一个寄存器bx 里面装了一个内存的地址bx=430430想算这个地址往后移8位的地址bx+8的地址存入ax中如果没有LEA的话 需要这么实现:ADD bx 8 mov bx ax 然后再把bx数据转移动ax这样操作会有一个问题:1、改变了bx原本的值2、加法操作有可能会导致溢出有可能相加的结果会大于32位的值又会改变CF OF这些标识位的数据这样会对后续的计算带来更多的复杂性所以通过LEA这样的操作来简化LEA ax (bx+8)在我们的实现下LEA 它不是基于任何地方去算地址的因为实现也没有必要主要是想基于栈里面去计算地址最多是用来读取函数的参数和局部变量的参数它们都是会基于栈的bp所在的位置所以我们的LEA是基于bp来加减可能会得到这个函数的第几个参数或第几个局部变量比如LEA-1 就是取bp在栈里面的位置 减1其实就是加1 因为栈是从大到小的 减1其实就是bp的下一个位置 这个位置可能就是这个函数的第一个局部变量 比如它是35 把这个位置的地址拿出来 取里面的值就是35LEA其实就是基于bp的地址来计算它的相对位置继续分析代码接下来就是load操作ax本身装了一个地址把这个地址所对应的内存的值加载到ax中去即ax=*ax由于不同的类型 则会使用不同的指针来做强制转换sc就会把数据存在栈顶 sp指向栈顶 里面存了一个地址把这个地址转换成相应的指针比方说char指针(char*)然后对这个地址取deference就拿到了这个地址所在的空间把这个ax的数据存在这个空间里用完栈顶之后sp会加加栈就会往回退一格即这个栈用完了 就回到上一位假设这个栈里存的地址是430430假设说它是data区的一个位置sp指向这个地址 先找到430430把它取出来 转化成一个int的指针指向了data区的一个位置再对它取一个dereference 那就拿到了这块空间假设ax=1 这个时候就会把430430这个地址的数据设置为1就完成了把ax里面的数据存到了内存空间的data区的430430这个位置再继续看代码push指令 比如ax里面load了一个数据2,将该数据压栈:sp指针先-- 再把数据2放入栈中运算相关指令算数运算四则运算ADD/SUB/MUL/DIVMOD取模位运算OR 或XOR 异或AND 与SHL/SHR左移/右移逻辑运算EQ相等NQ不等LT/LE/GT/GE小于/小于等于/大于/大于等于分支跳转指令JMP相关指令JMP 将pc指针跳到一个指定的代码区域假设430430是代码区的某个地址jmp 430430 ,pc指针就会直接移动到430430的地址处 跳过中间的指令JZ/JNZ 基于寄存器当前的值去判断是否要jump以及jump到什么位置JZ 就是判断 ax是否等于0 如果等于0 就做JMP 如果不等于0 接着执行下一条指令 pc就直接加1了JNX就相反举例说明while(a>b){ ... }这样一个while循环 怎么用最简单的vm命令实现?假设已经实现了a>b表达式的值 要么是0要么是1 并且将结果保存在ax中在代码区定义2个位置 一个是loop point,一个是end point然后JZ指令判断 ax是否等于0 如果等于0的话 就JMP到end point如果不等于0 直接执行下一条语句循环体代码执行完了 要循环 回到开始的位置 有一个JMP loop point再举一个例子if(a>b){ ... }else{ ... }在代码区有3个锚点 true point,false point,end point如果是true 则从true point执行如果是false 则从false point执行true执行到false point位置 也需要跳过false point后面的指令跳转到end point首先需要计算a>b的值存到ax中去当为true的时候 直接执行 不需要跳转当不为true的时候 即JZ false point 跳转到false point执行完就结束了如果执行ture的逻辑 执行完了之后 走到 JMP end point直接跳转到end point然后执行if之后的语句有了while和if这两个分支判断指令 不涉及函数的情况下 可以实现图灵完备的计算了为什么要有statck(code和data空间)假如没有栈 如何实现函数调用int add(int a,int b){ int ret; ret = a+b; reuturn ret; } int main(){ int a=3; int b=4; int ret=add(a,b); return 0; }main函数调用add函数 代码从main函数开始执行需要知道这几个信息a、要调用的函数的地址函数在翻译成汇编的时候 在代码区的位置b、在执行的过程中需要知道传递的参数的值add函数内的局部变量和参数值在函数执行完后就没用了 然后找到返回地址 返回到调用处就可以了关键是在函数执行的过程中要把局部变量a、b和返回地址存起来add函数在data区先存3再存4再存返回地址(比如是代码区的430430位置)函数调用结束之后 把这个data区删除 把430430地址数据清空data区在不同的地方就会有不同函数的空间得有一个统一的地方去管理这些空间这个地方逻辑上的概念可以定义成一个hash散列表能够让每次函数调用的时候 能够快速的定位到一个地方比如说add方法调用完了 我要回到main方法的时候mian的这些参数值返回值等得恢复得找到这个空间 去hash散列表中查这种方案也是可以的 但会导致data区的维护很复杂计算机中任何复杂的东西都可以通过增加一个中间层或者说抽象层来解决函数在调用的过程中 最后调用的也就是最近调用的正在执行的这个函数是最先释放的我执行完了 就回到调用我的地方是一个后进先出的过程那么就会想到stack所以一般会用stack去描述函数的局部空间
编译器1、编译器定义将高级别语言翻译成更底层的机器可执行的语言2、工业级编译器的编译过程编译过程分前端和后端两个阶段2-1 前端前端即parser:将源代码翻译成中间代码,以便给后端程序进一步处理parser过程分两个步骤词法分析即tokenize词法分析的目标是把人类语言简单处理一下告诉计算机这些词都是什么含义比如把int单词识别出来告诉计算机是整型;add识别出来告诉计算机是一个函数语法分析通过词法分析计算机已经知道每个词是什么意思 通过语法分析可以将词语组成一个完整的句子语法分析的目标就是按照语法(蕴含着逻辑和运算)生成中间代码,这个中间代码就是AST(抽象语法树)用逆波兰式来表示:+(+(*32)4)6有些语言比如lisp这是函数式语言,本身的语言的原始状态就非常类似于逆波兰式,这种语言会很轻易的得到抽象语法树高级语言比如java、c想要得到最终语法树想要经历上述2个过程2-2后端后端是编译最核心的关节分为2个组件可选的optimizer优化器(编译器最难最核心的组件)输入中间代码经历一系列的优化过程又得到了同一种的中间代码它做了一些优化工作比如剔除不影响结果的没用的代码(死代码剔除)、转换一些逻辑(函数内联)、做一些替换(常量替换)得到一个更利于执行的性能更高的代码Code Generator即CodeGen代码生成器生成最终目标平台(目标机器)的可执行代码的生成器将中间代码翻译成目标代码比如翻译成x86平台的汇编:movq、push、addjava虚拟记jvm:load、store、addllvm(LR)语言给各个平台 比如x86平台、amd平台、arm架构等都实现了上述两个过程想要做一个可用的编译器的话 最简单的方式就是写个parser把目标的语言翻译成llvm(LR) 选定想要的平台生成最终的机器代码这个语言不仅用在编译器领域,也用在机器学习、数据库优化等领域自定义C语言编译器设计思路自定义c语言编译器的设计思路前后端合一,没有中间优化过程目标代码基于自定义的VM即不是x86也不是jvm目标是为了简单,这个自定义的虚拟机麻雀虽小但五脏俱全编译过程是one pass的过程即读到源码,一行一行的去编译和分析当把所有代码全部pass完了之后,目标代码就已经生成好了,就不需要再读源码了源码只读一遍就可以完成parse和codeGen的过程得到想要的自定义虚拟机的机器码但是有一些c语言的特性是不支持的 但也无伤大雅比如说函数的局部变量必须得一定在函数的最开头的位置int add(int a,int b){ int ret; int tmp; tmp=a+b; ret=tmp; return ret; } ret和tmp这两个局部变量 其实也可以定义在a+b后面 但因为是one-pass过程 所以必须定义在add函数最开始的位置自制VM设计概要设计1 计算是基于集群器Register和stack两个组件来做的这样做的目的是为了简化比如有个两元计算加法操作:两个数相加a+b如果用传统的x86的物理机的架构它的运算单元在cpu内部能够处理的数据在cpu内部的寄存器 比如ax、bx这些寄存器能够做运算把结果输出到寄存器不能基于栈做运算因为栈在内存里内存的读取速度跟计算cpu的速度相差100倍以上所以在实际的物理机中要计算a+b需要把数据从内存加载到寄存器运算完之后再写回寄存器如果要存储这个资源 还需要再写回内存这里为了简化设计 只有一个通用的寄存器一元计算直接基于这个寄存器二元计算基于栈顶(stack peek)去跟通用寄存器一起去计算所以它的ALU(算数逻辑单元)是基于stack和ax这两个位置运行的2 pc寄存器 program counter指定目前运行到哪条指令了下条指令就是pc+1保持代码按既有的顺序去执行有2个指针寄存器一个是stack pointer(sp 维护栈顶)一个是base pointer(bp 维护上一个栈的栈顶:如果这个栈要回去的时候能找到上一个栈在什么地方 一个函数调用起一个新栈 执行完函数要回到函数之前的位置 代码是可以回去了 栈的内容也得回到原来的位置)3 内存空间vm指令编译好之后 得有一个内存空间来存放存放栈的空间还有静态数据、字面量在编译的过程中存在data的内存空间里比如32位地址空间的程序有4G的虚拟内存会分为这几块比如 4+3*2+6 做成的AST:最顶上是内核代码 做系统调用的话 会读到内核的代码在这里设计VM的时候没有用到堆因为并不想主动去实现动态内存动态内存可以通过一种作弊的方式去实现即Native-Call4 指令集包括3块4-1一个是save and load这一类的指令内存到寄存器的操作叫做load寄存器写回内存叫save这一类的内存和寄存器的指令都叫save and load4-2第二个就是运算类的指令算术运算:四则运算位运算逻辑运算:与 或 非4-3第三个就是分支跳转类指令语言实现判断、循环、函数跳转4-4为了简化实现 做了一些作弊类的指令Native-Call它主要处理IO(print、open、write、read文件)和动态内存(malloc、free、memset)的操作正常的平台是不需要Native-Call的 通过前3类指令可以实现但比较复杂 这里为了简化实现 做了作弊类的指令大致了解VM的运行原理有一段代码 helloworld编译完之后 代码区存放指令data存放字面量、helloworld的ascii码栈是从大到小的 栈刚编译完的时候 sp、bp都指向初始位置max位置data和code是从小到大的 初始位置是处于0的位置ax寄存器是一个空的状态pc寄存器开始在0的位置 执行第一行代码pc+1把指令读出来看看是什么指令 做相应的操作看是去data区取数据呢还是栈的指针向下减呢还是把栈里的数据加载到寄存器呢还是对寄存器和栈里的数据做一个计算呢根据指令的不同就会有不同的行为结果当把所有的指令执行完 退出当然也会有跳转 pc可能跳转到其他位置了最终会把所有的指令(代码)执行完栈指针也会通过函数的不断调用加加减减最终回到开始位置data里面的数据也都使用好了最终的结果保存在ax寄存器中了代码就结束了
安装kuboardhttps://gitee.com/pingfanrenbiji/gitlab/blob/master/kuboard-v3.yaml下载镜像并上传到本地仓库docker pull eipwork/etcd-host:3.4.16-1 docker pull eipwork/kuboard:v3 docker tag eipwork/etcd-host:3.4.16-1 127.0.0.1:5000/eipwork/etcd-host:3.4.16-1 docker push 127.0.0.1:5000/eipwork/etcd-host:3.4.16-1 docker tag eipwork/kuboard:v3 127.0.0.1:5000/eipwork/kuboard:v3 docker push 127.0.0.1:5000/eipwork/kuboard:v3启动kuboardkubectl apply -f kuboard-v3.yaml查看启动结果kubectl get pod -n kuboard导入现有的k8s集群查看k8s集群配置cat ~/.kube/config查看集群所在node的ipkubectl get node kubectl describe node docker-desktop|grep InternalIP
可以看到集群中有3个节点在你的项目中配置该nacos地址启动成功之后 就会在界面上显示出来这个服务K8S部署nginx并启动2个web服务(比如一个是pc端应用web,一个是移动端应用h5)git clone https://gitee.com/pingfanrenbiji/k8s-nginx.gitnginx目录是映射到宿主机上的文件(配置文件、日志文件)h5.confa、定义了h5应用nginx的访问日志文件路径和错误日志文件路径b、location / 定义了访问路径 访问根目录即访问容器中/etc/nginx/html目录下的index.html文件c、localtion /xibaoxiao-api/ 定义了如果h5应用访问后端接口地址中包含/xibaoxiao-api/ 则命中这一规则 转发到http://172.16.0.114:8092/中比如h5访问的后端接口是http://127.0.0.1:30001/xibaoxiao-api/bwy/user/getbyid首先30001是nginx的端口nginx发现请求路径中包含/xibaoxiao-api/则命中了localtion /xibaoxiao-api/规则然后转发给真实的后端服务http://172.16.0.114:8092/bwy/user/getbyidd、这里没有指定listen端口 则继承nginx.conf中定义的默认端口80web.confa、这里指定了一个端口因为web和h5是两个独立的应用 希望用2个不同的端口来访问b、制定了web的访问日志和错误日志文件路径c、如果访问9000端口的跟路径/ 即是访问/etc/nginx/web中的index.html文件nginx-dep.yaml是部署pod的脚本a、将宿主机上的2个应用(web和h5)的静态资源分别映射到/etc/nginx/web和/etc/nginx/htmlb、将宿主机上的配置文件映射到容器中的指定路径c、将容器中的日志文件映射到宿主机上d、3个副本nginx-svc.yaml是部署service的脚本配置80端口和9000端口对应的集群外部访问端口deploy目录是待部署的前端静态资源查看nginx启动情况访问页面访问h5 http://localhost:30081/访问web http://localhost:30082/查看节点上k8s资源的使用情况(比如查看nacos集群)kubectl get node每个pod分别申请了512M的内存使用了8%的内存 0.5个CPU使用了12%
本文介绍下"代码提交自动部署到云原生并实时查看服务的运行状态"运行环境的搭建过程k8s方式安装gitlab下载gitlab yaml文件git clone https://gitee.com/pingfanrenbiji/gitlab安装postgresql、redis、gitlab镜像下载docker pull sameersbn/postgresql:10docker pull sameersbn/redisdocker pull sameersbn/gitlab:11.8.1k8s部署kubectl apply -f .查看启动情况kubectl logs -f gitlab-7cc4bd85ff-459lf -n kube-ops截止目前都启动成功了gitlab的ingress域名是gitlab.demo.com暴露的http.nodePort端口是30003所以可以通过http://gitlab.demo.com:30003访问账号:root/admin321域名配置sudo vim /etc/hosts 172.16.0.114 gitlab.demo.com访问url是http://gitlab.demo.com:30003是否可以直接通过域名访问呢即http://gitlab.demo.com那么就需要通过nginx做下代理转发docker run --name=nginx --volume=/opt/docker/nginx03/html:/usr/share/nginx/html --volume=/opt/docker/nginxmengfaniaodeMBP:nginx03 mengfanxiao$ cat conf/conf.d/gitlab.conf server{ server_name gitlab.demo.com ; access_log /var/log/nginx/gitlab.access.log main; error_log /var/log/nginx/gitlab.error.log notice; location / { proxy_pass http://gitlab.demo.com:30003/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }这样就可以通过http://gitlab.demo.com/域名直接访问了查看gitlab-runner所需的配置信息gitlab地址 http://gitlab.demo.com/token BZncyg6pxxN6ugtKzFnm二进制方式部署gitlab-runner官方部署文档https://docs.gitlab.com/runner/install/osx.html我是在本地mac环境部署的 其他操作系统请自行选择安装版本# 下载二进制文件 sudo curl --output /usr/local/bin/gitlab-runner "https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64" # 设置执行权限 sudo chmod +x /usr/local/bin/gitlab-runner # 启动 gitlab-runner install gitlab-runner start查看启动情况这里需要注意 如果gitlab地址写成http://gitlab.demo.com/那么就说明 gitlab-runner访问gitlab是通过nginx访问的这里建议直接访问gitlab不经过nginx即gitlab地址写成http://gitlab.demo.com:30003/注册成功之后就可以在gitlab上看到gitlab-runner这个执行器了创建新的仓库比较简单具体就不演示了这里需要说一点就是本地代码可以直接上传到gitlab而不经过nginx因为nginx对于http请求的限制可能会影响代码的提交# 在项目跟目录下 git config -e安装docker镜像仓库docker run --name=registry --volume=/opt/docker/registry:/var/lib/registry -p 5000:5000 -d --restart=always registry编写gitlab-ci.yaml文件https://gitee.com/pingfanrenbiji/gitlab/blob/master/.gitlab-ci.yml配置maven环境变量
这里补充一个git的小知识点无论是gitlub还是gitee都有每次push的文件大小限制 最大是100MB如果超过了100MB就需要将这个文件删除然后再提交根据提示查看大文件git rev-list --objects --all | grep dc6b92c5b0080cdb55f54b39812d0bb56540e131从这个提交记录中删除该文件在工作区的顶级目录中运行这个命令git filter-branch -f --prune-empty --index-filter 'git rm -rf --cached --ignore-unmatch build/target/nacos-server-2.0.3.tar.gz' --tag-name-filter cat -- --all然后再强制(在确保不影响其他人代码的情况)推送即可git push origin 2.0.3:2.0.3 --forceK8S部署git clone -b 2.0.3 https://gitee.com/pingfanrenbiji/nacos-k8s先部署mysqlkubectl create -f ./deploy/mysql/mysql-local.yamlyaml文件这里简单介绍下标红的几处mysql版本号这里使用的mysql是5.7版本可以提前把镜像下载好数据文件映射到宿主机通过name对应起来volumeMounts-mountPath 这个标签是容器内部的路径volumes-hostPath 这个是宿主机的文件路径mysql端口通过NodePort暴露出来evn定义了数据库信息root账号对应的密码是rootnacos账号对应的密码是nacos数据库名称是nacos_devtest启动好之后 查看pod、servcie的情况kubectl get pod|grep mysql kubectl get svc|grep mysql连接数据库通过navaicat连接数据库部署nacoskubectl create -f ./deploy/nacos/nacos-quick-start.yaml这个文件也简单介绍几点容器镜像换成刚才生成的新镜像1000m表示一个cpu 500m表示0.5个cpu这里设置了512M 如果配置3个副本 则需要512*3大小的内存空间副本数量配置nacos服务列表配置命名规则:pod名称.service名称.命名空间.svc.cluster.local:端口号service暴露NodePort端口访问页面http://nacos-web.nacos-demo.test.com:30008/nacos
逻辑回归是用来解决二分类问题的什么是分类问题这是多种不同的动物以及它们不同的特征每种动物属于不同的种类有一条新的数据 知道它的特征 这些特征都属于特征列 预测这个是哪个种类的动物种类那列是属于目标列这种判断每条数据所属类别的问题属于分类问题二分类问题当分类问题的目标列只有两种情况时属于二分类问题比如把动物类别修改为是否为哺乳类回归和分类的区别不要被逻辑回归的回归二字所欺骗逻辑回归其实是解决的分类问题回归模型的输出是连续的分类模型的输出是离散的举例说明:左图:x轴表示每小时卖出咖啡的数量y轴代表通过卖咖啡所赚的钱y值是连续的值x值都对应一个y值当预测值y值是一个连续的值 称这类问题为回归问题右图:红色部分表示除去成本之后依然不能盈利蓝色部分表示除去成本之后开始盈利这个图表示是否盈利 两种情况所以是一个二分类问题逻辑回归是什么逻辑回归=线性回归+sigmoid函数什么是线性回归用一条直线简单的拟合下自变量和因变量之间的关系怎么把回归变成分类呢比如当这家咖啡店y小于130的时候是亏钱的大于130是盈利的映射到x轴 大于14表示盈利 小于14表示亏钱通过这样一个划分 就把回归问题变成了一个分类问题什么是sigmoid函数呢解决的是怎么把回归问题转换成分类问题的转换函数当输入值趋向于无穷小的时候函数值越逼近于0当输入值趋向于无穷大的时候函数值越逼近于1当输入值是0的时候 函数值为0.5线性回归的预测结果y值可能是从负无穷到正无穷的任意的数将结果作为sigmoid函数的输入当y值很小的时候对应的sigmoid函数的值是逼近0的值当y为0的时候作为sigmoid的输入 对应的sigmoid函数值是0.5当y值非常大的时候 对应的sigmoid函数输出值是逼近1的数经过sigmoid函数将回归函数得出的结果变成从0到1之间的某一个数当sigmoid函数小于0.5的时候 会预测为0当sigmoid函数大于0.5的是时候 会预测为1这样就得到了一个二分类的结果公式线性回归: z=w*x+bsigmoid函数:通过损失函数求预测值跟实际值相似程度的一个函数损失函数越小,模型越好代码引入依赖包这是libsvm格式的第一列是label列这是一个是二分类问题 目标列只有0/1后面是特征列每一个值的:前面是第几个特征:后面表示这个特征的值是多少1、先引入数据2、再对数据进行训练集和测试集的划分训练集和测试集都是已知的数据测试集虽然是已知的 拿它和预测结果做对比来评估模型的好坏3、数据划分完之后 进行训练数据4、训练完之后把特征和预测处理的值打印出来了5、打印算法模型的评价结果运行结果SparseVector(4,{0:4.8,1:3.0,2:1.4,3:0.1},0.0)这个数据的意思 这4个特征预测出来的结果是0逻辑回归很重要这是一个神经网络的图 其中的每一个节点都是一个逻辑回归
承接上文 插入排序和二分法局部最小值问题在数组中 无序 任何2个相邻的数不相等 局部最小定义 如果0位置上的数小于1位置上的数 那么0位置就是局部最小的位置 如果N-1位置上的数 小于 N-2位置上的数 那么N-1就是局部最小位置 如果中间位置i即比左边i-1位置小同时也比i+1位置小 那么i位置就是局部最小 i位置必须同时小于左边和右边的数 才叫局部最小 在这样一个数组中 找出一个局部最小即可 能不能求的过程 时间复杂度好于O(N) 最好二分 ?解题先看0位置是不是局部最小 如果0位置是局部最小 就直接返回了 如果0位置不是局部最小 那就是比1位置大 那么在0~1之间是向下的趋势在看下N-1位置 如果N-1位置是局部最小 如果N-1位置是局部最小 就直接返回了 如果不是 则在N-2 ~ N-1 这个范围上是向下的趋势0位置到N-1位置必存在局部最小 因为相邻两个数不相等就一定存在整个数组上值的变化波动曲线 直接取中点位置M如果M位置上的数比M-1位置上的数要小 比M+1位置上的数还要小 这个M就是局部最小值 如果M比M-1位置上的数要大那么在M-1 ~ M之间是向下的趋势 那么0-M范围必存在局部最小什么时候适合二分日常定出一个流程 优化方向就2个方向 1)数据状况 2)问题标准(这个题是局部最小标准) 这个题是2个结合起来 这种数据状况下求解这个问题就存在二分策略 当构建一个排他性的问题 比如左边有 右边没有 就可以使用二分了对数器有一个想要测的方法a 同一个问题可能有很多中策略来实现 如果不管时间复杂度好坏的话 可以写出一个暴力尝试的写法(一个题把所有的排列组合都弄一遍) 但做题的时候有时间限制可能一些方法不过 把这样的不追求时间复杂度优劣 但很好想 很好写的方法叫方法b 比如刚才那个二分的问题 可以用二分的方法找局部最小 也可以用遍历的方式找局部最小 通过线上oj(测试平台)方法进行测试 可能存在没有oj或者测试用例不全(可能存在 没有代码跑出错 也测试通过的情况) 即想测的方法a即使通过了线上oj也不能保证一定正确 这就有了对数器的方法 这是万无一失的方法 生成一个随机样本发生器 可以产生随机样本 在方法a跑一遍 得到结果a 在方法b跑一遍 得到结果b 比如测试几千万次 当发现两个结果不一样 要么方法a错了 要么方法b错了 要么两个方法都错 给一个小样本 比如长度为5的数组 通过人看的方式看看方法a结果和方法b的结果 就可以知道方法a、方法b是否对错 用人工干预的方式把方法a和方法b都修改对 然后把随机样本产生数的长度设置大些,值也更随机些 跑个几千万次 就可以确定方法a对了 这个就比线上oj平台更加可靠了准备一个方法b测试50万次 每一次都会生成一个随机数组(长度也随机、值也随机)Math.random() 表示在[0,1)所有小数 等概率返回一个 这个在数学上是做不到的 在计算机是可以的 因为0-1范围的所有小数在计算机是有穷尽的 精度更好的小数认为不存在了 Math.random() * N 返回[0,N)上的所有小数 等概率返回一个 (int)(Math.random() * N) 转换成整数 就会在[0,N-1]所有整数 等概率返回一个 利用这个机制实现数组长度的随机 对应上图中的第一行代码 for循环里面是2个随机数相减得到的结果也是随机的继续分析主方法每一次都会生成一个随机数组 范围是0~100 值范围是-100~100 得到arr1 然后把arr1拷贝出来一份arr2 让想测的方法a去排序arr1 再用对数器方法(方法b)去排序arr2 isEqual方法表示是否每个位置上的值都一样 一样 true,不一样false 如果有不一样的 则随机范围调小一点 人工干预的方式看看2个排序方法哪个排错了 然后去调试 再跑对数器 直接每一次结果都一样 这种方法需要写2个方法(两套独立思路写出来的东西) 但非常稳 不依赖线上测试平台
插入排序最差是N^ 选择排序和冒泡排序严格是N^ 所以插入排序的算法性能要优于选择排序和冒泡排序 0~0范围是有序的 所以从下标1位置开始 第一个for循环 表示想在0~1范围有序,想在0~2范围有序,想在0~3范围有序... 第二个for循环 比如第i的位置 此时想让0~i位置有序 已经做到了在0~i-1范围上有序了比如第i位置上的数是Y j=i-1 j+1就是i j位置上的数和j+1位置上的数比较 前一个要小了 这个时候要交换 交换完了之后 Y就来到了i-1位置 接下来往左移动再比较j--位置上的数即i-2 j+1位置上的数就是i-1 如果j位置上的数大于j+1位置上的数 还是往左移 j就是当前数的前一个数,当前数在j+1位置所以第二个for循环就是在做: 一直往前看如果一直都小的话就一直往前换 换到越界停或者换到不比左边位置小了停二分法1、在一个有序的数组中,找某个数是否存在如果从左往右遍历找的话 就是一个O(N)的算法 每个数只碰一次 通过二分法找最快 先找到中点位置的数如果x > num 所以x右边一定不会有num 就可以在x左边继续二分 如果x < num 所以x左边是不需要看的 只在x右边二分这个算法的时间复杂度怎么估计?一次砍一半 一共砍几次 时间复杂度是比如数组中一定有8个数 砍一半4个 再砍2个 再砍1个 如果还找不到就是不存在这个数如果找到了就是砍3次就是2、在一个有序数组中,找>=某个数最左侧的位置是否满足大于等于num4是满足的 所以左侧有可能还存在大于等于3的数 所以继续二分假设中点是箭头所指的2次是不满足大于等于num所以右边去二分再找中点位置 上图箭头3比之前记录的t的位置更靠左 所以放弃之前t的位置记 上图箭头3的位置为t满足大与等于3 再往左分也满足 再分就没有数据可分了二分法找一个数存不存在 当找到了 就不需要二分了而找大于等于num最左侧的位置一定是二分到结束的 即二分到某一个范围上没有数才停3、局部最小值问题在数组中 无序 任何2个相邻的数不相等局部最小定义如果0位置上的数小于1位置上的数 那么0位置就是局部最小的位置 如果N-1位置上的数 小于 N-2位置上的数 那么N-1就是局部最小位置 如果中间位置i即比左边i-1位置小同时也比i+1位置小 那么i位置就是局部最小 i位置必须同时小于左边和右边的数 才叫局部最小在这样一个数组中 找出一个局部最小即可能不能求的过程 时间复杂度好于O(N)最好二分 ?这道题目的解析 请看下文讲解哟
常见的负载均衡策略随机、hash、轮询、权重、最小连接数、最快响应速度适用场景1、在短连接中 因为连接快速建立销毁 因为数据延时容易造成堆积效应, 随机、hash、轮询、权重 四种方式大致能够保持整体是均衡的,服务端重启也不会影响整体均衡2、最小连接、最快响应速度是有状态的算法,因为数据延时容易造成堆积效应3、长连接,连接会一直保持,断连后需要重新选择一个新的服务节点,当服务重启后,最终连接数会出现不均衡的情况4、随机、轮询、权重的策略在客户端重连切换时可以使用长连接相对短连接的优势在整体连接稳定时,服务端需要一个rebalance机制,将集群视角的连接数重新洗牌分配,趋向另外一种稳态Nacos负载均衡方案客户端随机选择/基于权重随机+服务端运行时柔性调整1、建立连接:启动时实时连接一次,失败后进入异步重连2、连接切换服务端推送切换异步切换节点,指定IP,只做一次客户端连接尝试,失败后正常切换心跳失败心跳失败时,启动切换请求失败时如果连接状态不合法,启动异步重连服务端重启时连接切换的流程1、服务集群中的Server1要重启,将Server1的连接数上限设置为02、Server1推送给他所服务的客户端,告诉客户端我要下班了3、客户端接收到Server1发送过来的连接重置指令,然后进行节点切换4、Server1进行重启,重启好之后,告诉其他的Server,我来上班了,让集群知道Server1可以正常提供服务了通过控制台监控数据和运维管理1、Server之间的数据共享,包含负载计算数据、变更管理、权重值、节点监控数据、调节策略数据2、通过web ui控制台可以查看到这些监控数据包括基础系统信息和连接数3、通过控制台也可以进行运维操作 比如重启(柔性驱逐)/连接数上限调整/指定节点重置
Nacos1.x版本推送模型的现状分析Nacos1.x版本配置中心推送模型1、配置中心服务端通过MD5一致性对比发现客户端不是最新版本的配置2、所以会与客户端建立短连接通过异步Servlet的方式主动推送数据给客户端缺点因为是短连接 30秒定期创建和销毁 所以GC压力很大服务注册发现中心推送模型服务端与客户端建立Http或UDP连接进行推送数据缺点丢包因UDP存在丢包的情况 所以会有一个UDP的补偿查询云架构下无法反向推送反思改进的方向因配置中心和服务注册发现中心推送通道不一致所以要统一推送通道因http短连接性能压力大所以要使用长链接场景分析客户端和服务端之间连接客户端连接服务端:首先要获取服务端节点列表,然后负载均衡选择一个节点连接;连接断开时需要切换服务端重连客户端发起客户端基于当前可用的长链接发起配置领域或服务发现领域的RPC语意接口通信服务端发起服务端推送配置变更数据或服务变更数据给客户端;失败可重推;有推送ack机制,方便服务端进行metrics和重推判定客户端断开连接感知客户端断开连接,将连接注销,并且清空对应的上下文比如客户端连接注册的服务和订阅的服务服务端之间连接服务端之间需要长连接感知对端存活状态,需要通过长连接汇报服务状态(同步RPC能力);断开连接,需要进行重连;服务端列表发生变更,需要创建新节点的长连接,销毁下线的节点长连接数据同步比如服务注册与发现:服务端之间进行AP Distro数据同步,需要异步RPC带ack能力比如配置中心:配置变更信息同步、当前连接数信息、系统负载信息同步、负载调节信息同步长链接核心诉求功能性诉求连接无论客户端还是服务端都要具有连接生命周期的感知能力 包括连接的建立和连接断开客户端角度1、客户端调用服务端需要支持同步阻塞、异步Future、异步CallBack三种模式2、客户端与服务端断开连接,需要具备底层切换连接的能力3、客户端响应服务端的连接切换的请求4、选址/服务发现服务端角度1、服务主动推送数据,需要客户端返回ack以保证消息的可靠推送,并且可进行失败重试2、服务端主动推送负载调节
Servlet异步处理性能优化的过程3.0版本之前是Thread-Pre-Request模式即每一次Http请求都由某一个线程从头到尾负责处理如果一个请求需要进行IO操作,比如访问数据库、调用第三方服务接口等,那么其所对应的线程将同步地等待IO操作完成, 而IO操作是非常慢的,所以此时的线程并不能及时地释放回线程池以供后续使用,在并发量越来越大的情况下,这将带来严重的性能问题举例说明从HttpServletRequest对象中获得一个AsyncContext对象,该对象构成了异步处理的上下文,Request和Response对象都可从中获取。AsyncContext可以从当前线程传给另外的线程,并在新的线程中完成对请求的处理并返回结果给客户端,初始线程便可以还回给容器线程池以处理更多的请求举例说明老的线程执行start方法start方法向servlet容器申请获取新的线程新的线程执行业务逻辑原先的那个老的线程也会被servelt容器的线程池回收这种方式对性能的改进不大,因为如果新的线程和初始线程共享同一个线程池的话,相当于闲置下了一个线程,但同时又占用了另一个线程手动创建线程手动创建的线程,不能复用因此,一种更好的办法是我们自己维护一个线程池。这个线程池不同于Servlet容器的主线程池用户发起的请求首先交由Servlet容器主线程池中的线程处理,在该线程中,获取到AsyncContext,然后将其交给异步处理线程池可以通过Java提供的Executor框架来创建线程池3.1版本Servlet 3.0对请求的处理虽然是异步的,但是对InputStream和OutputStream的IO操作却依然是阻塞的,对于数据量大的请求体或者返回体,阻塞IO也将导致不必要的等待。因此在Servlet 3.1中引入了非阻塞IO通过在HttpServletRequest和HttpServletResponse中分别添加ReadListener和WriterListener方式,只有在IO数据满足一定条件时(比如数据准备好时),才进行后续的操作上述的第2步 只有在完全读到请求数据的时候才会去申请业务线程上述第5步 只有在完全准备好响应数据的时候才会去触发响应请求举例为ServletInputStream添加了一个ReadListener,并在ReadListener的onAllDataRead()方法中完成了长时处理过程
早期的Nacos一致性协议早期Nacos版本架构服务注册和配置管理一致性协议是分开的,没有下沉到Nacos的内核模块作为通用能力演进,服务发现模块一致性协议的实现和服务注册发现模块的逻辑强耦合在一起,并且充斥着服务注册发现的一些概念。这使得Nacos的服务注册发现模块的逻辑变得复杂且难以维护,耦合了一致性协议层的数据状态,难以做到计算存储彻底分离,以及对计算层的无限水平扩容能力也有一定的影响解决该问题的思路必然需要对Nacos的一致性协议做抽象以及下Nacos架构沉,使其成为Core模块的能力,彻底让服务注册发现模块只充当计算能力,同时为配置模块去外部数据库存储打下了架构基础当前的Nacos架构新架构已经完成了将一致性协议从原先的服务注册发现模块下沉到了内核模块当中,并且尽可能的提供了统一的抽象接口,使得上层的服务注册发现模块以及配置管理模块,不再需要耦合任何一致性语义,解耦抽象分层后,每个模块能快速演进,并且性能和可用性都大幅提升Nacos是如何做到一致性下沉协议的呢一致性协议已经被抽象在了consistency的包中,Nacos对于AP、CP的一致性协议接口使用抽象都在里面,并且在实现具体的一致性协议时,采用了插件可插拔的形式,进一步将一致性协议具体实现逻辑和服务注册发现、配置管理两个模块达到解耦的目的两个计算模块耦合了带状态的接口仅做完一致性协议抽象是不够的,如果只做到这里,那么服务注册发现以及配置管理,还是需要依赖一致性协议的接口,在两个计算模块中耦合了带状态的接口;并且,虽然做了比较高度的一致性协议抽象,服务模块以及配置模块却依然还是要在自己的代码模块中去显示的处理一致性协议的读写请求逻辑,以及需要自己去实现一个对接一致性协议的存储,这其实是不好的,服务发现以及配置模块,更多应该专注于数据的使用以及计算,而非数据怎么存储、怎么保障数据一致性,数据存储以及多节点一致的问题应该交由存储层来保证。为了进一步降低一致性协议出现在服务注册发现以及配置管理两个模块的频次以及尽可能让一致性协议只在内核模块中感知,Nacos这里又做了另一份工作——数据存储抽象数据存储抽象如果利用一致性协议实现一个存储,那么服务模块以及配置模块,就由原来的依赖一致性协议接口转变为了依赖存储接口,而存储接口后面的具体实现,就比一致性协议要丰富得多了,并且服务模块以及配置模块也无需为直接依赖一致性协议而承担多余的编码工作(快照、状态机实现、数据同步)。使得这两个模块可以更加的专注自己的核心逻辑架构进一步演进-Nacos的计算层与存储层彻底分离Nacos自研Distro协议Distro协议是Nacos社区自研的一种AP分布式协议,是面向临时实例设计的一种分布式协议,其保证了在某些Nacos节点宕机后,整个临时实例处理系统依旧可以正常工作。作为一种有状态的中间件应用的内嵌协议,Distro保证了各个Nacos节点对于海量注册请求的统一协调和存储设计思想Nacos每个节点是平等的都可以处理写请求,同时把新数据同步到其他节点每个节点只负责部分数据,定时发送自己负责数据的校验值到其他节点来保持数据一致性每个节点独立处理读请求,及时从本地发出响应Distro协议工作原理数据初始化新加入的Distro节点会进行全量数据拉取 具体操作是轮询所有的Distro节点,通过向其他的机器发送请求拉取全量数据在全量拉取操作完成之后,Nacos的每台机器上都维护了当前的所有注册上来的非持久化实例数据数据校验在Distro集群启动之后,各台机器之间会定期的发送心跳。心跳信息主要为各个机器上的所有数据的元信息(之所以使用元信息,是因为需要保证网络中数据传输的量级维持在一个较低水平)每台机器在固定时间间隔会向其他机器发起一次数据校验请求一旦在数据校验过程中,某台机器发现其他机器上的数据与本地数据不一致,则会发起一次全量拉取请求,将数据补齐写操作对于一个已经启动完成的Distro集群,在一次客户端发起写操作的流程中,当注册非持久化的实例的写请求打到某台Nacos服务器时,Distro集群处理的流程图如下整个步骤包括几个部分(图中从上到下顺序)前置的Filter拦截请求,并根据请求中包含的IP和port信息计算其所属的Distro责任节点,并将该请求转发到所属的Distro责任节点上责任节点上的Controller将写请求进行解析Distro协议定期执行Sync任务,将本机所负责的所有的实例信息同步到其他节点上读操作由于每台机器上都存放了全量数据,因此在每一次读操作中,Distro机器会直接从本地拉取数据。快速响应这种机制保证了Distro协议可以作为一种AP协议,对于读操作都进行及时的响应。在网络分区的情况下,对于所有的读操作也能够正常返回;当网络恢复时,各个Distro节点会把各数据分片的数据进行合并恢复小结Distro协议是Nacos对于临时实例数据开发的一致性协议。其数据存储在缓存中,并且会在启动时进行全量数据同步,并定期进行数据校验在Distro协议的设计思想下,每个Distro节点都可以接收到读写请求。所有的Distro协议的请求场景主要分为三种情况当该节点接收到属于该节点负责的实例的写请求时,直接写入当该节点接收到不属于该节点负责的实例的写请求时,将在集群内部路由,转发给对应的节点,从而完成读写当该节点接收到任何读请求时,都直接在本机查询并返回(因为所有实例都被同步到了每台机器上)Distro协议作为Nacos的内嵌临时实例一致性协议,保证了在分布式环境下每个节点上面的服务信息的状态都能够及时地通知其他节点,可以维持数十万量级服务实例的存储和一致性注本文参考并借鉴<Nacos架构与原理>
Nacos一致性协议Nacos技术架构先简单介绍下Nacos的技术架构 从而对nacos有一个整体的认识 如图Nacos架构分为四层 用户层、应用层、核心层、各种插件 再深入分析下nacos一致性协议的发展过程及原理实现为什么nacos需要一致性协议Nacos是一个需要存储数据的一个组件 为了实现这个目标,就需要在Nacos内部实现数据存储 单机下其实问题不大,简单的内嵌关系型数据库即可 但是集群模式下 就需要考虑如何保障各个节点之间的数据一致性以及数据同步 而要解决这个问题 就不得不引入共识算法 通过算法来保障各个节点之间的数据的一致性为什么Nacos会在单个集群中同时运行CP协议以及AP协议呢从服务注册来看服务之间感知对方服务的当前可正常提供服务的实例信息,必须从服务发现注册中心进行获取,因此对于服务注册发现中心组件的可用性,提出了很高的要求,需要在任何场景下,尽最大可能保证服务注册发现能力可以对外提供服务服务A和服务B往注册中心注册 服务A从注册中心获取服务B的信息 访问服务B 服务B从注册中心获取服务A的信息 访问服务A如果数据丢失的话,是可以通过心跳机制快速弥补数据丢失对于非持久化数据(即需要客户端上报心跳进行服务实例续约)不可以使用强一致性共识算法必须要求集群内超过半数的节点正常 整个集群才可以正常对外提供服务最终一致共识算法最终一致共识算法的话,更多保障服务的可用性,并且能够保证在一定的时间内各个节点之间的数据能够达成一致对于持久化数据强一致性因为所有的数据都是直接使用调用Nacos服务端直接创建,因此需要由Nacos保障数据在各个节点之间的强一致性,故而针对此类型的服务数据,选择了强一致性共识算法来保障数据的一致性nacos集群包括3个节点 分别位于不同的网络分区 服务A往节点1上注册 然后持久化到节点1对应的本地存储上 此时要保证节点1对应的持久化数据必须同步到节点2和节点3上从配置管理来看强一致性在nacos服务端修改了配置 必须同步到集群中的大部分节点即必须要求集群中的大部分节点是强一致性的强制一致性算法的选择Nacos选择了JRaft 是因为JRaft支持多RaftGroup,为Nacos后面的多数据分片带来了可能最终一致性算法的选择最终一致性协议算法 例如Gossip和Eureka内的数据同步算法 而Nacos使用的是阿里自研的Distro算法 该算法集中了Gossip和Eureka同步算法的优势Gossip缺点对于原生的Gossip,由于随机选取发送消息的节点,也就不可避免的存在消息重复发送给同一节点的情况,增加了网络的传输的压力,也给消息节点带来额外的处理负载Distro引入了Server的概念 每个节点负责一部分数据以及将自己的数据同步给其他节点,有效的降低了消息冗余的问题
自动扩缩容需求背景为了处理突发而不可预测的流量增长 Kubernetes 可以监控你的 pod 并在检测到 CPU 使用率或其他度量增长时自动对它们扩容k8s提供的解决方案1、每个节点上的cAdvisor组件收集每pod上的监控数据 2、Heapster聚集每个cAdvisor收集到的监控数据 3、Autoscaler从Heapster得到监控数据 根据计算结果调整副本数量 4、计算出最新的 pod 数量后 它会去更新 Scale 资源的 replicas 字段 剩下的工作就由 Replication 控制器、调度器、kublet 来完成如何计算副本数集群扩容-增加新节点HPA 在需要的时候会创建更多的 pod 实例 但万一所有的节点都满了,放不下更多 pod 了 则集群需要扩容了即往集群中增加新的节点发现未被调度的pod找可用节点在该节点上调度该podautoscaler减少节点数目如果某个节点上所有 pod 请求的CPU、内存都不到 50%, 该节点即被认定为不再需要什么情况下该节点不可被回收如果节点上有系统 pod 在运行、 对非托管 pod,、以及有本地存储的 pod 该节点不可被归还 否则就会造成这些 pod 提供的服务中断 即只有当 Cluster Autoscaler 知道节点上运行的 pod 能够重新调度到其他节点 该节点才会被归还节点被选中下线的流程当一个节点被选中下线 它首先会被标记为不可调度 ReplicaSet或者其他控制器 创建替代pod并调度到其他剩下的节点污点和容忍机制节点声明一个NoSchedule类型的污点类似于 男女朋友要结婚之前 男朋友身上有很多缺点 女朋友要考虑下我是否能够容忍男朋友上的缺点 如果容忍则结婚 不能容忍则分手节点声明一个PreferNoSchedule类型的污点表示尽量阻止 pod 被调度到这个节点上 但是如果没有其他节点可以调度 pod 依然会被调度到这个节点上pod1: 和节点2说 我可以调度该这个节点嘛? 节点2: 我这边没有房间了 都住满了 pod1: 和节点1说 没有其他节点"收留"我了 您就行行好吧 节点1: 勉为其难的说那好吧也类比大学生刚出校门 能不给家里要钱就不要钱 实际挣不到钱 没办法生存了 再向家里要钱节点声明一个NoExecute类型的污点节点运行期间 如果在一个节点上添加了 NoExecute 污点 那些在该节点上运行着的 pod, 如果没有容忍这个 NoExecute 污点 将会从这个节点去除类比一位将军有几个心腹 某一次打了败仗 将军性情大变 这些心腹中如果能容忍将军的这一变化 就继续跟着将军 如果忍受不了 则会离开将军 另谋高就亲缘性机制nodeSelector类比: 你对象有很多备胎 但她就一心一意想嫁给你亲缘性机制类比: 你对象有很多备胎 她有80%嫁给你的概率 有20%嫁给备胎的概率补充1、还有非亲缘性机制让pod调度在不同的节点上 2、可以给节点设置机架标签 让所有pod调度在同一个机架上联合集群需求背景为确保你不受数据中心级别故障的影响 应用程序应同时部署在多个数据中心或云可用区域中 当其中一个数据中心或可用区域变得不可用时 可将客户端请求路由到运行在其余健康数据中心或区域中的应用程序1、数据中心1中有一个k8s集群 数据中心2中有一个k8s集群(目的做数据灾备使用) 2、通过公共的控制面板来管理超级集群中的2个节点(每个节点代表一个k8s集群) 3、超级集群的唯一的访问入口是ingress两种数据处理模式数据同步控制面板在集群1上做资源操作 集群1将数据同步到集群2统一分配通过控制面板发出创建10个pod的指令 每个集群分别创建5个
Kubernetes架构设计核心组件api server 功能 controller manager负责维护集群的状态scheduler负责资源的调度 按照预定的调度策略将Pod调度到相应的机器上kubeletkube-proxy为Service提供cluster内部的服务发现和负载均衡Add-onskube-dns负责为整个集群提供DNS服务Ingress Controller为服务提供外网入口Heapster提供资源监控Dashboard提供GUIFederation提供跨可用区的集群Fluentd-elasticsearch提供集群日志采集、存储与查询K8S分层结构生态系统在接口层之上的庞大容器集群管理调度的生态系统K8S工作流程分析kubectl提交部署申请replication 创建指定数量的podscheduler将pod调度到节点节点上的kubelet管理pod的生命周期kubeproxy1、 kubeproxy运行在集群各个主机上 管理网络通信,服务发现,负载均衡 2、 当有数据发送到主机时 其将路由到正确的pod或容器 3、 对于主机上发出的数据 它可以基于请求地址发现远程服务器 并将数据正确路由 4、 在某些情况下会使用轮询调度算法将请求发送到集群中的多个实例Api Server的工作流程数据验证根据不同的资源类型找对应的handler处理器yaml文件转换成处理对象验证对象属性的合法性将合法对象保存到etcd中Controller的工作流程1、controller和apiserver通过Informer建立连接 (创建Informer的时候 需要传递一个networkClient对象 用于两者之间建立连接) 2、Informer的Reflector负责维护这个连接 有了连接之后就可以监听apiserver中的数据变化了 通过ListAndWatch方法获取并监听API对象实例的变化 3、在ListAndWatch机制下 一旦APIServer有新的API实例被创建、删除或者更新 Reflector都会收到“事件通知” 4、该事件和它所对应的API对象这个组合 就被称为增量(Delta) 它被放进一个Delta FIFO Queue中 (api对象中的key会放入工作队列中) 5、Informer会从这个队列中读取增量 每拿到一个增量 Informer就会判断这个增量里的事件类型 然后创建或者更新本地对象缓存(Store) 并且Informer会根据这个事件类型 触发事先注册好的ResourceEventHandler 这些Handler需要在创建控制器的时候注册给它对应的Informercontroller大致工作流程
RocketMQ事务消息通过Op消息来确定提交或回滚事务的最终状态一阶段存储的消息的内容 二阶段时恢复出一条完整的普通消息 然后走一遍消息写入流程消息查询按照MessageId查询消息客户端通过messageId得到broker地址 然后再通过commitlog offset读取真正的记录按照Message Key查询消息文件大小是固定的等于40+500W*4+2000W*20= 420000040个字节大小Header保存一些总的统计信息Slot Table不保存真正的索引数据,而是保存每个槽位对应的单向链表的头20*2000W20*2000W 是真正的索引数据 即一个 Index File 可以保存 2000W个索引。类似于HashMap数组+链表的数据结构Timestamp记录的是消息storeTimestamp之间的差 并不是一个绝对的时间
RocketMQ中NettyRemotingServer的Reactor多线程模型1、 一个 Reactor 主线程(eventLoopGroupBoss) 负责监听 TCP网络连接请求 建立好连接 创建SocketChannel 并注册到selector上 2、拿到网络数据后 再丢给Worker线程池(eventLoopGroupSelector) 3、在真正执行业务逻辑之前先需要defaultEventExecutorGroup进行 SSL验证、编解码、空闲检查、网络连接管理 4、根据 RomotingCommand 的业务请求码code去processorTable 这个本地缓存变量中找到对应的 processor 然后封装成task任务后 提交给对应的业务processor处理线程池来执行消息过滤1、 Consumer端订阅消息 是需要通过ConsumeQueue这个消息消费的逻辑队列拿到一个索引 然后再从CommitLog里面读取真正的消息实体内容 2、ConsumeQueue的存储结构 有8个字节存储的Message Tag的哈希值 基于Tag的消息过滤正式基于这个字段值的Tag过滤方式1、 一个消息有多个TAG Consumer端在订阅消息时可以指定Topic或指定TAG 2、 发送一个Pull消息的请求给Broker端 3、 Broker端从RocketMQ的文件存储层—Store读取数据之前 a、 会用这些数据先构建一个MessageFilter 然后传给Store b、 Store从 ConsumeQueue读取到一条记录后 会用它记录的消息tag hash值去做过滤 c、 由于在服务端只是根据hashcode进行判断 无法精确对tag原始字符串进行过滤 故在消息消费端拉取到消息后 还需要对消息的原始tag字符串进行比对 如果不同,则丢弃该消息,不进行消息消费SQL92的过滤方式和上面的Tag过滤方式区别只是在Store层的具体过滤过程不太一样 Store层通过SQL表达式检索ConsumeQueue索引 因会影响效率 所以在检索之前通过布隆过滤器避免每次都通过SQL表达式检索负载均衡Producer负载均衡1、 Producer端在发送消息的时候 会先根据Topic找到指定的TopicPublishInfo 2、 在获取了TopicPublishInfo路由信息后 RocketMQ的客户端在默认方式下selectOneMessageQueue()方法 会从TopicPublishInfo中的messageQueueList中 3、 选择一个队列(MessageQueue)进行发送消息具体的容错策略何为不可用就是按之前失败的 按一定的时间做退避 如果上次请求的延迟超过550Lms 就退避3000Lms 在这期间该broker代理不可用
异步通信机制源码精读生产者发送消息异步监听消息发送结果创建一个线程池 用其中的一个线程去执行发送消息的动作监听发送结果 如果有回调函数则调用回调函数 这里的onSuccess回调就是图1中的回调函数1、一个请求对应一个请求编号 每次请求的发送都要先获取一个信号量锁 在服务器资源有限的情况下 防止调用太快导致服务不可用 2、一个请求编号对应一个响应结果 将对应关系保存在内存中 3、进行netty通信NettyRemotingServer.NettyServerHandler 读取到发送过来的请求并处理执行处理请求的逻辑将该请求封装成一个任务 提交到业务线程池来处理 处理完之后 封装响应对象(包含请求编号) 发送给netty客户端NettyRemotingClient 接收到netty server发送过来的响应数据解析响应数据通过请求编号从内存中获取未来响应对象 有回调函数则执行回调函数 没有回调函数则将响应对象封装到未来对象中从线程池获取线程异步执行回调函数 然后就来到了图3的回调 图3回调到图1执行具体的onSuccess逻辑
对于数据的写入 OS会先写入至Cache内 随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上对于数据的读取 如果一次读取文件时出现未命中PageCache的情况 OS从物理磁盘上访问读取文件的同时 会顺序对其他相邻块的数据文件进行预读取消费队列的读性能几乎接近读内存的原因在消息堆积的情况下也不会影响性能存储的数据较少page cache机制预读取顺序读取消息存储的日志数据文件CommitLog的读取会严重影响性能读取消息内容会产生较多的随机访问读取如何提升随机读性能在块存储采用SSD的话 选择合适的系统IO调度算法 比如Deadline算法 随机读的性能会有所提升文件的读写操作传统的IO方式传统的IO方式 用户态空间的进程要读写磁盘文件 需要经过内核空间 用户进程访问内核空间的缓存 如果没有的话 则读取磁盘文件 用户进程写入文件 先写入内核空间的socket缓存 再通过网卡写入到磁盘 经过2次DMA拷贝+2次CPU拷贝 4次上下文切换RocketMQ mmap+write1、页缓存是对磁盘数据的缓存 2、用户先读取页缓存 如果没有数据则读取磁盘 根据局部性原理 将相邻磁盘块读入页缓存 3、用户将数据写入页缓存 异步线程将小的写入操作合并成大的写入 然后刷入磁盘 4、顺序写入磁盘 磁头几乎不用换道 5、异步刷盘 容易丢失数据 a、可以同步刷盘 但性能低 b、一般采用多副本机制保证消息的可靠 6、数据追加到日志文件的尾部 老的消息无法更改mmap利用内存映射文件来避免拷贝用户空间可以通过映射地址加偏移量的方式直接操作内核空间的页缓存 避免了内核态再拷贝到用户态rocketmq采用mmap+write方式实现文件读写操作产生2次DMA拷贝+1次CPU拷贝 4次上下文切换 通过内存映射减少了一次CPU拷贝 可以减少内存使用 适合大文件的传输对比Kafafa sendfilesendfile1、用户进程通过sendfile()方法向操作系统发起调用上下文从用户态转向内核态 2、DMA控制器把数据从硬盘中拷贝到读缓冲区 3、CPU将读缓冲区中数据拷贝到socket缓冲区 DMA控制器把数据从socket缓冲区拷贝到网卡 上下文从内核态切换回用户态 sendfile调用返回 2次用户态和内核态的上下文切换 3次拷贝 sendfile方法IO数据对用户空间完全不可见 所以只能适用于完全不需要用户空间处理的情况 比如静态文件服务器sendfile+DMA gather1、用户进程通过sendfile()方法向操作系统发起调用 上下文从用户态转向内核态 2、DMA控制器利用scatter把数据从硬盘中拷贝到读缓冲区离散存储 3、CPU把读缓冲区中的文件描述符和数据长度发送到socket缓冲区 4、DMA控制器根据文件描述符和数据长度 使用scatter/gather把数据从内核缓冲区拷贝到网卡 5、sendfile()调用返回,上下文从内核态切换回用户态 DMA gather和sendfile一样数据对用户空间不可见 而且需要硬件支持 同时输入文件描述符只能是文件 但是过程中完全没有CPU拷贝过程 极大提升了性能 产生2次DMA拷贝 没有CPU拷贝 而且也只有2次上下文切换
消息存储CommitLog1、消息内容和元数据都会存在CommitLog日志文件中 2、消息内容不是定长的 3、单个文件大小默认1G 文件名长度为20位 左边补零 剩余为起始偏移量 比如第一个文件名是 00000000000000000000 起始偏移量为0 文件大小为1G=1073741824 当第一个文件写满了 第二个文件为00000000001073741824 起始偏移量为1073741824ConsumeQueue(消息消费队列)主要是为了提高消息消费的性能消费者通过消息消费队列ConsumeQueue(作为索引) 来查找消费的消息消费队列ConsumeQueue(索引)内容ConsumeQueue(逻辑消费队列) 作为消费消息的索引 保存了指定Topic下的队列消息在CommitLog中的 起始物理偏移量offset(8个字节) 消息大小size和(4个字节) 消息Tag的HashCode值(8个字节) 每个条目定长20个字节 单个文件有30W个条目 可以像数组一样随机访问每个条目 每个ConsumeQueue文件大约5.72Mconsumequeue文件夹的组织方式topic/queue/file三层组织结构具体存储路径$HOME/store/consumequeue/{topic}/{queueId}/{fileName}IndexFile(索引文件)通过key或时间区间来查询消息1、存储位置 $HOME \store\index${fileName} 2、文件名 以创建时的时间戳命名的 3、文件大小 单个文件约为400M 可以存储2000W个索引存储结构Broker是混合型存储结构单个broker所有队列共用一个日志文件索引和数据分离的存储结构生产者和消费者使用索引和数据相分离的存储结构整体流程1、Producer发送消息至Broker端 2、Broker端使用同步或者异步的方式对消息刷盘持久化 保存至CommitLog中 3、Consumer拉取消息 4、服务端也支持长轮询模式 Broker允许等待30s的时间 只要这段时间内有新消息到达 将直接返回给消费端页缓存和内存映射页缓存(PageCache)是OS对文件的缓存 用于加速对文件的读写程序对文件进行顺序读写的速度几乎接近于内存的读写速度 主要原因就是由于OS使用PageCache机制对读写访问操作进行了性能优化 将一部分的内存用作PageCache
a、Broker分为Master与Slave b、Master和Slave是1对多的关系 c、Master和Slave对应关系通过指定相同的BrokerName; BorkerId=0表示Master,BorkerId=1表示Slave d、Master也可以部署多个 e、brokerId=1参与消息的读负载每个Broker与NameServer集群中的所有节点建立长连接 定时注册Topic信息到所有NameServer索引服务根据特定的Message key对投递到Broker的消息进行索引服务 以提供消息的快速查询部署架构组件启动流程Producer与NameServer集群中的其中一个节点(随机选择) 建立长连接 定期从NameServer获取Topic路由信息生产者向提供Topic 服务的Master建立长连接 且定时向Master发送心跳消费者Consumer与NameServer集群中的其中一个节点(随机选择)建立长连接 定期从NameServer获取Topic路由信息a、消费者向提供Topic服务的Master、Slave建立长连接 且定时向Master、Slave发送心跳 b、Consumer既可以从Master订阅消息,也可以从Slave订阅消息集群工作流程服务注册a、启动NameServer,NameServer起来后监听端口 等待Broker、Producer、Consumer连上来 相当于一个路由控制中心 b、Broker启动 跟所有的NameServer保持长连接 定时发送心跳包 心跳包中包含当前Broker信息(IP+端口等)以及存储所有Topic信息 注册成功后 NameServer集群中就有Topic跟Broker的映射关系创建topic收发消息前 先创建Topic 创建Topic时需要指定该Topic要存储在哪些Broker上 也可以在发送消息时自动创建Topic生产消息Producer发送消息 启动时先跟NameServer集群中的其中一台建立长连接 并从NameServer中获取当前发送的Topic存在哪些Broker上 轮询从队列列表中选择一个队列 然后与队列所在的Broker建立长连接从而向Broker发消息消费消息Consumer跟Producer类似 跟其中一台NameServer建立长连接 获取当前订阅Topic存在哪些Broker上 然后直接跟Broker建立连接通道 开始消费消息
RocketMQ架构生产者注册中心(NameServer)中记录着所有Broker集群的信息 生产者从NS中通过负载均衡机制获取某一个Broker集群 生产者往这个Broker集群的指定队列中投递消息 投递的过程支持快速失败和低延迟消费者支持两种模式 a、消费者从队列中pull 拉取消息 b、队列给消费者push 推送消息提供消息实时订阅机制集群模式一个消息只能被集群中的一个消费者消费广播模式集群中的每个消费者都会消费NameServer支持Broker动态注册与发现a、每个NameServer将保存关于Broker集群的整个路由信息和用于客户端查询的队列信息 b、ns各实例间相互不进行信息通讯 做灾备使用BrokerServer主要功能重要子模块路由模块负责处理客户端请求客户端模块负责管理生产者/消费者和维护topic订阅消息存储服务提供API接口处理消息存储到物理硬盘和查询功能高可用服务提供Master Broker 和 Slave Broker之间的数据同步功能
Nginx Socket Channel父子进程通讯socketpair该方法用于创建父子进程间使用的套接字 入参: type 表示套接字上使用TCP还是UDP sv[2] 表示一个含有两个元素的整型数组即两个套接字 出参: 当返回0 表示sv[2]这两个套接字创建成功 返回-1 表示创建失败sv[0]写入数据 sv[1]读取到sv[0]写入的数据 sv[1]写入数据 sv[0]读取到sv[1]写入的数据 nginx用channel用于同步master进程和worker进程间的状态worker进程如何接收数据worker进程将sv[1]套接字添加到epoll中 当接收到父进程消息时子进程会通过epoll事件 回调到相应的handler方法来处理这个channel消息信号信号用于传递消息 信号量用于同步代码段一个进程可以向另外一个进程或另外一组进程发送信号消息 通知目标进程执行特定的代码nginx描述接收到信号时行为的结构体举例当收到用户发来的重启命令 ./nginx -s reload新启动进程的生命周期Linux内核收到信号 触发事件监听机制 执行该事件对应的handler方法的运行逻辑 比如handler方法中重新加载配置文件信号注册nginx将每个信号存入数组中 在初始化信号方法中将所有信号注册到Linux内核 在Linux内核收到socket发送过来的信号时 就会触发epoll事件监听机制 然后执行信号中的handler方法信号量这是一种保证共享资源有序访问的工具 保证两个或多个代码段不被并发访问 使用信号量作为互斥锁有可能导致进程睡眠生命周期初始化信号量int sem_init(sem_t*sem,int pshared,unsigned int value)pshared为0 表示线程间同步 为1表示进程间同步 Nginx每个进程都是单线程的 pshared设为1即可信号量实现互斥锁释放锁调用sem_post方法把sem值加1 这个操作没有阻塞加锁调用sem_wait方法 会把信号量减1 如果发现sem值小于等于0 则阻塞当前进程(进程会进入睡眠状态) 直到其他进程将信号量值修改为正数 才能使得当前进程将sem值减1继续向下执行
2022年05月