两年工作总结

330 阅读22分钟

工作交代

时间过得好快,从校园出来,一眨眼就两年的时光过去了。仿佛还来不及做什么,就这么迷迷糊糊的对待了。

我是2019年10月份,拖着行李箱,独自来到了杭州这座城市,开始我的工作生涯。那个时候有几家正准备现场面试的公司,我从中挑中了比较中意的一个,第二年六月份大学毕业,参加公司的毕业转正答辩,前前后后总共呆了一年多。

我也是从在这家公司工作开始,接触到了Golang语言。好在大学里面有C++和Java的基础,所以入门起来比较快。在公司里面的工作内容主要是做爬虫任务调度系统,和另外一个同事,一起开发了一个任务调度系统。在这个过程中,我成长了许多,慢慢的懂得了一些系统设计的思想,当然只是粗略的懂了一些,也不敢班门弄斧。

在公司实习的同时,接触了前后端分离的思想,学会了Vue,独立的开发了一个校园家教发布平台作为我的毕业设计。

后来,公司的组织架构调整,我到了一个项目组里面,参与了一个微服务系统的开发,真正意义上的接触了微服务的思想。利用课余的时间,我在极客时间上面买了一些课程,像MySQL、微服务之类的都是我们工作中接触到的知识,一点点的学了起来。

从这家公司离职,自己也想了很久,其中绝大部分的原因大概是,没有什么更多的成长空间吧。

第二年,我离职了,去了一家区块链的公司,参与了联盟链Fabric的开发工作。

刚接触区块链这个行业,学习的东西还是很多的,入门了整整两个月。

之后,和同事一起去了上海出差,从事Fabric智能合约的业务开发工作。

这一去,就是半年多。

待在这个项目里,我真的学到了很多。如何做项目分析,系统架构分析,如何写文档(这个很重要!!!)等等。

我能明显的感觉到,自己成长的很快,成长了很多。

但是项目进度比较紧,在整个2020年的下半年,几乎天天都是加班。对于我来说,出差本来就不太适应,再加上高强度的加班工作,害怕自己长期以往,身体吃不消。所以就有了换工作的想法。

所以到了2021年初,我又离职了,目前还是待在杭州,从事的方向变成了边缘网关。

我现在自己会给自己一些压力,第一个方面是,跳到了一个比较大的平台,自己的技术水平、沟通能力、合作能力需要跟上。第二个方面,目前已经工作了两年多,作为程序员,需要的就是不断进步不断学习新的知识。如果原地踏步,可能过不了多久,就会被这个圈子慢慢淘汰掉。

说完自己,接下来我们谈一点干货吧。

在谈之前,我先说明一下,由于我的工作经历的原因,我接下来谈的,可能会有一些局限性,观点也偏主观。读者们在看的过程中,作为一个参考就好,不需要太较真,如果脾气不好,请轻点喷。

Golang

首先谈谈后端开发,常用的语言是Java、Golang、Python。Java语言类型的程序员是目前市面上最多的,也是很多公司都会选择的。Java语言开发的整个后端项目有比较好的项目规范,适用于业务逻辑复杂的情况。阿里巴巴有专门的Java开发规范,建议大家有空去读一读。本人从事的是Golang语言,Golang语言适用于开发微服务,特点是开发快、效率高等。但是对于复杂逻辑的业务,代码方面不太好掌控。大多数的小公司,都会选择用Golang开发,因为这门语言相比于Java、Python最大的一个特点就是节省内存。可以给很多小公司节省服务器的开销。

Golang语言的后端框架有很多,像Gin、Beego等,关系型数据库的操作有gorm。这些是Golang后端开发掌握的基础能力。

Golang语言有一些特有的特性,比如协程goroutine,它比线程更加轻量级、高效。比如通道channel,是一种通过共享内存支持协程之间的通信方式。在多个goroutine消费channel中的数据时,channel内部支持锁机制,每一条消息最终只会分配给一个goroutine消费。在Golang内部,有一整套goroutine调度机制GMP,其中G指的是Goroutine,M指的是Machine,P指的是Process。GMP的原理大致就是通过全局Cache和各个线程Cache的方式保存需要运行的Goroutine,通过Process的协调,将Goroutine分配在有限的Machine上运行。

Golang是支持GC的语言,内部使用三色标记垃圾回收算法,原理大概的说就是通过可达性算法,标记出那些被引用的对象,将剩下来没有被标记也就是需要被释放的对象进行内存回收。在之前比较老的版本,STW的影响比较大,所谓的STW就是在GC的时候,因为多线程访问内存会出现不安全的问题,为了保证内存GC的准确性,在标记对象的时候,会通过屏障停止程序代码继续运行下去,直到所有对象都被处理过后,再继续运行程序,这个短暂的时间,就被成为STW(Stop The World)。由于STW,会导致在程序无法提供服务的问题。在Java中,也存在这种现象。但是目前随着Golang版本的不断更新,GC算法也在不断优化,STW的时间也慢慢越来越短。

大家需要注意的一点,在定义map的时候,尽量不要在value中存放指针,因为这样会导致GC的时间过长。

另外一个知识点,就是Golang的map是一个无序map,如果需要从map中遍历数据,需要用slice进行保存,按照一定的顺序进行排序,这样才能保证每次查询出来的数据顺序一致。并且map是无锁并发不安全的。在使用map进行内存缓存的时候,需要考虑到多线程访问缓存带来的安全问题。常见的两种办法,一种是加读写锁RWLock,另一种是使用sync.Map。在写多读少的场景,推荐使用RWLock,因为sync.Map内部使用空间换时间的方法,内部有两个map,一个支持读操作一个支持写操作,当写操作过于频繁,会导致map不断更新,带来的是频繁GC操作,会带来比较大的性能开销。

Golang里面开出来的Goroutine是无状态的,如果需要主函数等待Goroutine执行完成或者终止Goroutine运行,通常有三种方法。第一种是使用sync中的waiteGroup,包含Add、Done、Wait方法。可以类比于Java中的CountDownLatch。第二种是使用Context包中Done方法,将主函数的context带入到Goroutine中,同时在主函数中使用select监听Goroutine接收的context发出的Done信号。第三种是自定义一个channel,传入Goroutine中,主函数等待读取Goroutine中执行完成向channel发送的终止信息。

Golang没有继承的概念,只有组合的概念,每一个struct的定义,可以当做一个类,struct与struct之间可以组合嵌套。在软件设计原则中,类的组合比类的继承更能达到解耦的效果。Golang没有明显的接口实现逻辑,当一个struct实现了一个interface声明的所有方法,这个struct就默认实现了这个interface。在函数调用的入参中,我们通常在调用方传入具体实现了这个interface的struct,而在函数体的接收参数定义这个interface来接收,以此达到被调用函数的复用效果。这也是面向对象特性中多态思想的体现。

在Golang中,error的处理是最蛋疼的。基本上十个函数调用有九个会返回error,对于每一个error都需要进行处理或者向上抛。通常在业务逻辑中,我们都会自定义error,声明error的类型。在Golang官方errors包中,error只是一个struct,它提供了New、Wrap、Error等方法,提供了创建error、向上抛出error、输出error信息的功能。所以需要注意的是,我们不能用string的等值比较error是否相同,因为error是一个struct,是一个实例对象,尽管两个error的值信息一样,但是对象在内存中只是一个存放地址值,两者并不相同。通常我们在函数的第一行,使用defer的功能,对函数体中所有的error进行统一的处理。其中defer是延迟处理标志,函数会在return前拦截处理defer匿名函数内的代码。

Golang的项目结构在github有一个比较出名的example,大家可以参考或者模仿。大家需要注意的是,当外部项目需要调用该项目的代码时,只能调用internel包以外的函数或者对象方法。对于internel包内的代码,对外部调用项目来说,是不可用的。这也是一种代码保护机制。

MySQL

后端项目,离不开的就是数据的增删改查。通常大家接触到最多的就是MySQL了,所以我在这里小小的班门弄斧一下,大家轻点喷。

MySQL常用的版本有5.7和8.0,通常为了向前兼容,大部分公司使用的MySQL版本都是5.7。在这个版本中,MySQL默认支持InnoDB存储引擎,这个引擎的特点就是支持事务,也就是我们常说的ACID。

一般来说,如果需要对多张表进行增、改、删等操作的时候,为了防止多阶段操作的成功失败不一致问题,需要用到事务特性。如果操作不完全失败,就进行事务回滚,将所有操作都取消。

事务有四种隔离级别,分别是读未提交、读已提交、可重复读和序列化。MySQL中InnoDB默认支持的事务隔离级别是可重复读。

大家需要注意的是,对于事务的每一种隔离级别,存储引擎内部都会提供对应的锁机制实现。大家在对数据进行操作的平时,需要注意出现死锁的情况。在数据读取和操作中,支持读写锁,读锁也就是共享锁,多把读锁可以同时拥有。写锁也叫排它锁,同一时刻只允许一把写锁对数据进行操作。不同的存储引擎,有不同的锁级别,有表锁、行锁、间隙锁。大家注意在执行delete或者update操作的时候,最好带上where条件,防止全表删除或者更新的情况,或者因为触发表锁导致死锁的情况。

数据的查询通过索引查找的方式和全表扫描的方式效率差距很大,本质的原因是在InnoDB引擎内部,会对添加了索引的表字段建立B+树以提高查询效率。在查询语句的编写过程中,尽量表明需要查询的字段,这样在查询的字段如果已经创建了联合索引的情况下InnoDB查找不需要进行回表。B+树的叶子节点通常存储的是表的主键,通过查询条件在索引B+树中查询到对应主键,再到以主键为查询条件建立的B+树中查找整行数据的方式我们称为回表,回表会进行两次B+树查询。

联合索引的支持查询方式是最左匹配原则,如果查询语句中的where条件没有按照联合索引的最左匹配原则进行查询,InnoDB将会全表扫描。

在表设计上,一张表的字段不应设计过多,一般不超过20个字段。每个字段的字段类型应该按照实际情况尽量缩减,比如uuid默认是32位,那么定义varchar(32)即可,定义varchar(255)会造成空间浪费。

在分页查询中,limit支持的page和pageSize两个字段,当page越大,查询的效率越低。因此尽量设计一个自动递增的整型字段,在page过大的时候,通过添加过滤自动递增的整型字段的where条件提高查询效率。

MySQL默认是单机存储,对于读多写少的业务场景,可以主从部署,支持读写分离,减轻写服务器的压力。

MySQL最多只能支持几k的并发,对于大量的并发查询数据的场景,建议在上游添加缓存服务比如Redis、Memcached等。

MySQL在操作数据的时候会提供binlog日志,通常会使用cancel等组件服务将数据进行导出到消息队列,进行分析、特定搜索、用户推荐等其他场景。如果MySQL服务器数据丢失,也可以使用binlog日志进行数据恢复,但是因为数据操作会在一段时间内存在系统内存中,定期flush到硬盘,所以通过binlog日志也不一定能完全恢复出所有数据。

Redis

当用户量剧增,访问频繁的时候,在MySQL上游添加一个缓存服务,同步一部分热点数据,可以减轻数据库的访问压力。常见的缓存服务有Redis。

redis是由c语言编写的内存型分布式缓存组件。特点是支持大量读写场景,查询数据高效。

虽然redis是分布式缓存,但是为了防止服务宕机,通常会使用持久化机制将数据保存到硬盘中。redis支持的持久化机制包括AOF和RDB两种。AOF通过记录每一次写、改、删操作的日志,在服务宕机后,通过操作日志进行命令重新执行的方式恢复数据。RDB通过记录数据快照的方式,在服务宕机后,通过数据快照恢复该时间段以前的所有数据。通常来说,两者都有各自的缺点,AOF的缺点是数据恢复慢,RDB的缺点是数据快照是定时执行的,那么在宕机时刻与上一次数据快照记录时刻的中间这一段时间的数据操作,将会丢失。所以我们会两者兼用同步执行。建议RDB的时间间隔不要设置的太短,因为RDB快照的时候执行内部的bgsave命令会导致redis在短暂的时间内无法提供服务。

虽然redis能有效的减轻数据库的访问压力,但是redis也不是银弹。如果数据最终还是以数据库中为准,那么在对数据进行读写操作的时候,需要考虑缓存与数据库不一致的问题。

redis与mysql数据一致性的解决方案有三种。

第一种是先更新redis,同时同步到db

第二种是先只更新redis,定时将redis的数据同步到db

第三种是只更新db,并将redis的对应数据删除,等下一次读取时候从db读,再拉取到redis中。

对于这三种方案,都可能出现db操作成功,redis操作失败或者redis操作成功,db操作失败的情况。

对于第一种,如果redis操作失败,将直接返回失败,db数据将不继续同步。如果redis操作成功,db同步失败,将redis操作的数据进行删除,返回失败。如果redis的删除操作失败,开启后台服务,等待一会再进行删除。

对于第二种,如果redis更新失败,将不会继续通知后台服务通知更新此数据,同时返回失败。如果redis更新成功,后台定时同步任务失败,采取延时重试的方式。暂时返回成功。

对于第三种,如果db更新失败,直接返回失败。如果db操作成功,redis数据删除失败,操作同第一种。

引入redis,除了数据不一致的问题之外,还有可能出现缓存雪崩、缓存失效的情况。在添加缓存的时候,尽量设置不一样的缓存失效时间,防止同一时间内大量缓存数据失效,数据访问db造成db访问压力过大的问题。

redis之所以读取效率快,是因为大量数据存在内存中,如果需要大量的缓存数据存储,单机内存容量有限,redis需要进行集群部署。redis的集群部署存储方式是将拆分的一万多个slot槽位均匀分布在各个redis服务器中,redis的key通过一致性哈希,将数据存储在某个slot槽位对应的redis服务器中。redis的扩容和缩容操作会引起比较大数据迁移,这个时候尽量对外停止服务,否则可能会导致缓存数据失效的问题。

redis通过哨兵机制发现服务上下线的问题。通常的部署模式是一主二从三哨兵。

redis的应用场景有很多,比如利用zsort实现排行榜,利用list实现轻量级消息队列,利用hash set实现微博点赞等等。

在redis存储的时候需要注意,key值尽量不要使用中文,value值尽量不要过大。在设计key的时候,应该根据业务统一key的设计规范。

虽然redis有16个db库,但是只是逻辑隔离,缓存数据都是存储在一个地方,不同的db库的读写是竞争关系。

Kafka

接下来谈一谈消息队列。因为对Kafka比较了解,所以在这里只谈一谈Kafka。

消息队列的应用场景不用多讲了,上下游解耦、削峰填谷、异步处理等等大家根据实际场景去使用就好了。

先说一说消息队列会遇到的一些常见问题吧。比如消息丢失、消息重复发送、消息重试机制、消息顺序性、消息重复消费等

在Kafka中消息出现丢失的情况极低,因为Kafka是保证了至少一次的发送机制。只要是在HW以内的offset,Kafka默认已经持久化到了硬盘中,所以在消费HW以内的offset消息,不会出现消息丢失的情况。

Kafka提供了消息发送的ACK机制,这个ACK机制有三个值可以选择。

当ACK=0的时候,即消息发送到了leader即确认发送成功,此时并不知道其他replica是否已经将消息持久化了没有,这种情况下极有可能出现消息发送了但是丢失的情况。因为如果此时leader节点宕机,其他replica会竞选leader,当某一个replica竞选了leader以后,Kafka内部引入了leader epoach机制进行日志截断,此时如果该replica并没有同步到leader接收到这一条消息,那么这条消息就会丢失。

当ACK=1的时候,即消息发送到了该partition下的ISR集合内的所有replica内。当ISR集合中有多个replica存在,即使此时leader所在的节点宕机,也不会存在消息丢失的情况。因为partition下的leader默认是从ISR集合中产生的,而此时ISR集合内的所有replica已经存储了该条消息,所以丢失的可能性几乎为零。

当ACK=-1的时候,即消息发送到了该partition下的所有replica内。不管leader所在的节点是否宕机,也不管该ISR下的replica是否只有一个,只要该parition下的replica超过一个,那么该消息就不会丢失。

在日常情况下,我们默认ACK=1,因为ACK=0消息极有可能丢失,ACK=-1消息发送确认时间太长,发送效率太低。

对于消息重复发送的问题,我建议从消费端进行去重解决。因为对于producer端,如果出现了消息发送但是没有接收到ACK,但实际上已经发送成功却判断消息发送失败,所以重复发送一次的场景,Kafka也束手无策。不过可以开启事务机制,确保只发送一次,但是一旦开启事务,Kafka的发送消费能力将大打折扣,所以不建议开启事务。

在Kafka中,producer端每发送的一条消息,都会存在对应topic下的partition中的某个offset上。消息发送必须指定topic,可以指定某个partition,也可以不指定。当partition不指定时候,某个topic下的消息会通过负载均衡的方式分布在各个partition下。因为只有同一个parititon下的消息是有序的,所以在给有多个partition的topic发送消息的时候不指定partition,就会出现消息乱序的情况。

Kafka的通过topic对消息进行逻辑隔离,通过topic下的partition对消息进行物理隔离,在topic下划分多个partition是为了提高consumer端的消费能力。一个partition只能被一个consumer端消费,但是一个consumer端可以消费多个partition。每个consumer端都会被分配到一个consumer group中,如果该consumer group组中只有一个consumer端,那么该consumer group订阅的topic下的所有partition都会被这一个consumer端消费。如果consumer group组的consumer端个数小于等于topic下的partition数目,那么consumer group中的consumer端会被均匀的分配到一定的partition数,有可能是一个partition,也有可能是多个partition。相反,如果consumer group组的consumer端个数大于topic下的partition数目,那么consumer group中将会有consumer端分不到partition,消费不到数据。

在实际应用场景中,通常在consumer group中设置与partition数目对等的consumer端数。确保每个consumer端至少消费一个partition下的offset消息。

Kafka集群的每一个服务称作broker,多个broker中会通过zookeeper选举出一个controller处理内部请求和外部操作。但是数据真正的读写操作都发生在partition上,partition归属于某个topic下,为了防止数据丢失,partition一般会设置多个,每一个称作replica。每个partition都会从多个replica中选举出一个partition leader,负责处理数据的写操作和读操作。其他的replica负责于leader交互,进行数据的同步。同一个partition下的多个replica会均匀的分布在不同的broker中。因此在设计上,我们可以发现,实际上Kafka的消息处理是负载均衡的,基本上每个broker都会参与进来。partition的leader默认是从ISR集合中选举产生的。ISR全名是In Sync Replica,意思是已经于leader的消息保持一致的Replica。如果在一定时间内,或者一定数目的offset内,replica没有与leader的offset保持一致,那么就不能存在于ISR集合中,就算之前存在ISR集合中,也会被踢出去。等待一段时间后,消息及时同步了,才有机会加入到ISR集合中。因此,从ISR集合中选举leader在一定程度上是为了保证在leader重新选举的时候消息也能保证同步一致,不会丢失。

因为Kafka中引入了consumer group机制,所以能很大程度上提高consumer端的消费能力。但是也因为consumer group的rebalance机制,会让consumer端的消费产生短暂性的不可用。问题是这样的,因为consumer group中存在一个叫coordinate的均衡器,负责将partition均匀的分配到consumer group的每个consumer端中。如果consumer group中consumer端有添加或者减少,那么partition就需要重新分配,这个时候,该consumer group下的所有consumer端都会停止消费,等待coordinate给他重新分配新的partition。consumer端和partition越多,这个等待时间就越长。因此,不建议topic下的partition设置的过多,一般在20个以内。

看到这里,感谢大家听我絮絮叨叨了这么久。

上篇先写这么多,如果大家还想看什么,欢迎在留言区评论,接下来的内容会根据大家反馈的情况继续提供给大家。

希望能帮助到大家,谢谢。

我是马钰道长,如有转载,请标明出处。

blog.csdn.net/feizaoSYUAC…