2023年8月,通过xianling哥的讲解,终于大彻大悟分布式系统了!
分布式出现的思想:为了用廉价的、普通的机器完成昂贵的大型主机无法完成的计算、存储任务。其目的是利用更多的机器,处理更多的数据。
一、其实你早就接触了什么是分布式、什么是中间件
但凡有两台服务器不在一起,就是分布式系统。
分布式不是近几年出现的技术,在2000年初期,阿里的业务一个服务器就放不下了,那时候就在研究分布式了。
单体架构 VS 分布式架构
缺点:
但凡是一个标点符号写错了,都要整个系统重新编译发布,维护成本太高
一个系统的所有功能必须使用同一种语言开发,如果想要换语言,就要全部推翻重来。但是分布式系统的不同功能模块可以用不同的语言开发(比如商品模块用Java,订单模块用go语言),因为消息中间件可以屏蔽语言之间的差异。
二、理解分布式计算和分布式存储
二者区别
分布式计算:
- 应用服务器的解耦。比如阿里巴巴以前只用一台服务器做用户注册、订单、支付、售后等所有的服务。现在拆分成用户注册服务器、订单提交服务器、支付服务器等。
- 以前不注重服务的可用性的时候,订单系统可能只部署一个。但是我们现在考虑万一这个宕掉了怎么办? 所以同一个服务至少要部署两个(这里服务的意思就是整个project,部署的时候不是以接口为单位,而是以project为单位。当然部署了多个project,那么一个接口也就有多个咯),当然这两个可以在同一台机器上,也可以在不同的机器上。一般是在不同的机器上,因为万一机器宕掉了,两个在同一台机器上的话不就都完蛋了吗
分布式存储:就是mysql集群。数据量太大了,单机mysql放不了了,就把数据分库分表放到10台mysql服务器上,每台服务器各存1/10的数据。
三、CAP和BASE理论
四、分布式系统进程间通信(分布式系统的核心)
分布式的核心内容是IPC进程间通信,注意是服务器和服务器之间的通信,有如下几种方式:
RPC 远程过程调用:屏蔽底层细节,调用远程方法就像调用本地方法一样(同步)
client stub 和 server stub
RMC 远程方法调用(同步)
MOM 消息中间件(不要求双方在线,异步)
HTTP(spring cloud NetFlix就是用HTTP通信)
既然是两台机器之间的通信,为什么不直接用TCP/IP?
首先,不是不用TCP/IP,而是把TCP/IP作为通信的底层,在TCP/IP之上构建RPC、RMC、和MOM的框架。因为TCP/IP的字段不能满足进程间的需求,我们要新增一些字段,所以就要在TCP/IP协议的基础上构造中间件自己的协议。换句话说,消息中间件的底层协议还是TCP/IP协议
其实可以用HTTP请求,但是我们不用,道理很简单,比如出门防晒你其实可以把塑料袋套头上,但是哪有戴帽子合适呀!不用HTTP实现两个机器的通信是因为,HTTP一般用于客户端对服务器的一次性请求,建立短链接。但是分布式系统的进程间通信不是客户端和服务器的通信,而是多个服务器之间的通信。比如有一个订单业务服务器,一个会员业务服务器,二者之间如何交互,这是一个长期通信的过程,需要建立长链接。
我们需要在TCP/IP基础上自己重新构建一种通信框架,也就是RPC、RMC和MOM。
MOM分类
分布式消息中间件
ActiveMQ
不用了
RabbitMQ
开源
和spring是一家开发出来的,和spring的适配性最高
美团、滴滴都在用
Kafka
性能最好,但不支持事务
京东的JMQ就是在kafaka外面套了一个壳
RocketMQ
阿里和滴滴自研的
消息中间件MOM理解
RPC和RMC通信的缺点
要求client和server同时在线
client会被阻塞,什么都不能干(这是同步的一种特点,异步的话就是两个线程各干各的,谁也不需要等谁)
MOM的优点
不要求client和server同时在线
延迟隐藏。client请求完了以后,直接去做下一件的任务,感受不到client的阻塞
MOM 都有哪些协议?
MOM如何实现的异步?
借助消息队列。
MOM工作流程
不同消息格式的转换,
client订购消息,server推送消息
五、分布式系统下如何生成UUID
林哥给出的生成UUID唯一的方法:
- 法一:MYSQL层校验
- 法二:在service层生成UUID,去MySQL校验是否存在,存在再重新生成(for循环
首先要明确一下,一提到UUID本身指的就是分布式系统下的唯一ID
在单体系统中,我们可以用时间戳,也就是当前时间距离1970年1月1日零点的毫秒数的方式实现ID,但是也会存在同一毫秒内产生两个实体的情况,所以我们采用毫秒数+随机数(浪潮就是这么实现的),但是这样做缺少了验证这一步,虽然99.9999%是唯一的,但是还是不能保证百分之百哦,所以林哥才会反复提到“MySQL层校验”。
生成分布式UUID的常见方案:
- 借助数据库主键
- redis
- 算法,就是想个办法拼凑出来全局唯一的ID
- java原生的UUID(不推荐使用,但是jd也在用)
- 改进java原生的UUID:
java 原生的UUID为36位 or 32位,太长. 这里提供一个位数较短的UUID. * UUID生成规则,当前时间减去'零时'的毫秒数 + N位随机数,转变成62进制的String类型. * 当前配置可满足30年内每毫秒10^9分之一的碰撞. * 实测现在长度为13位,想要更短的话可以调整下方的几个参数
- Twitter开源的雪花算法,没有两篇雪花是相同的。用时间戳,机房ID,机器ID,每台机器上的自增数合成
- 美团开源的落叶算法,同样,“没有两片相同的落叶”
- car-fence用的糊涂工具包hutool
法(一)数据库主键自增
如何让数据库主键自增呢?就要每次索要UUID的时候向表里插入一条数据,当然这条数据本身没有任何意义,只是为了推动主键自增,然后把生成的主键返回给java程序
CREATE TABLE `sequence_id` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `stub` char(10) NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `stub` (`stub`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;优点:实现简单,仅此而已
缺点:
- 每次获取UUID都要访问数据库,访问数据库可比访问redis缓存慢多了,由此可见这种实现方式比不上接下来要将的借助redis实现。
- ID没有业务含义,通过主键看不出这是个啥
- 可以通过主键的差值判断一天产生了多少订单量,这可是商业机密呀
- 淘宝用户有上亿个,每个用户都有UUID,一个表肯定放不下,那么就要分库分表,分库分表也挺累的。
法(二)数据库号段(其实这个我没看懂哈)
批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了,主要是减少了访问数据库的次数。
数据库的号段模式也是目前比较主流的一种分布式 ID 生成方式。像滴滴开源的Tinyidopen in new window 就是基于这种方式来做的。不过,TinyId 使用了双号段缓存、增加多 db 支持等方式来进一步优化
1. 创建一个数据库表。
CREATE TABLE `sequence_id_generator` ( `id` int(10) NOT NULL, `current_max_id` bigint(20) NOT NULL COMMENT '当前最大id', `step` int(10) NOT NULL COMMENT '号段的长度', `version` int(20) NOT NULL COMMENT '版本号', `biz_type` int(20) NOT NULL COMMENT '业务类型', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;一次获取step长度的id,获取的批量 id 为:current_max_id ~ current_max_id+step,version字段主要用于解决并发问题(乐观锁)
2. 先插入一行数据
INSERT INTO `sequence_id_generator` (`id`, `current_max_id`, `step`, `version`, `biz_type`) VALUES (1, 0, 100, 0, 101);3.通过 SELECT 获取指定业务下的批量唯一 ID
业务和业务之间的UUID是独立的,可以重复。可能后续会把biz_type字段拼到UUID里
SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 1014. 不够用的话,更新之后重新 SELECT 即可。
UPDATE sequence_id_generator SET current_max_id = 0+100, version=version+1 WHERE version = 0 AND `biz_type` = 101 SELECT `current_max_id`, `step`,`version` FROM `sequence_id_generator` where `biz_type` = 101优点:相比于上一个方法,减少了访问数据库的次数
缺点: 就是上一个方法的另外3个缺点
- ID没有业务含义,通过主键看不出这是个啥
- 可以通过主键的差值判断一天产生了多少订单量,这可是商业机密呀
- 淘宝用户有上亿个,每个用户都有UUID,一个表肯定放不下,那么就要分库分表,分库分表也挺累的。
法(三)redis的incr自增
incr UUID法(四) 算法们
上面也都说过了
六、分布式锁
先来看一个用Redis实现分布式锁的完整代码:
public List<Node> getDataByRedisLock(){ List<Node> nodeList = null; //===================加锁 String uuid = UUID.randomUUID().toString(); Boolean lock = redisTemplate.opsForValue().setIfAbsent("redis-lock", uuid,100,TimeUnit.SECONDS); if (lock){ try { nodeList = getNodesByMysql(); }finally { //===================解锁 //lua脚本 String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end"; redisTemplate.execute( new DefaultRedisScript<Long>(script,Long.class), Arrays.asList("redis-lock"), uuid ); } }else { //注意,如果是用synchronize这种本地锁的话, //没获取到锁的时候会自动阻塞,然后尝试下一次加锁, //这是synchronize底层帮我们写好了的;但是这里redis得靠我们手动实现 try { System.out.println("没有获取锁重试"); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } //重试 getDataByRedisLock(); } return nodeList; }是不是很简单? 虽然接下来我们要讲一大堆分布式锁的原理,但是实际使用起来,只有两行代码而已,当然咯简单是因为底层封装的好,我们当然要去看一下底层。
分布式锁的实现有很多形式,只不过这里选择了用Redis的setIfAbsent方法实现分布式锁,底层其实是retranlock,是一个悲观锁。
6.1 为什么要有分布式锁?或者说,分布式锁和本地锁的区别在于?
其独特性在于分布式系统中,相同的服务被部署在不同的机器上。单体系统下,对多线程加锁,生成的这个锁放在JVM运行时数据区里,因为所有线程都能查看JVM运行空间,所以大家都能访问到。但是分布式系统下,我在一台机器上生成一个锁,其他机器是访问不到的呀。所以分布式锁必须放在多台机器都能访问到的地方,比如Redis、数据库、消息中间件这种所有机器都能访问到的公共存储区。
6.2 分布式锁的实现方式
常见分布式锁实现方案如下:
- 借助关系型数据库比如 MySQL 实现分布式锁(×,一般是依托于mysql的唯一索引或排它锁去实现,性能太差)
- 借助分布式协调服务 ZooKeeper 实现分布式锁(√)
- 借助分布式键值存储系统比如 Redis 实现分布式锁(√)
6.3 我们今天主要讲的是用Redis实现分布式锁
1、保证上锁的原子性
可以自己先想一下如何实现。
肯定不是用watch命令哈,watch命令实现的是Redis级别的乐观锁,但是我们现在要实现的java程序级别的分布式锁。
加锁就是“占坑”,告诉别人我已经开始用这个资源了,并且加锁之前还要判断别人有没有这个锁,是不是想起来了setnx命令?因为setnx命令就是在set之前要判断一下这个key是否已经存在了。还有一个问题,单体架构下,我服务宕机了,JVM就会关掉,相应的我的锁就消失了。分布式架构下,为了避免还没解锁前服务就宕掉了导致这个资源被一直锁着,所以我们必须在加锁的同时给这个锁设定过期时间,也就是说我们的需求有3个:① set一个键值对,② 判断是否已经存在,③设置过期时间 。且这3个操作不能被打断,也就说必须放在同一个原子操作里,而我们又知道由于Redis是单线程的,所以Redis的一条命令就是一个原子操作,那么我们就需要一条命令同时去做上面的事情。setex可以实现吗?No
好了不绕弯子了,Redis命令是这样实现的:
set 锁的key 锁的value nx ex 50对应到java里,Redistemplate帮我们封装了这样一个方法:
redisTemplate.opsForValue().setIfAbsent(key, value, releaseTime, TimeUnit.SECONDS);(1)俩问题
问题1:
我们又会想,给键值对设置过期时间,过期时间设置多久比较好呢?万一被锁的资源没用完呢,键值对就过期了怎么办?会出现两个情况:
情况①:这个时候线程2跑来尝试加锁,发现居然加上了,那么此时线程1和线程2同时操作资源,会出问题的呀
情况②:而且就算同时操作资源这一步没出问题,此时只有一把锁,但是线程1和2都还没执行删锁操作,线程1会把线程2的锁给删掉!
没关系,还有续约操作~PS: 有的系统不注重性能,可能会把过期时间设置的特别大,这样它就不用考虑续约了。
问题2:
还有一个问题是,主从切换,锁可能会丢。为什么会丢呢?难道redis的从节点数据不是时刻跟主节点保持一致吗?确实不是时刻保持一致。因为redis主从复制用的是RDB快照,从节点的数据是主节点的上一个快照版本,RDB本身就会丢失修改嘛(搬运《Redis(五)—— Redis进阶部分》的解说:试想我们设置一个save 60 10000,60s内发生一万次修改的时候持久化到硬盘,但是如果还没到60s,发生第9999次修改后宕机了,那这9999次修改就丢失了)。
过期的问题可以避免,但主从切换丢失的问题避免不了,如果你的系统业务连这种极端情况都无法忍受的话,那就不要用redis实现分布式锁了,用数据库去实现吧。
2、看门狗续约
续约操作也很复杂,所以直接写了一个第三方包。
对于 Java 开发的小伙伴来说,已经有了现成的解决方案:Redissonopen in new window。其他语言的解决方案,可以在 Redis 官方文档中找到,地址:https://redis.io/docs/manual/patterns/distributed-locks/
jar包:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.15.6</version> </dependency>配置bean:
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ Config config = new Config(); config.setTransportMode(TransportMode.NIO); SingleServerConfig singleServerConfig = config.useSingleServer(); //可以用"rediss://"来启用SSL连接 singleServerConfig.setAddress("redis://linux100:6379"); RedissonClient redisson = Redisson.create(config); return redisson; } }redisson使用起来就跟本地锁一样:
@RestController public class UserController { @Autowired private RedissonClient redissonClient; @GetMapping("/test") public String test() { RLock lock = redissonClient.getLock("test-lock"); try { System.out.println("加锁成功!!!"); Thread.sleep(10000); } catch (Exception e) { e.printStackTrace(); }finally { System.out.println("释放锁成功!!!"); lock.unlock(); } return "success"; } }如果我们不就是不想导入redisson,不采用续约的操作呢?那就会出现上述两个问题
而下面保证删锁原子性的操作只能解决第二个问题,还是存在第一种问题的隐患!
3、保证删锁的原子性(如果采用看门狗的话,那么直接删就行了,完全不用设置UUID)
理想情况下(java线程顺序执行的情况下)肯定是用完资源以后,用redis的del命令删掉这个锁,然后第二个线程继续上锁。但是存在这样一种情况,线程1加锁用资源的时候,超出了过期时间还没用完,因为没采用看门狗续约此时锁过期了,线程2尝试加锁发现加上了!因为锁是同名的,所以线程1最后在finally里执行删锁操作的时候把线程2加的锁给删掉了。
解决方法有两个:
法(一):对过期时间进行续约,这样就不会出现线程1的锁过期,线程2来加锁的情况了,这是从源头解决问题
法(二):给每个锁配备一个UUID作为value,删锁之前用UUID判断一下是不是自己设置的锁
对于法(二),实现如下
还会有一个问题(我靠这个太难想到了,但是操作系统里举过这个例子),因为判断UUID和删锁的操作是分两步实现的:
- 判断UUID
- 删锁
如果线程1判断完UUID确实是自己的,可以开始删了!这个时候刚好线程1被CPU切换走了,刚好线程1的锁过期了,另一台机器上的线程2刚好尝试来加锁发现加上了,然后CPU调度回线程1的时候线程1直接第二步执行删锁操作,又出错了!解决方法只有一个就是让判断UUID和删锁的操作不要分成两步,而是作为一个原子性操作,java实在无能为力了,只能借用Lua脚本。
七、高性能
7.1 分库分表
【推荐】单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。说明:如果预计三年后的数据量根本达不到这个级别,请不要在创建表时就分库分表。
两个问题:
- 原理上如何实现分库分表?
- 工程上如何实现分库分表呢?也就是如何写代码?
1、原理上如何实现分库分表?
假如我们要把order表分成4库16表。两大核心需要确定的:
- 分片键——如何确定根据哪个字段去分库分表
- 首选:user_id(95%的订单查询都带user_id,区分度极高;因为订单表一般是用户自己来查订单的场景比较多,用user_id去分片让同一个用户的订单数据都落入同一个表里而不是分散到各个表里)
- 备选:order_id(适合纯订单维度查询,但是按照订单去查询的场景不多)、
分片键的选取也是一门学问,我理解的是,①哪个字段几乎每次查询都带着,②并且用这个字段可以把读写流量均匀分到每个表里
- 分片算法
- 分库算法:user_id % 4(4个分库,均匀分布)
- 分表算法:user_id % 16(每个库4张表,共16张物理分表)
- 优势:计算简单、数据均匀、扩容可平滑(后续一致性哈希升级)
- 平均去塞16张表,而不是把第一张表塞满了,再塞另一个表!
这样,可用分片号表示出4库16表
- 库名: 业务库名_分片号(2位补零) → order_db_00~ order_db_03
- 表名: 逻辑表名_分片号(2位补零) → order_00 ~ order_03
逻辑库:order_db 、逻辑表order ├── 物理库 01:order_db_00 │ ├── 物理表:order_00 │ ├── 物理表:order_01 │ ├── 物理表:order_02 │ └── 物理表:order_03 │ ├── 物理库 02:order_db_01 │ ├── 物理表:order_00 │ ├── 物理表:order_01 │ ├── 物理表:order_02 │ └── 物理表:order_03 │ ├── 物理库 03:order_db_02 │ ├── 物理表:order_00 │ ├── 物理表:order_01 │ ├── 物理表:order_02 │ └── 物理表:order_03 │ └── 物理库 04:order_db_03 ├── 物理表:order_00 ├── 物理表:order_01 ├── 物理表:order_02 └── 物理表:order_03路由示例(user_id=10086)
step01:计算分库:10086 % 4 = 2 → 落入 order_db_02
step02:计算分表:10086 % 16 = 6 → 6%4=2 → 落入 order_02
step03: 最终路径: order_db_02.order_02
2、工程上如何实现分库分表呢?也就是如何写代码?
思路是,保证物理上是4个库和16个表,但是对应于应用层无感,应用层依然认为是一个库一个表(不然,你今天把order表给拆成4库16表,明天把user表拆成8库32表,我应用层写操作数据库代码的时候怎么记得住你这个表到底分成了多少个??)。所以没有什么是新增一层不能解决的。需要一个中间件shading jdbc,通过加一层的方式实现应用无感知
- 数据库层面:先建好4个库和16个表。
- 应用层面
- maven引入shading jdbc的依赖
- 在application.yml里配置所有需要分库分表的表分成了4库16表还是8库32表
八、高可用
8.1 幂等性
这张图的顺序从前往后看,每个节点都可以拦住重复交易。
(1)前端:用户点击一次提交后按钮变灰,或者立即弹出一个小窗口“提交成功”,防止用户重复点击。但是有句话叫“永远不要相信前端传来的数据”,前端可能卡bug,所以后端还是要自己给自己兜底
(2)继续往后看,前端给后端发送的这个节点
可以通过token的唯一性校验住
(3)后端入口处校验
可以通过业务唯一键去校验住,并且这个业务唯一键一定要入库,而且在数据库加唯一索引。这一步是最重要的。举例,24年所在的系统的业务唯一键就是文件名,每一笔交易来了以后,去库里查这个文件名是否用过,并且给这个文件名加了唯一索引。再比如,对于注册交易来说,邮箱、手机号、用户名都可以用来作为业务唯一键,一旦库里有了就说明用户进行了重复点击。
PS: 订单号、流水号这些并不是业务唯一键。比如我就是可以买完一件衣服,我同样的流程再买一件一样的。再比如,我就是想同一笔钱转两次。这些情况下就是可以生成两个不同的订单号呀。
(4)调用实时接口。
如果是调用自己系统另一个微服务的接口,可以通过查数据库的状态判断,但是如果是调用别人系统的接口,如果try-catch到超时了,那重发一次还是如何处理?另一个接口多半是没有对于幂等性的处理的(你发两遍,他就认为你要转两次账)。这时如果你在catch里直接重发一次,万一上次其实成功了,你付了两次款、发了两张券,那就是生产事故。24年所在的系统,因为涉及到转账,所以catch到超时直接告诉用户失败,让用户再来一遍。
(5)发MQ消息
跟TCP握手的原理一样,如果是投递消息的路上丢了,导致消费端没收到ACK,那再投一遍,这种情况下消费端依然只执行了一遍。没有问题,如果是ACK回复丢了或者在路上超时,导致消息又投递了一遍,那么消费端的业务就会执行两遍。处理方式参考TCP握手——TCP的处理方式就是A->B建立连接的时候,B维护一个注册表,把A注册进去,这样就算B给A回复的第二次握手ACK丢了,A再次发一遍第一次握手,B一看注册表里有,就不会开俩连接了。同理,消费端去维护一张消息注册表,用消息ID作为唯一主键,如果插入失败,就表示已经收到过了。
(6) 被回调两次
跟调实时接口一样,也得先搞清楚另一个系统那边什么情况:其他系统回调我们两次,如果他们不去处理这种情况的话,我们就得处理。或者换句话说,作为一个高可用的系统,除了不能相信前端发来的任何数据之外,也不能相信其他系统发来的任何请求。
要做的话如何做:处理方式得看回调后你做的业务
1)如果只是把回调发你的结果给入库,并且回调的某个信息作为唯一主键的话,那我处理的方式就可以insert的部分try-catch一下,重复插入不报错,直接内部消化。
2)如果后面流程比较复杂,那就绝对不能做两遍,这时候就可以用状态机,回调前一个状态“等待回调”,回调后一个状态“已回到”,两个动作——每次回调前,判断这笔交易是否是“等待回调”状态,第一次收到回调的时候,把状态改为“已收到”
8.2 超时、重试、限流、熔断、降级
//todo这5个暂时认为是一个组合,先写一起,等哪天学的更明白,发现不是这么回事,再拆出来。
8.2.1 超时
非常好理解,上游系统的时间预算要盖住自己的处理时间和下游系统的时间预算。比如一个请求经过A、B、C三个系统,A时间预算是3000ms,B的时间预算是2200ms,C的时间预算是1200ms。
给A系统配置了预算时间3000ms以后,A系统自请求进入时开始计时,如果到了3秒,不管下游B系统是否返回,A系统自己都会抛错超时。
讲的时候是自顶向下分析,但设计的时候只能从低向上设计。最底层的C系统先定下自己的超时预算(可以用压测得到的P99再扩大一点),告诉B系统的开发人员,B系统的开发人员在此基础上加上自己的处理时间,作为。。(或者直接用压测得到的P99?)
当然这只是针对实时接口需要给客户实时响应。如果是异步去做的,就没有DeadLine这个东西。
8.2.2 重试
重试的底层逻辑:首先要判断能不能重试,能的话,如何定义重试的次数和重试的时间间隔。
1、只重试可重试的
- 一般只重试超时、连接断开、临时 5xx、限流后明确允许重试的响应
- 下游接口文档里会写,哪些错误码可以重试(比如返回错误码“XXXX”表示没准备好的时候可以重试),哪些错误码表示确定失败不需要重试,比如下游都告诉你“余额不足”了,你还重试什么?
- 幂等性: 扣款这种,发送了扣款接口,超时了,不知道那边到底执行没有,这就要看下游接口有没有幂等性了,没有幂等性的话,你再发一次,可能扣了两笔。
这里正好讲一下重试的风险:
重试风暴跟redis缓存击穿一个意思,规避方法也一个意思,就是“打散”。
重试雪崩跟redis缓存雪崩也一个意思,。。
2、重试的策略
高级策略的目标只有一个,就是打散多个客户端的同步重试,跟优秀的哈希算法会让哈希值均匀分布一个道理。解释:假设1000个客户端同时遇到故障,它们会按照1秒、2秒、4秒的节奏同步发起重试,形成一波波越来越猛烈的“重试风暴”或“雷鸣群效应”。而抖动(Jitter) 正是为了解决这个问题而生。它在计算出的退避时间上增加一个随机值,打散了这1000个客户段的重试
3、重试的次数
在线同步请求的重试次数通常要很克制,常见是 1~3 次。异步的(包括MQ)或者自动任务重试,可以更多。
8.2.3 限流
两个地方可以限流
- 网关限流
- 每个微服务入口自己限流
限流的4个算法,都很容易理解!
- 固定窗口计数器算法
- 思想:比如规定一分钟内只能处理30个请求,就维护一个计数器count,每分钟清零一次,来一个请求count就加一,当count=30个时,再来的一律拒绝
- 优点:实现简单
- 缺点:
- 不够平滑,如果前20秒就满了30个请求,那么后面40秒就不对外提供服务了。
- 容易造成爆发。比如规定窗口是“1分钟最多1000个”,极端情况下,假如第一分钟的前59秒一个都没来,最后1秒来了1000个,第二分钟的第1秒来了1000个,这一下就要处理2000个
- 滑动窗口计数器算法
- 思想:将固定大小的大窗口划分为n个小窗口,每个小窗口最多处理“总请求数/n”个请求
- 优点:控制的粒度变小了,上面固定窗口计数器算法俩缺点都能解决
- 缺点:依然不够平滑。比如我小窗口的粒度是“1秒处理3个请求”,那第一秒来了4个请求,就会被扔掉一个,第二秒来了3个,全部处理,第三秒来了4个请求,又被扔掉一个。解决方法看漏桶算法
- 漏桶算法
- 思想:跟消息队列的思想差不多,维护一个固定容量大小的桶,请求来了以后,装到桶里(这个速率是我管不着的),请求像水一样匀速低落的速率是我可以控制的。当桶满了的时候,多余的水直接扔掉。实现方式就是队列。
- 优点:桶匀速漏水(固定的速率处理请求),所以很平滑
- 缺点:也正是因为桶匀速漏水,难以对付激增的流量
实际业务场景中,漏桶算法的适用范围相对窄一些。当业务需要允许一定程度的突发流量时,令牌桶通常更合适;但在需要严格控制输出速率的场景(如对接有明确 QPS 限制的第三方 API)中,漏桶仍然是合适的方案。
4. 令牌桶算法
思想:主要是针对令牌桶的“匀速”进行一个改进 ,有了令牌以后,可以漏水的速度可以弹性伸缩。此时,水滴代表令牌,以固定速率生成令牌,放到桶里,请求来了以后直接拿令牌,只要有令牌就可以直接拿走,不用等,拿到令牌的请求才可以被处理。
8.2.4 降级
限流管入口。它解决的是"流量太多,要不要都放进来"。比如秒杀时 10 万个请求同时打进来,系统只能扛 1 万个,那就必须拦掉一部分。
熔断管下游。它解决的是"下游已经慢了或挂了,要不要继续调"。如果继续调,下游恢复不了,自己也会被拖垮。
降级管结果。它解决的是"请求被限流了、下游被熔断了、业务出异常了,接下来给用户返回什么"。可以返回默认值、缓存数据、静态页面,也可以直接关闭某个非核心功能。
更口语一点说:
- 限流:别让太多请求进来
- 熔断:别再打已经异常的下游
- 降级:出问题后换一种方式继续服务
- Fallback:真正返回的兜底结果
1. 延迟处理
有些操作没必要同步完成,可以先放到 MQ 里慢慢处理。
比如评论后发积分、统计用户行为、生成报表,这类操作即使晚几秒完成,用户一般也感知不到。主流程只要先返回成功,即“受理即成功”,后面的事情交给异步任务处理即可。
但这里有个坑:MQ 也可能积压。如果消费者已经处理不过来了,生产者还一直重试,反而会把系统打得更严重。所以重试要加退避和随机抖动,必要时还要限流。
2. 页面片段降级
这是最直观的一种方式:页面主体保留,把非核心区块关掉。
比如商品详情页里,商品标题、价格、库存要保;推荐区、广告位、活动挂件可以先隐藏。
这种方式简单有效,但前提是你提前做好了开关和兜底逻辑。真出问题时再改代码、再发版,基本就来不及了。
3. 接口兜底降级
页面里有些接口是异步加载的,比如配送时效、价格预测、推荐列表。它们如果超时,可以直接返回默认值或缓存值。
比如配送时效接口异常时,可以先展示“预计 2-3 天送达”;推荐接口异常时,可以返回缓存里的热门商品。
这里要注意一点:兜底数据最好提前准备好。不要等接口已经挂了,才临时去查数据库或重新生成缓存,那样很可能把故障继续放大。