后端开发总结

75 阅读18分钟

微服务实际

spring boot自动装配原理

自动装配核心是 EnableAutoConfiguration,它通过 AutoConfigurationImportSelector 读取 classpath 下自动配置类,并结合一系列 @Conditional 注解进行按需装配。

高可用

限流

限流一般是从服务提供者provider的视角提供的针对自我保护的能力, 对于流量负载超过我们系统的处理能力,限流策略可以防止我们的系统被激增的流量打垮。京东内部无论是同步交互的JSF, 还是异步交互的JMQ都提供了限流的能力,大家可以根据自己系统的情况进行设置;我们知道常见的限流算法包括:计数器算法,滑动时间窗口算法,漏斗算法,令牌桶算法,具体算法可以网上google下,下面是这些算法的优缺点对比。

熔断降级

熔断是防止我们的系统被下游系统拖垮,比如下游系统接口性能严重变差,甚至下游系统挂了;这个时候会导致大量的线程堆积,不能释放占用的CPU,内存等资源,这种情况下不仅影响该接口的性能,还会影响其他接口的性能,严重的情况会将我们的系统拖垮,造成雪崩效应,通过打开熔断器,流量不再请求到有问题的系统,可以保护我们的系统不被拖垮。降级是一种有损操作,我们作为服务提供者,需要将这种损失尽可能降到最低,无论是返回友好的提示,还是返回可接受的降级数据。降级细分的话又分为人工降级,自动降级。

超时设置

超时可以让系统快速失败,进行自我保护,避免无限等待下游依赖系统。比如下游新开发的接口,一般会基于压测提供一个TP99的耗时,我们会基于此配置超时时间;老接口的话,会基于线上的TP99耗时来配置超时时间。 超时时间在设置的时候需要遵循漏斗原则, 从上游系统到下游系统设置的超时时间要逐渐减少,如下图所示。为什么要满足漏斗原则,假设不满足漏斗原则,比如服务A调取服务B的超时时间设置成500ms,而服务B调取服务C的超时时间设置成800ms,这个时候回导致服务A调取服务B大量的超时从而导致可用率降低,而此时服务B从自身角度看是可用的。

重试

为什么要重试:提高成功率,分布式环境通信具有不可靠性。引入重试,那么我们开发接口要做好幂等。而且要警惕重试风暴,因为你依赖的接口,他们也会调用下游的rpc。

隔离

核心服务和非核心服务进行环境隔离,避免影响。

  • 线程池也要进行隔离。避免多个api接口复用同一个线程。

存储层

存储层主要通过复制和分片来保证存储层的高可用,复制主要是通过副本(主从节点,主从副本)来保证高可用,分片是将数据分散到不同的节点上来保证高可用(鸡蛋不要放在同一个篮子中)。复制和分片在保证高可用的情况下,其实也提高了系统的高性能和高并发,复制和分片的思想在Mysql,Redis,ElasticSearch, kafka中都进行了采用。 ①复制

复制技术是一份数据的完整的拷贝,思想是通过冗余保证高可用。 复制又可以分为:主从复制,多主复制,无主复制。

  • 主从复制: 客户端将所有写入操作发送到单个节点(主库),该节点将数据更改事件流发送到其他副本(从库)。读取可以在任何副本上执行,但从库的读取结果可能是陈旧的。

  • 多主复制: 客户端将每个写入发送到几个主库节点之一,其中任何一个主库都可以接受写入。主库将数据更改事件流发送给彼此以及任何从库节点。

  • 无主复制: 客户端将每个写入发送到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。

②分区

分区也称为分片,对于非常大的数据集在单节点进行存储时,一方面可用性比较低(鸡蛋放在同一个篮子中),另一方面也会遇到存储和性能的瓶颈,我们需要将大的数据集通过负载均衡分片到不同的节点上,每条数据(每条记录,每行或每个文档)属于且仅属于一个分区,每个分区都是自己的小型数据库。 分区我们分为键范围分区,散列分区。

键范围分区: 其中键是有序的,并且分区拥有从某个最小值到某个最大值的所有键。排序的优势在于可以进行有效的范围查询,但是如果应用程序经常访问相邻的键,则存在热点的风险。在这种方法中,当分区变得太大时,通常将分区分成两个子分区来动态地重新平衡分区。

散列分区: 散列函数应用于每个键,分区拥有一定范围的散列。这种方法破坏了键的排序,使得范围查询效率低下,但可以更均匀地分配负载。通过散列进行分区时,通常先提前创建固定数量的分区,为每个节点分配多个分区,并在添加或删除节点时将整个分区从一个节点移动到另一个节点。也可以使用动态分区。

服务优化

缓存

1)动静分离:对于一个缓存对象,可能分为很多种属性,这些属性中有的是静态的,有的是动态的。在缓存的时候最好采用动静分离的方式。如企鹅电竞的视频详情分为标题、时长、清晰度、封面 URL、点赞数、评论数等,其中标题、时长等属于静态属性,基本不会改变,而点赞数、评论数经常改变,在缓存时这两部分开,以免因为动态属性每次的变更要把整个视频缓存拉出来进行更新一遍,成本很高。

2)慎用大对象:如果缓存对象过大,每次读写开销非常大并且可能会卡住其他请求,特别是在 redis 这种单线程的架构中。典型的情况是将一堆列表挂在某个 value 的字段上或者存储一个没有边界的列表,这种情况下需要重新设计数据结构或者分割 value 再由客户端聚合。

3)过期设置:尽量设置过期时间减少脏数据和存储占用,但要注意过期时间不能集中在某个时间段。

4)超时设置:缓存作为加速数据访问的手段,通常需要设置超时时间而且超时时间不能过长(如 100ms 左右),否则会导致整个请求超时连回源访问的机会都没有。

5)缓存隔离:首先,不同的业务使用不同的 key,防止出现冲突或者互相覆盖。其次,核心和非核心业务进行通过不同的缓存实例进行物理上的隔离。

6)失败降级:使用缓存需要有一定的降级预案,缓存通常不是关键逻辑,特别是对于核心服务,如果缓存部分失效或者失败,应该继续回源处理,不应该直接中断返回。

7)容量控制:使用缓存要进行容量控制,特别是本地缓存,缓存数量太多内存紧张时会频繁的 swap 存储空间或 GC 操作,从而降低响应速度。

8)业务导向:以业务为导向,不要为了缓存而缓存。对性能要求不高或请求量不大,分布式缓存甚至数据库都足以应对时,就不需要增加本地缓存,否则可能因为引入数据节点复制和幂等处理逻辑反而得不偿失。

9)监控告警:对大对象、慢查询、内存占用等进行监控。

  1. 缓存雪崩:缓存雪崩是指缓存中的大量数据同时失效或者过期,大量的请求直接读取到下游数据库,导致数据库瞬时压力过大,通常的解决方案是将缓存数据设置的过期时间随机化。在事件服务中就是利用固定过期时间+随机值的方式进行文章的淘汰,避免缓存雪崩。
  2. 缓存穿透:缓存穿透是指读取下游不存在的数据,导致缓存命中不了,每次都请求下游数据库。这种情况通常会出现在线上异常流量攻击或者下游数据被删除的状况,针对缓存穿透可以使用布隆过滤器对不存在的数据进行过滤,或者在读取下游数据不存在的情况,可以在缓存中设置空值,防止不断的穿透。事件服务可能会出现查询文章被删除的情况,就是利用设置空值的方法防止被删除数据的请求不断穿透到下游。
  3. 缓存击穿:缓存击穿是指某个热点数据在缓存中被删除或者过期,导致大量的热点请求同时请求数据库。解决方案可以对于热点数据设置较长的过期时间或者利用分布式锁避免多个相同请求同时访问下游服务。在新闻业务中,对于热点新闻经常会出现这种情况,事件服务利用 golang 的 singlefilght 保证同一篇文章请求在同一时刻只有一个会请求到下游,防止缓存击穿。
  4. 热点 key:热点 key 是指缓存中被频繁访问的 key,导致缓存该 key 的分片或者 Redis 访问量过高。可以将可热点 key 分散存储到多个 key 上,例如将热点 key+序列号的方式存储,不同 key 存储的值都是相同的,在访问时随机访问一个 key,分散原来单 key 分片的压力;此外还可以将 key 缓存到机器内存中,避免 Redis 单节点压力过大,在新闻业务中,对于热点文章就是采用这种方式,将热点文章存储到机器内存中,避免存储热点文章 Redis 单分片请求量过大。

并行化处理

MySQL 的主从同步过程从数据库通过 I/Othread 读取住主库的 binlog,将日志写入到 relay log 中,然后由 sqlthread 执行 relaylog 进行数据的同步。其中 sqlthread 就是由多个线程并发执行加快数据的同步,防止主从同步延迟。sqlthread 多线程化也经历了多个版本迭代,按表维度分发到同一个线程进行数据同步,再到按行维度分发到同一个线程。

批量化处理

Kafka 的消息发送并不是直接写入到 broker 中的,发送过程是将发送到同一个 topic 同一个分区的消息通过 main 函数的 partitioner 组件发送到同一个队列中,由 sender 线程不断拉取队列中消息批量发送到 broker 中。利用批量发送消息处理,节省大量的网络开销,提高发送效率。 我们读取下游服务或者数据库的时候,可以一次多查询几条数据,节省网络 I/O;读取 Redis 的还可以利用 pipeline 或者 lua 脚本处理多条命令,提升读写性能;前端请求 js 文件或者小图片时,可以将多个 js 文件或者图片合并到一起返回,减少前端的连接数,提升传输性能。同样需要注意的是批量处理多条数据,有可能会降低吞吐量,以及本身下游就不支持过多的批量数据,此时可以将多条数据分批并发请求。对于事件底层页服务中不同组件下配置的不同文章 id,会统一批量请求下游内容服务获取文章详情,对于批量的条数也会做限制,防止单批数据量过大。

数据压损

Redis 的 AOF 重写是利用 bgrewriteaof 命令进行 AOF 文件重写,因为 AOF 是追加写日志,对于同一个 key 可能存在多条修改修改命令,导致 AOF 文件过大,Redis 重启后加载 AOF 文件会变得缓慢,导致启动时间过长。可以利用重写命令将对于同一个 key 的修改只保存一条记录,减小 AOF 文件体积。 大数据领域的 Hbase、Cassandra 等 NoSQL 数据库写入性能都很高,它们的底层存储数据结构就是 LSM 树(log structured merge tree),这种数据结构的核心思想是追加写,积攒一定的数据后合并成更大的 segement,对于数据的删除也只是增加一条删除记录。同样对一个 key 的修改记录也有多条。这种存储结构的优点是写入性能高,但是缺点也比较明显,数据存在冗余和文件体积大。主要通过线程进行段合并将多个小文件合并成更大的文件来减少存储文件体积,提升查询效率。

无锁

MySQL 利用 mvcc 实现多个事务进行读写并发时保证数据的一致性和隔离型,也是解决读写并发的一种无锁化设计方案之一。它主要通过对每一行数据的变更记录维护多个版本链来实现的,通过隐藏列 rollptr 和 undolog 来实现快照读。在事务对某一行数据进行操作时,会根据当前事务 id 以及事务隔离级别判断读取那个版本的数据,对于可重复读就是在事务开始的时候生成 readview,在后续整个事务期间都使用这个 readview。MySQL 中除了使用 mvcc 避免互斥锁外,bufferpool 还可以设置多个,通过多个 bufferpool 降低锁的粒度,提升读写性能,也是一种优化方案。

日常工作 在读多写少的场景下可以利用 atomic.value 存储数据,减少锁的竞争,提升系统性能,例如配置服务中数据就是利用 atomic.value 存储的;syncmap 为了提升读性能,优先使用 atomic 进行 read 操作,然后再进行加互斥锁操作进行 dirty 的操作,在读多写少的情况下也可以使用 syncmap。

无锁化设计方案之一就是利用消息队列,对于秒杀系统的秒杀操作进行异步处理,将秒杀操作发布一个消息到消息队列中,这样所有用户的秒杀行为就形成了一个先进先出的队列,只有前面先添加到消息队列中的用户才能抢购商品成功。从队列中消费消息进行库存变更的线程是个单线程,因此对于 db 的操作不会存在冲突,不需要加锁操作

分片

Redis 对于命令的执行过程是单线程的,单机有着很好的读写性能,但是单机的机器容量跟连接数毕竟有限,因此单机 Redis 必然会存在读写上限跟存储上限。Redis 集群的出现就是为了解决单机 Redis 的读写性能瓶颈问题,Redis 集群是将数据自动分片到多个节点上,每个节点负责数据的一部分,每个节点都可以对外提供服务,突破单机 Redis 存储限制跟读写上限,提高整个服务的高并发能力。除了官方推出的集群模式,代理模式 codis 等也是将数据分片到不同节点,codis 将多个完全独立的 Redis 节点组成集群,通过 codis 转发请求到某一节点,来提高服务存储能力和读写性能。 同样的 Kafka 中每个 topic 也支持多个 partition,partition 分布到多个 broker 上,减轻单台机器的读写压力,通过增加 partition 数量可以增加消费者并行消费消息,提高 Kafka 的水平扩展能力和吞吐量。 在某次微服务重构中,需要进行数据同步,将总库中存储的全量数据通过 Kafka 同步到内容微服务新的存储中,预期同步 QPS 高达15k。由于 Kafka 的每个 partition 只能通过一个消费者消费,要达到预期 QPS,因此需要创建750+partition 才能够实现,但是 Kafka 的 partition 过多会导致 rebalance 很慢,影响服务性能,成本和可维护行都不高。采用分片化的思想,可以将同一个 partition 中的数据,通过一个消费者在内存中分片到多个 channel 上,不同的 channel 对应的独立协程进行消费,多协程并发处理消息提高消费速度,消费成功后写入到对应的成功 channel,由统一的 offsetMaker 线程消费成功消息进行 offset 提交,保证消息消费的可靠性。

顺序写

顺序写

MySQL 的 InnoDB 存储引擎在创建主键时通常会建议使用自增主键,而不是使用 uuid,最主要的原因是 InnoDB 底层采用 B+树用来存储数据,每个叶子结点是一个数据页,存储多条数据记录,页面内的数据通过链表有序存储,数据页间通过双向链表存储。由于 uuid 是无序的,有可能会插入到已经空间不足的数据页中间,导致数据页分裂成两个新的数据页以便插入新数据,影响整体写入性能。

此外 MySQL 中的写入过程并不是每次将修改的数据直接写入到磁盘中,而是修改内存中 buffer pool 内存储的数据页,将数据页的变更记录到 undolog 和 binlog 日志中,保证数据变更不丢失,每次记录 log 都是追加写到日志文件尾部,顺序写入到磁盘。对数据进行变更时通过顺序写 log,避免随机写磁盘数据页,提升写入性能,这种将随机写转变为顺序写的思想在很多中间件中都有所体现。

MQ

rocketmq的事务消息

image.png 其核心原理和流程是:

1)分布式事务发起方在执行第一个本地事务前,向 MQ 发送一条事务消息并保存到服务端,MQ 消费者无法感知和消费该消息 ①②。

2)事务消息发送成功后开始进行单机事务操作 ③:

a)如果本地事务执行成功,则将 MQ 服务端的事务消息更新为正常状态 ④;

b)如果本地事务执行时因为宕机或者网络问题没有及时向 MQ 服务端反馈,则之前的事务消息会一直保存在 MQ。MQ 服务端会对事务消息进行定期扫描,如果发现有消息保存时间超过了一定的时间阀值,则向 MQ 生产端发送检查事务执行状态的请求 ⑤;

c)检查本地事务结果后 ⑥,如果事务执行成功,则将之前保存的事务消息更新为正常状态,否则告知 MQ 服务端进行丢弃;

3)消费者获取到事务消息设置为正常状态后,则执行第二个本地事务 ⑧。如果执行失败则通知 MQ 发送方对第一个本地事务进行回滚或正向补偿。

Redis

redis big key问题

大key定义:单个string类型的key大小达到20Kb并且QPS高,单个string类型key大小100kb,集合类型的key元素超过5000个。

影响:redis单线程,大key处理会比较耗时,会发生阻塞。网络打满,阻塞。阻塞工作线程

如何解决: 分片:比如我们可以有一个key,拆分10个,加上随机前缀0-9,每次put的时候,可以根据比如订单idhash 到其中一个,进行操作 压缩: 外部存储:将大二进制对象,大json放到对象存储,redis保留url

集合比较大:

分桶:根据范围或者hash拆分多个set/zset

引用

mp.weixin.qq.com/s/EWeGzht0P…