Author Image

Hi, I am Ormissia

Ormissia

后端开发

主要从事后端开发,同时也熟悉一些数据处理的工作。

Elastic Certified Engineer
Team Work
Hard Working

技能

最近

Hero Image
全局变量加锁的优化

#golang #cache_line 开发中不可避免地会遇到需要对全局变量加锁的情况,而一旦并发量高了之后,加锁的变量有可能变成服务的性能瓶颈所在。所以千方百计地寻找优化方法。 改变锁的类型 如果业务中的全局变量是读多写少的应用场景,我们可以将互斥锁改为读写锁。即读取时对变量加读锁,这样可以支持多个线程并发读取同一个变量。而只有变量需要修改时才加写锁,保证写的时候不会被其他线程读取到错误的值。 互斥锁: func DoWork() { lock.Lock() defer lock.Unlock() // do something... } 读写锁: func Read() { lock.RLock() defer lock.RUnlock() // read global variable } func Write() { lock.Lock() defer lock.Unlock() // write global variable } 降低锁住的代码块长度 在Go中,我们常常会利用defer关键字的特性,写出如下的代码: func Write() { lock.Lock() defer lock.Unlock() // do something... } 但在实际代码中,如果对全局变量读写前后会有较长时间去做其他工作的情况下,就会造成极大的性能损耗。加锁之后没有立即对全局变量进行读写,或者对全局变量读写完之后没有立即释放锁,都会使其他线程没有办法立即抢到锁,从而拉低了整个系统的并发性能。 根据这个逻辑,可以将上述代码改成如下格式: func Write() { // do something... lock.Lock() // read or write global variable lock.Unlock() // do something.

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 前面几次着重讨论了在分布式系统中出现错误之后该如何处理。虽然长篇累牍谈了很多,但所谈到的情况还是过于乐观,现实则会更加复杂。我们可以根据墨菲定律做一个非常悲观的假定:即所有可能出错的事情最终一定会出错。 作为开发者,我们的核心任务是构建可靠的系统,即使系统面临各种出错的可能,也需要完成预定的工作(确保满足用户期望)。 所以,首先我们要充分认识目前所面临的挑战。 比如:故障可能来自网络问题,以及时钟与时序问题等等…… 在有一段工作经历中,我们的线上系统使用的是公有云,其中用到了不同地区的实例。两地区之间使用的是一条百兆带宽的专线。某个星期天的下午,领导通知我们有个服务出问题了,我查了程序日志之后,看到有许多访问上游服务网络超时的日志,即网络问题。随机运维查看之后告诉我们,上面提到的那条百兆专线被跑满了。至此,服务出问题的罪魁祸首已经找到——网络原因。当然,带宽被占满是由于业务增长还是某些服务出现bug抑或是恶意攻击,这就是另一个话题了。 所以,在我看来,所谓网络的不可靠性并不一定特指网络本身出了什么问题。 故障与部分失效 我们所开发的单点程序,通常会以一种确定的方式运行:要么工作,要么出错。单台节点上的软件通常不应该出现模棱两可的现象。而在分布式系统中,可能会出现系统的一部分正常工作,但其他部分出现难以预测的故障,我们称之为"部分失效"。 问题的难点就在于这种部分失效是不确定的:如果涉及多个节点和网络,几乎肯定会碰到有时网络正常,有时则莫名地失败。 正是由于这种不确定性和部分失效大大提高了分布式系统的复杂性。 不可靠的网络 我们目前关注的主要是分布式无共享系统,即通过网络连接多个节点。所以网络是跨节点通信的唯一途径,并且每台机器都有自己的内存和磁盘,一台机器不能直接访问另一台机器的内存或磁盘除非通过网络向对方发出请求。 诚然,无共享并不是构建集群系统的唯一方式,但却是当下构建互联网服务的主流方式。主要因为:硬件成本低廉,可以采用跨区域多数据中心来实现高可靠性,同时也可以给不同地域的用户提供更高的访问效率。 在我们的网络中一个节点发送数据到另一个节点,但是网络并不能保证他什么时候到达,甚至于,不能保证何时到达。 发送请求等待响应的过程中,有很多错误可能出现: 请求已经丢失(比如有人拔了网线,当然在系统上云之后这种物理层面的小问题,基本可以由底层的虚拟化系统来避免和保障) 请求可能在某个队列中等待,无法马上发送或响应(比如网络发送方或接收方已经超负荷,正如文章开头所提到的例子) 远程接收节点可能已经失效(比如依赖的上游服务崩溃,不过目前基于kubernetes的系统可以在一定程度上保障服务的稳定性) 远程节点可能暂时无法响应(比如正在运行长时间的垃圾回收。可以对服务进行内存的调优,可以在基于kubernetes的系统限制内存大小同时增加实例数等等) 远程接收节点已经完成了请求处理,但回复却在网络中丢失(比如交换机配置错误) 远程接收节点已经完成了请求处理,但回复却被延迟处理(比如网络或发送者的机器超出负荷) 处理类似的问题通常可以采用超市机制:在等待一段时间之后,如果仍然没有收到回复则选择放弃,并认为响应不会到达。 检测故障 许多系统都有自动检测节点失效这种的功能,比如 在ES中节点超过1分钟无响应则踢出集群,而后数据分片在正常的节点上进行重建。 在kubernetes中节点失效后,集群也会自动将失效节点的任务自动负载到其他节点之上。 超时与无限期的延迟 如果超时是故障检测唯一可行的方法,那么超时应该设置多长呢?很不幸,这并没有一个标准的答案。在上面提到的ES的例子是采用了一分钟的时间,延迟1分钟是为了防止偶尔的网络延迟等,因为将某个节点踢出集群后数据分片在集群中重新分配是需要消耗资源的。 设置较长超时则意味着更长时间的等待,才能宣告节点失效(在这期间,用户只能等待或者看到错误信息)。而较短的超时设置可以帮助更快地检测故障,但可能会出现误判,例如实际上节点只是出现短暂的性能波动(由于节点或者网络上的高负载峰值)。 参考链接 《数据密集型应用系统设计》

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 上一次我们主要从书中学习了主从架构消息同步相关的内容,而书中后面提到了多主节点复制(如多数据中心等)和无主节点复制(书中提到的Riak、Dynamo等并不了解,我认为最近比较火的一些区块链技术也是一些无主节点复制)。这两种模式在实际中(至少在我的认知范围内中小体量的公司基本不会维护一些多数据中心的场景)并不常见,这里不再过多讨论。 在一个单独的主从复制架构中,主节点和所有从节点都需要保存全量的数据。在项目初期,如果对未来的数据增量没有一个相对准确的判断,在业务发展一段时间之后应用就会遇到性能瓶颈,同时也有可能面临扩容困难等一系列问题。因此,分片的机制应运而生。 数据分区与数据复制 分区通常与复制相结合,即每一个分区的复制都是一个完整的主从架构的复制,而每个分区都会在多个节点上拥有相同的(不考虑微观上的延迟)副本,这意味着某条记录属于特定分区,而同样的内容会被保存到不同节点上以提高系统的容错性,这样即使某一个节点失效也不会影响整个集群的运行。 键-值数据的分区 面对海量的数据如何决定哪一条记录该放在哪个分区上呢?分区的主要目标就是将数据和查询负载均匀地分布在所有节点上。 而如果分区不均匀,就会出现某些分区节点比其他分区承担了更多的任务,即为数据倾斜。数据倾斜会导致分区效率严重下降以至于丧失了既定的目标。 避免热点最简单的办法是将数据随机分配到所有节点上。这种方法可以比较均匀地分布数据,但也有一个致命的缺点:如此写入到集群中的数据是无法通过特定key来读取的,因为没有办法知道数据保存在哪个节点上,所以不得不查询所有节点。 简单的改进方法可以通过key来分配分区,比如a-z的单词根据首字母分配到26个节点上。 基于关键字区间分区 假如上述根据单纯根据首字母字来分区时没有26个节点,那就需要将某些临近的字母放到同一个分区中,比如ab放到第一个分区,cd放到第二个分区……依次类推,26个字母需要13个节点即可放完。 但是基于关键字区间的分区也存在缺点,某些访问模式会导致热点。假如使用时间戳作为关键字,每一天的数据写入到一个分区中时,就会使这个分区成为热点。而其他分区始终处于空闲状态。 为了避免上述问题,可以在时间戳以外加入其他内容,比如数据类型等 基于关键字哈希值分区 对于上述数据倾斜与热点问题,许多分布式系统采用了基于关键字哈希函数的方式来分区。 一个好的哈希函数可以处理数据倾斜并使其均匀分布,这样从整体来看可以使数据均匀的分布到所有分区上。 负载倾斜与热点 如上所述,基于哈希的分区方法可以减轻热点,但依然无法完全避免。一个极端情况是所有读写都是针对同一个key进行的,则最终的请求都会被路由到同一个分区中。比如某个明星又离婚了等等… 而最让人困扰的是,数据倾斜的问题不光会出现在这些基础设施(指分布式存储,一些消息中间件等)中,也会出现在我们的应用层中。比如,为了防止数据乱序(有时候乱序的数据会给下游处理带来压力,比如Flink处理乱序数据产生的延迟问题。再者相同key发往不同分区时也会使Flink处理数据时产生大量的Shuffle带来的网络IO压力)从而采用哈希等方法将数据写入kafka的partition中。 即使采用了哈希分区的方法,如果出现某个热点key产生大量数据,就会造成数据倾斜。严重时将导致Kafka集群中某几个节点(主分片和所有副本所在的节点)磁盘被写满,进而导致整个集群不可用引发生产故障。 针对这个特特定的场景,由于同一key的数据可以在较长一段时间后忍受分区发生改变,因此可以在几个小时以后改变一次分区选择规则。诚然,这个办法并不能推广到所有数据倾斜问题的解决中。 分区与二级索引 上面讨论的分区方案都依赖于键值的数据模型(其实我个人认为,多数数据存储莫不如此,即便是回到MySQL也是通过主键查询,要么回表,再要么全表扫描)。键值模型相对简单,即都是通过关键字来访问记录。但是涉及到二级索引,情况就会变得复杂。 考虑到其复杂性,部分存储并不支持二级索引,如HBase作为一个面向列的存储,为了兼顾大数据量写入和OLAP场景的应用,并不支持二级索引。但是二级索引则是ES等一些全文搜索引擎的根本值所在。 而二级索引也是需要存储到不同分区中的,目前主要有两种方法来支持二级索引进行分区: 基于文档的分区 基于此条的分区 基于文档分区的二级索引 基于文档的分区是将所有二级索引在每个分区中都存了一个词条,而每个分区中的二级索引只记录自己分区的数据。 如果需要通过二级索引查询数据,就需要每一个分区的二级索引,再做统一处理。因此会导致读延迟显著放大。 基于此条的二级索引分区 基于词条的二级索引分区即与数据分区类似,二级索引的词条被放入所有分区,每个词条只存在于某一个分区(不考虑副本)。 这种方法对比前者,好处就是读取更为高效,不需要遍历所有分区的二级索引。相反这种方案写入性能相对较低,因为一个文档里面可能涉及到多个二级索引,而二级索引的分区又可能完全不同甚至不在同一节点上,由此势必引入显著的写放大。 而正因如此,实践中对全局二级索引的更新往往都是异步的。 参考链接 《数据密集型应用系统设计》

Hero Image
数据密集型应用系统设计(DDIA)读书笔记

#golang 通常在生产中存储结构化数据最常用的是MySQL,而MySQL底层存储用的数据结构是B+树。当并发量达到一定程度之后通常会将单点的MySQL拆分成主从架构(在这之前可以加入内存型缓存如Redis等,属于不同层级的解决办法,不在此文讨论范畴)。 问题产生 在主从架构中主要问题之一有复制滞后。 这里以MySQL集群为例,主从复制要求所有写请求都经由主节点,而从节点只接收只读的查询请求(这一点在ES/Kafka的多副本分片中也有类似体现,主分片写入,从分片只支持读取)。对于读操作密集的负载(如web),这是一个不错的选择。 在这种扩展体系下,只需增加更多的从节点,就可以提高读请求的吞吐量。但是,这种方法在实际生产中只能用于异步复制,如果试图同步所有的从副本(即强一致性),则单个副本的写入失败将使数据在整个集群中写入失败。并且节点越多,发生故障的概率越高,所以以完全同步来设计系统在现实中反而非常不可靠。 在Kafka集群中为了提高消息吞吐量时与副本同步相关的设置通常会将acks设置为1或者0(1/0的区别在于leader是否落盘),partition的leader收到数据后即代表集群收到消息 说回到MySQL的主从集群,从上文中得到的结论,如果采用异步复制的话,很不幸如果一个应用正好从一个异步的从节点中读取数据,而该副本落后于主节点,这时应用读到的是过期的消息,表现在用户面前就会产生薛定谔的数据,即在同一时刻查询会出现两种截然不同的数据。 不过这个不一致的状态只是暂时的,经过一段时间之后,从节点的数据会更新到与主节点保持一致,即最终一致性。 解决办法 由于网络等原因导致的不一致性,不仅仅是存在于理论中,其是个实实在在的现实问题。下面分析复制滞后可能出现的问题,并找出相应的解决思路。 读自己的写 举个栗子: 当用户提交一些数据,然后刷新页面查看刚刚修改的内容时,例如用户信息,或者是对于一些帖子的评论等。提交新数据必须发送到主节点,但是当用户取数据时,数据可能来自从节点。 当集群是异步复制时就会出现问题,用户在数据写入到主节点而尚未达到从节点时刷新页面,看到的是数据修改之前的状态。这将给用户带来困惑。延伸到一些库存类型的应用,其实并不会导致超卖。如果用户看到是旧状态,误认为操作失败重新走了一遍流程,这时写入请求依然是访问到主节点,而主节点的数据是最新的,会返回失败。而这将进一步给用户带来困扰。 对于这种情况,我们需要"写后读一致性",该机制保证用户重新加载页面,总是能看到自己最新更新的数据。但对于其他用户看这条信息没有任何保证 方案一 总是从主节点读取用户可能会修改的信息,否则在从节点读取。即,从用户访问自己的信息时候从主节点读取,访问其他人的信息时候在从节点读取。 方案二 在客户端记住最近更新的时间戳,并附带在请求中。如果查到的数据不够新,则从其他副本中重新查询,或者直接从主节点中查询。 方案三 如果副本分布在多个数据中心(地理位置上的多个机房)等,就必须把请求路由到主节点所在的数据中心。至少目前还没有接触过这种项目,没有很深的理解,不过多讨论这种情况。 此外,依然存在一些其他问题需要考虑,如用户在多个设备上登录,这样一个设备就无法知道其他设备上进行了什么操作,如果采用方案二的话,依然会出现不一致。 单调读 在上述第二个例子中,出现了用户数据向后回滚的情况。 假设用户从不同副本进行了多次读取,用户刷新了一个网页,该请求可能会被随机路由到某一个从节点。用户2345先后在两个从节点上执行了两次完全相同的查询(先是少量滞后的从节点,然后是滞后很大的从节点),则很有可能出现以下情况。 第一个查询返回了最近用户1234所添加的评论,但第二个查询结果代表了更早时间点的状态。如果第一个查询没有返回任何内容,用户2345并不知道用户1234最近的评论,情况还好。但当用户2345看到了用户1234的评论之后,紧接着评论又消失了,就会感到十分困惑。 阿b(bilibili)的评论系统在使用中出现过类型的现象,但不清楚是否是由于审核等一些其他因素造成的。总之是在一个新视频发布后去刷新评论,第一次看到有人评论了,再次刷新评论又消失了。 单调读一致性可以确保不会发生这种异常。这是一个比强一致性弱,但比最终一致性强的保证。即保证用户依次进行多次读取,绝不会看到回滚的现象。 实现单调读的一种方式是,确保每个用户总是从固定的同一副本执行读操作(不同的用户当然可以从不同的副本读取)。例如,使用用户ID的哈希来决定去哪个副本读取消息,但如果该副本失效,系统必须要有能力将查询重新路由到其他有效的副本上。 前缀一致读 第三个由于复制滞后导致反常的例子。 比如A和B之间以下的对话: A: 请问B,你能听到吗? B: 你好A,我能听到 这两句话之间存在因果关系,即B听到了A的问题,然后再去回答。 现在如果有第三人在通过从节点上收听上述对话。假设B发的消息先同步了,观察者看到的对话就变成了这样: B: 你好A,我能听到 A: 请问B,你能听到吗? 这逻辑就变得混乱了。 防止这种异常需要引入另一种保证:前缀一致读。该保证是说,对于一系列按照某个顺序发生的写请求,那么读取这些内容时必须要按照当时写入的顺序 小结 上面讨论的是在保证最终一致性异步复制的情况下发生的。当系统决不能忍受这些问题时,那就必须采用强一致性,但随之而来的就是写入性能低下,故障率高,一个节点故障引发整个集群不可用等各种问题。都需要在应用开始进行得失的平衡。 再举个栗子: 在kafka这种对写入性能要求极高的应用中,如果发送的消息不是特别重要,有要求极高吞吐量的时候,比如日志收集等,则可以设置为Leader收到消息即代表成功 而在ES中,则必须要求数据分片的所有副本都写入成功才返回成功,采用了强一致性。而ES采用了健康检查,超过1分钟不活跃的节点就剔除集群等机制,从而保证了数据可以实时地写入。 延伸 结合到实际工作中的项目分析,也存在类似问题。 下面举两个类似的栗子: 例一 在某基础信息管理平台中需要一个模糊搜索的功能,各方面平衡之后采用在应用内存中使用前缀树的方式做缓存。由于应用是多实例的,这时数据的增删改就会在多实例之间存在一个短暂的不一致。 例二 在某数据处理应用中,由于每一条数据中需要有多个(一到十几不等)条目访问缓存。开始的时候将缓存放在Redis里,而应用访问Redis的时间大概需要十几到几十毫秒的时间,这样每一条数据的处理时间就在几十毫秒到几百毫秒之间。而使用多线程处理,则会造成消息的严重乱序。 测试下来,程序每秒只能处理不超过20条数据,大大影响了效率。而后将缓存改到内存中,省掉了访问Redis的时间,再结合Kafka的一些优化策略,极大的提高了应用吞吐量。测试后每秒大概可以处理几千条数据。缓存放到程序内存中之后,也同样会出现缓存不一致的问题。 下面是这两个应用中采用的一个缓存架构图: 在这个架构中,如果某个实例接收Redis消息慢了,就会出现不同实例间的数据不一致 参考链接 《数据密集型应用系统设计》

Hero Image
HTTP笔记

#http-code http协议(超文本传输协议) 是客户端和服务器端两者通信共同遵循的一些规则。主要内容是定义了客户端如何向服务器请求资源,服务器如何响应客户端请求。 请求中的POST与GET方法的区别 get是从服务器上获取数据,post是向服务器传送数据。 在客户端,Get方式在通过URL提交数据,数据在URL中可以看到;POST方式,数据放置在HTML HEADER内提交。 对于get方式,服务器端用Request.QueryString获取变量的值,对于post方式,服务器端用Request.Form获取提交的数据。 GET方式提交的数据最多只能有1024字节,而POST则没有此限制。 安全性问题。正如在(1)中提到,使用 GET 的时候,参数会显示在地址栏上,而 Post 不会。所以,如果这些数据是中文数据而且是非敏感数据,那么使用 GET;如果用户输入的数据不是中文字符而且包含敏感数据,那么还是使用 post为好。 HTTP 1.0 HTTP 1.0规定浏览器与服务器只保持短暂的连接,浏览器的每次请求都需要与服务器建立一个TCP连接,服务器完成请求处理后立即断开TCP连接 当一个网页文件中包含了很多图像的地址的时候,那就需要很多次的HTTP请求和响应,每次请求和响应都需要一个单独的连接,每次连接只是传输一个文档和图像,上一次和下一次请求完全分离。即使图像文件都很小,但是客户端和服务器端每次建立和关闭连接却是一个相对比较费时的过程,并且会严重影响客户机和服务器的性能。当一个网页文件中包含JS文件,CSS文件等内容时,也会出现类似上述的情况。 HTTP 1.1 为了克服HTTP 1.0的这个缺陷,HTTP 1.1支持持久连接(HTTP/1.1的默认模式使用带流水线的持久连接),在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟。一个包含有许多图像的网页文件的多个请求和应答可以在一个连接中传输,但每个单独的网页文件的请求和应答仍然需要使用各自的连接。 HTTP 1.1还允许客户端不用等待上一次请求结果返回,就可以发出下一次请求,但服务器端必须按照接收到客户端请求的先后顺序依次回送响应结果,以保证客户端能够区分出每次请求的响应内容,这样也显著地减少了整个下载过程所需要的时间。 在HTTP 1.1,request和response头中都有可能出现一个connection的头,此header的含义是当client和server通信时对于长链接如何进行处理。 在HTTP 1.1中,client和server都是默认对方支持长链接的, 如果client使用HTTP 1.1协议,但又不希望使用长链接,则需要在header中指明connection的值为close;如果server方也不想支持长链接,则在response中也需要明确说明connection的值为close。不论request还是response的header中包含了值为close的connection,都表明当前正在使用的tcp链接在当天请求处理完毕后会被断掉。以后client再进行新的请求时就必须创建新的tcp链接了。 HTTP 1.1在继承了HTTP 1.0优点的基础上,也克服了HTTP 1.0的性能问题。 HTTP 1.1通过增加更多的请求头和响应头来改进和扩充HTTP 1.0的功能。如,HTTP 1.0不支持Host请求头字段,浏览器无法使用主机头名来明确表示要访问服务器上的哪个WEB站点,这样就无法使用WEB服务器在同一个IP地址和端口号上配置多个虚拟WEB站点。在HTTP 1.1中增加Host请求头字段后,WEB浏览器可以使用主机头名来明确表示要访问服务器上的哪个WEB站点,这才实现了在一台WEB服务器上可以在同一个IP地址和端口号上使用不同的主机名来创建多个虚拟WEB站点。HTTP 1.1的持续连接,也需要增加新的请求头来帮助实现,例如,Connection请求头的值为Keep-Alive时,客户端通知服务器返回本次请求结果后保持连接;Connection请求头的值为close时,客户端通知服务器返回本次请求结果后关闭连接。HTTP 1.1还提供了与身份认证、状态管理和Cache缓存等机制相关的请求头和响应头。HTTP 1.0不支持文件断点续传,RANGE:bytes是HTTP 1.1新增内容,HTTP 1.0每次传送文件都是从文件头开始,即0字节处开始。RANGE:bytes=XXXX表示要求服务器从文件XXXX字节处开始传送,这就是我们平时所说的断点续传。 HTTP 1.1和HTTP 1.0协议的区别 缓存处理 带宽优化及网络连接的使用 错误通知的管理 消息在网络中的发送 互联网地址的维护 安全性及完整性 HTTP 1.x和HTTP 2协议的区别 二进制分帧:HTTP 2采用二进制格式传输数据,而非HTTP 1.x的文本格式 头部压缩:头部表在HTTP 2的连接存续期内始终存在,由客户端和服务器共同渐进地更新。请求一发送了所有的头部字段,第二个请求则只需要发送差异数据,这样可以减少冗余数据,降低开销 多路复用:直白的说就是所有的请求都是通过一个TCP连接并发完成。HTTP 1.x虽然通过pipeline也能并发请求,但是多个请求之间的响应会被阻塞的,所以pipeline至今也没有被普及应用,而HTTP 2做到了真正的并发请求。同时,流还支持优先级和流量控制。 服务器推送:服务端能够更快的把资源推送给客户端。例如服务端可以主动把JS和CSS文件推送给客户端,而不需要客户端解析HTML再发送这些请求。当客户端需要的时候,它已经在客户端了。 HTTP 2主要是HTTP 1.

历程

Flink
2021-10
...
Elastic
2021-8
...
Spark
2021-6
...
Yarn
2021-5
...
Scala
2021-5
...
Hbase
2021-4
...
Hive
2021-3
...
HDFS
2021-2
...
Kafka
2021-10
...
Kubernetes
2020-12
...
VUE
2020-9
VUE+Element开发个人博客项目前端程序
Golang
2020-7
Go基础语法,GMP模型,GC回收机制。使用Gin+Gorm开发Web后端程序
JVM
2020-4
了解JVM常见垃圾回收器类型,ParNew+CMS的新生代、老年代垃圾回收机制
Redis
2020-1
Redis数据结构,基于Redis的缓存设计。穿透、击穿、雪崩等问题发生原因及避免方式。
MyBatis
2019-6
Spring中引入Mybatis连接数据库
Springboot
2019-5
学习基于Springboot开发Web后端程序
MySQL
2019-1
MySQL的调优,慢SQL等问题解决
JAVA
2018-1
JAVA基础语法,面向对象设计思想

成就

Elastic Certified Engineer