golang 后端技术开发必备总结

goper
English

Golang

首先谈谈后端开发,常用的语言是Java、Golang、Python。Java语言类型的程序员是目前市面上最多的,也是很多公司都会选择的。Java语言开发的整个后端项目有比较好的项目规范,适用于业务逻辑复杂的情况。本人从事的是Golang语言,Golang语言适用于开发微服务,特点是开发快、效率高等。大多数的公司(字节主力语言 Go, B站主力Go, 腾讯偏向Go 等等),都开始选择用Golang开发,因为这门语言相比于Java、Python最大的一个特点就是节省内存,支持高并发,简洁高效,容易上手学习。

Golang语言的后端框架有很多,像 Gin(推荐使用)、Beego、Iris等,关系型数据库的操作有gorm。这些是Golang后端开发掌握的基础能力。
微服务框架有: go-zero kratos

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中的waitGroup,包含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匿名函数内的代码。(可以使用 pkg/errors 包解决)

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


MySQL

后端项目,离不开的就是数据的增删改查。通常大家接触到最多的就是MySQL了,推荐去看下 MySQL 45讲

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将会全表扫描。索引优化使用 Explain 语句。

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

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

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

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

MySQL在操作数据的时候会提供binlog日志,通常会使用cancal等组件服务将数据进行导出到消息队列,进行分析、特定搜索、用户推荐等其他场景。如果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 某个数据过期了,直接从Mysql中查询数据.
写操作: 先更新Mysql, 然后再更新Redis即可;如果更新 redis失败,可以考虑重试,

对于上述操作,如果还存在不一致的情况,考虑加一个兜底方案, 监听 mysql binlog日志,然后binlog 日志发送到 kafka 队列中消费解决。

引入redis,除了数据不一致的问题之外,还有可能出现缓存雪崩、缓存穿透,缓存击穿的情况。在添加缓存的时候,尽量设置不一样的缓存失效时间,防止同一时间内大量缓存数据失效,数据访问db造成db访问压力过大的问题; 缓存穿透可以考虑布隆过滤器, 缓存击穿考虑分布式锁解决。

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

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

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

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

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


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个以内。

阅读 2.5k

go 后端开发

374 声望
16 粉丝
0 条评论

go 后端开发

374 声望
16 粉丝
文章目录
宣传栏