记录些Spring+题集(55)

最系统的幂等性方案:一锁二判三更新

什么是幂等性?

所谓幂等性,就是一次操作和多次操作同一个资源,所产生的影响均与一次操作的影响相同。

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。幂等性,用数学语言表达就是:

f(x)=f(f(x))

维基百科的幂等性定义如下:

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的,更复杂的操作幂等保证是利用唯一交易号(流水号)实现.

在软件或者系统中,重复使用幂等函数或幂等方法不会影响系统状态,也不用担心重复执行会对系统造成改变。

通俗点说:

一个接口如果幂等,不管被调多少次,只要参数不变,结果也不变。幂等性是对于写操作来说的,一个写操作,一般都需要保证:

  • 幂等性

  • 可用性

  • ACID事务属性。

当然,这里仅仅聚焦 幂等。

为什么需要幂等性?

如果客户端重复调用,服务端会遇到如下的很多问题:

  1. 创建订单时,重复调用是否产生两笔订单?

  2. 扣减库存时,重复调是否会多扣一次?

这就是出现了幂等性问题。按照幂等性要求,需要保证一次请求和多次请求同一个资源产生相同的副作用。

所以:创建订单时,重复调用是否产生两笔订单?当然不能。

所以:扣减库存时,重复调是否会多扣一次?当然不能。

这些,都是需要幂等性机制去保障。如果不支持幂等操作,那将会出现以下情况:

  • 电商超卖现象

  • 重复转账、扣款或付款

  • 重复增加金币、积分或优惠券

什么样的原因导致幂等性问题?

原因之一:底层网络阻塞和延迟的问题

在系统高并发的环境下,很有可能因为网络阻塞等等问题,导致客户端不能及时的收到服务端响应,甚至是调用超时。这时候用户会重复点击,重复请求。

在消息队列组件中,客户端也有重试机制,如果投递失败/投递超时,则会重新投递。对于服务端来说,可能会收到重复投递的一份消息。

在RPC组件中,客户端也有重试机制,如果投递失败/投递超时,则会重试调用。对于服务端来说,可能会重复收到通用的调用。

原因之二:用户层面的重复操作

比如下单的按键在点按之后,在没有收到服务器请求之前,用户还可以被按。

或者,用户的App闪退/人工强退,之后重新打开重新下单

需要幂等性的 两大场景

可能会发生重复请求或重试操作的场景,在分布式、微服务架构中是随处可见的。

  • 网络波动:因网络波动,可能会引起重复请求

  • 分布式消息消费:任务发布后,使用分布式消息服务来进行消费

  • 用户重复操作:用户在使用产品时,可能无意地触发多笔交易,甚至没有响应而有意触发多笔交易

  • 未关闭的重试机制:因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)

大致可以分为两大类:

  • 第一类:单数据CRUD操作的幂等性保证方案

  • 第二类:多数据并发操作的幂等性保证方案

第一类:单数据CRUD操作的幂等性保证方案

首先,来看看单数据CRUD操作的幂等性保证方案,对于单数据CRUD操作,很多具备天然幂等性

  • 新增类动作:不具备幂等性

  • 查询类动作:重复查询不会产生或变更新的数据,查询具有天然幂等性

  • 更新类动作:

    • 基于主键的计算式Update,不具备幂等性,即UPDATE goods SET number=number-1 WHERE id=1

    • 基于主键的非计算式Update:具备幂等性,即UPDATE goods SET number=newNumber WHERE id=1

    • 基于条件查询的Update,不一定具备幂等性(需要根据实际情况进行分析判断)

  • 删除类动作:

    • 基于主键的Delete具备幂等性

    • 一般业务层面都是逻辑删除(即update操作),而基于主键的逻辑删除操作也是具有幂等性的

大家看到,对于单数据CRUD操作, 只有在下面的三个场景,保证幂等即可:

  • 主键的计算式Update

  • 基于条件查询的Update

  • 新增类动作

第二类:多数据并发操作的幂等性保证方案

现在的应用,大部分都是微服务的。并且一个操作会涉及到多个数据的并发操作,会通过RPC调用到多个微服务。

分为两种情况:

  • 多数据同步操作,一般是服务端提供一个统一的同步操作api,客户端调用该api完成,直接获得操作结果。

  • 多数据异步操作,由于同步操作性能低,在高并发场景都会同步变异步,于是乎,服务端还要额外提供一个查询操作结果的api,去查询结果。第一次超时之后,调用方调用查询接口,如果查到了就走成功的流程,失败了就走失败的流程。

多数据并发操作的经典场景,参考如下:
1. 高并发抢红包

在抢一份红包的时候,点击了抢,开始异步抢红包。抢到就有,没抢到就没有。抢完之后,无论我们重复点击多少次,红包都会提示你已经抢过该红包了。

2. 高并发下单

高并发下单的一个很基本的问题,就是要避免重复订单。如果用户操作一次,由于超时重试等原因,一看下了两个单,甚至10个重复单。

3. 高并发支付

在支付场景,支付平台会生成唯一的支付连接,不会再次生成另外的支付连接。

如何保证幂等呢 ?

幂等性的的确保方案,非常多,大致如下图所示

图片

一些基础性的幂等性解决方案

全局唯一ID

如果使用全局唯一ID,就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。

如果不存在则把全局ID,存储到存储系统中,比如数据库、Redis等。如果存在则表示该方法已经执行。

使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。一般情况下,对分布式的全局唯一id,可以参考以下几种方式:

  • UUID

  • Snowflake

  • 数据库自增ID

  • 业务本身的唯一约束

  • 业务字段+时间戳拼接

唯一索引(去重表)

这种方法适用于在业务中有唯一标识的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识

这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据写入去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。

插入或更新(upsert)

这种方法插入并且有唯一索引的情况,比如我们要关联商品品类,其中商品的ID和品类的ID可以构成唯一索引,并且在数据表中也增加了唯一索引。这时就可以使用InsertOrUpdate操作。

多版本控制

这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等:

boolean updateGoodsName(int id,String newName,int version);

在实现时可以如下:

update goods set name=#{newName},version=#{version} where id=#{id} and version<${version}
状态机控制

这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100,付款失败为99。

在做状态机更新时,我们就可以这样控制:

update goods_order set status=#{status} where id=#{id} and status<#{status}

以上就是保证接口幂等性的一些方法。

综合性的解决方案:一锁二判三更新

前面的方案,都是一些基础性的方案。在实际的业务中,一般会结合起来使用。

在双11和双12的活动中,对于幂等性问题,支付宝团队摸索出来了一个综合性的解决方案:一锁二判三更新。这个方案,可以作为一个比较通用的综合性的幂等解决方案。

何为“一锁二判三更新”?

简单来说就是当任何一个并发请求过来的时候

  • 1. 先锁定单据

  • 2. 然后判断单据状态,是否之前已经更新过对应状态了

  • 3.1  如果之前并没有更新,则本次请求可以更新,并完成相关业务逻辑。

  • 3.2  如果之前已经有更新,则本次不能更新,也不能完成业务逻辑。

图片

一锁、二判、三更性的核心步骤

第一步:先加锁。

高并发场景,建议是redis分布式锁,而不是低性能的DB锁,也不是CP型的 Zookeeper锁。

如果普通的redis分布式锁性能太低,该如何?

还可以考虑引入 锁的分段机制, 比如内部分成100段,总体上,就大概能线性提升 100倍。

第二步:进行幂等性判断。

幂等性判断,就是 进行 数据检查。

可以基于状态机、流水表、唯一性索引等等前面介绍的 基础方案,进行重复操作的判断。

第三步:数据更新

如果通过了第二步的幂等性判断, 说明之前没有执行过更新操作。

那么就进入第三步,进行数据的更新,将数据进行持久化。

操作完成之后, 记得释放锁, 结束整个流程。

什么是泛型擦除、泛型上界、泛型下界、PECS原则?

什么是泛型

泛型的本质是 类型参数化,解决类型爆炸的问题。所谓泛型是指将类型参数化,以达到代码复用提高软件开发工作效率的一种数据类型。

比如:如果我们的代码中存在很多的 食物类型, 继承关系如下

图片

然后我们要定义一个盘子 plate,注意这个盘子除了  装入食物food之外,还可以装其他的比如 小玩具。为了装不同类型的食物,我们需要定义不同的盘子:

(1) 装水果的盘子  FruitPlate

(2) 装肉的盘子  MeatPlate

(3) 装苹果的盘子  ApplePlate

(4) 装香蕉的盘子  BananaPlate

.....

(N) 装云南苹果的盘子  YunnanFruitPlate

这就是盘子类型的  类型爆炸。如何解决上面的类型爆炸问题呢? 这就要用到泛型。那么盘子里的东西的类型,我们就用泛型。

//盘子里的东西
private T someThing;

图片

从这个例子看到:泛型是一种类型占位符,或称之为类型参数

如何使用呢?

public static void main(String[] args) {

    //创建一个装肉的盘子
    PlateDemo1<Meat> plateDemo1 =new PlateDemo1<>(new Pork());

    //创建一个装水果的盘子
    PlateDemo1<Fruit> plateDemo2 =new PlateDemo1<>(new Apple());

}

所谓泛型,就是 数据类型 指定为一个参数,在不创建新类的情况下,通过创建变量的时候去确定 数据的具体类型。

也就是说,在创建对象或者调用方法的时候才明确下具体的类型。

泛型定义:泛型类、泛型接口、泛型方法

泛型定义格式:

<类型>:指定一种类型的格式,这里的类型可以看做是形参

<类型1,类型2...>:指定多种类型的格式,多种类型之间用逗号隔开。定义的时候是泛型形参

这个泛型形参,将来具体调用时候,需要有给定的类型,那个给定的具体的Java类型可以看出是实参。

泛型可以在类、接口、方法中使用,分别称为泛型类、泛型接口、泛型方法。

图片

第一类:泛型类 定义格式:

修饰符 class 类名<类型> { }

上面的例子就是 泛型类

//盘子,可以装 任何东西,包括 食物 其他
class PlateDemo1<T> {

    //盘子里的东西
    private T someThing;
}

第二类:泛型方法

定义格式:

修饰符 <泛型类型> 返回值类型 方法名(类型 变量名) { }

示例代码:

public <T> void demo(T t) { 
    ...
} 

第三类:泛型接口

定义格式:

修饰符 interface 接口名<类型> { }

示例代码:

public interface Generic<T> { 
    void demo(T t); 
}

泛型接口的实现类

public class GenericImpl<T> implements Generic<T> { 
    public void demo(T t) { 
      ...
    } 
}

为啥不用Object做泛型化?

没有泛型的情况的下,好像Object也能实现简单的 泛化。通过定义为类型Object的引用,来实现参数的“任意化”。

比如上面的例子的 泛型类

//盘子,可以装 任何东西,包括 食物 其他
class PlateDemo1<T> {

    //盘子里的东西
    private T someThing;
}

通过定义为类型Object的引用,来实现参数的“任意化”,结果如下

//盘子,可以装 任何东西,包括 食物 其他
class PlateDemo1   {

    //盘子里的东西
    private Object  someThing;
}

Object实现参数的 “泛型化”、“任意化”带来的缺点是:要做显式的强制类型转换。参数类型强制转换有一个大大降低代码复用性和扩展性的坏处:

  • 首先,要求开发者对实际参数类型可预知。

  • 其次,不利于未来的 扩展。

泛型的两大好处

而引入泛型后,有如下好处:

1、避免了强制类型转换,提高代码的复用性和扩展性

泛型中,所有的类型转换都是自动和隐式的,不需要强制类型转换,可以提高代码的重用率,再加上明确的类型信息,代码的可读性也会更好。

2、把运行时期的问题提前到了编译期,编译时的类型检查,使程序更加健壮

使用普通的Object泛化,对于强制类型转换错误的情况,编译期不会提示错误,在运行的时候才出现异常,这是一个安全隐患。

泛型的好处是在编译期检查类型安全,并能捕捉类型不匹配的错误,避免运行时抛出类型转化异常ClassCastException,将运行时错误提前到编译时错误,消除安全隐患。

正是由于以上两点原因,泛型得到了广泛的应用。

比如Java中,所有的标准集合接口都是泛型化的:Collection<V>List<V>Set<V> 和 Map<K,V>

图片

泛型的上界/ 上界通配符(Upper Bounds Wildcards)

现在我定义一个“水果盘子”,用来装苹果, 逻辑上水果盘子当然可以装苹果。

那么,一个“装苹果的盘子”,能转换成一个“装水果的盘子”吗?

看下面的例子

图片

那么,一个“装苹果的盘子”,能转换成一个“装水果的盘子”吗? 答案是不行的。

编译器 的逻辑是这样的:

  • 苹果  is-a  水果

  • 装苹果的盘子 not  is-a   装水果的盘子

也就是说:就算 苹果  is-a 水果,但容器之间是没有继承关系的。

怎么办?这里用到了  泛型上界。   泛型上界是这么定义的:

<?extends 基类B> 

<?extends 基类B>  表示泛型实参类型的上界是“基类B”,

换句话说,泛型实参的类型,可能是“基类B” 或者是“基类B”的子类;

修改之后的例子如下,使用 泛型上界通配符(Upper Bounds Wildcards)后,编译器就不报错误了:

图片

使用(Upper Bounds Wildcards)通配符作为泛型实参,所定义 PlateDemo1<?extends Fruit> 引用,可以 覆盖下图中方框内部的所有子类的  泛型对象。

图片

<?extends T>  表示类型的上界,参数化类型可能是T 或者是 T的子类;

PlateDemo1<?extends Fruit> 引用,可以 覆盖下图中方框内部的所有子类的  泛型对象,编译器都不报错,下面的代码如下:

图片

为啥<?extends Fruit> 叫做 上界,而不叫下界? 原因是:这个通配符,定义了实参的类型上限 为 Fruit,具体如下图:

图片

上界通配符(Upper Bounds Wildcards)的问题

上界通配符(Upper Bounds Wildcards)的作用,实现了 子类泛型对象 到 父类Java泛型对象之间的引用转换。但是,这样的引用转换也有一定的副作用。

具体如下:

图片

通过例子可以看到:

(1)往基类盘子,set( ) 任何对象,都 失效了

(2)从基类盘子,get ( )  对象的引用,返回 类型是上界对象,  这个还是 可以的

简单来说:上界<? extends T>不能往里存,只能往外取

所以,上界通配符(Upper Bounds Wildcards)什么时候用,什么时候不用呢:

(1)当从集合中获取元素进行操作的时候用,可以用当前元素的类型接收,也可以用当前元素的父类型接收。

(2)往集合中添加元素时,不能用上界通配符(Upper Bounds Wildcards)。

泛型的下界/ 下界通配符(Lower Bounds Wildcards)

往集合中添加元素时,不能用上界通配符(Upper Bounds Wildcards)。

怎么办呢?Java也提供了一种通配符,叫做 泛型的下界/ 下界通配符(Lower Bounds Wildcards)。

泛型上界是这么定义的:

<?super 子类C> 

<?super 子类C>  表示泛型实参类型的下界是“子类C”,

图片

<? super T>  表示 T是类型下边界,参数化类型是此T类型的超类型,直至object;

图片

下面的这一张图,下界的感觉更加强烈一些:

图片

下界/ 下界通配符(Lower Bounds Wildcards)的问题

下界/ 下界通配符(Lower Bounds Wildcards) 作用,实现了 复类泛型对象 到 子类Java泛型对象之间的引用转换。但是,这样的引用转换也有一定的副作用。

具体如下:

图片

通过例子可以看到:

(1)往基类盘子,set( ) 任何子类对象,都是OK的

(2)从基类盘子,get ( )  对象的引用是编译错误的,除非是Object类型

简单来说:下界<? super T>可以往里存,但不能向外取,要取只能取Object对象

所以,下界/ 下界通配符(Lower Bounds Wildcards)什么时候用,什么时候不用呢:

(1)当往集合中添加元素时候用,既可以添加T类型对象,又可以添加T的子类型对象

(2)当从集合get ( )  对象的引用时,不能用上界通配符(Upper Bounds Wildcards)。除非get 的是Object类型

PECS原则

PECS原则的全称是Producer Extends Consumer Super

什么是PECS(Producer Extends Consumer Super)原则?PECS原则全称"Producer Extends, Consumer Super",即上界生产,下界消费。

  • Producer Extends 上界生产,就是 生产者使用  “? extends T”通配符。

  • Consumer Super 下界消费,就是消费者使用  “? super T”通配符

最终PECS  (Producer Extends Consumer Super ) 原则

  • 频繁往外读取内容的,适合用上界Extends。

  • 经常往里插入的,适合用下界Super。

在阿里编程规范中,就有这么一条:

【强制】泛型通配符<? extends T>来接收返回的数据,此写法的泛型集合不能使用 add 方法, 而<? super T>不能使用 get 方法,两者在接口调用赋值的场景中容易出错。

1. Producer Extends 上界生产

Producer Extends 上界生产,就是 生产者使用  “? extends T”通配符。

以“? extends T”声明的集合,不能往此集合中添加元素,所以它也只能作为生产者,如下:

图片

所以,使用 “? extends T”  上界,能轻松地成为 producer 生产者,完成

  • 读取元素

  • 迭代元素

这就是 Producer Extends 上界生产,就是 生产者使用  “? extends T”通配符。

2. Consumer Super 下界消费,就是消费者使用  “? super T”通配符

在通配符的表达式中,只有“? super T”能添加元素,所以它能作为消费者(消费其他通配符集合)。

图片

当然,针对采用“? super T”通配符的集合,对其遍历时需要多一次转型。

总之 PECS 就是:

1、频繁往外读取内容的,适合用上界Extends。

2、经常往里插入的,适合用下界Super

明白了泛型、泛型的上界,泛型的下届之后,什么是泛型的擦除。

泛型的类型擦除

前面讲到,泛型的本质是 类型参数化,解决类型爆炸的问题。比如:如果我们的代码中存在很多的 食物类型, 继承关系如下

图片

没有泛型,为了实现去装不同类型的食物,我们需要定义不同的盘子:

(1) 装水果的盘子  FruitPlate

(2) 装肉的盘子  MeatPlate

(3) 装苹果的盘子  ApplePlate

(4) 装香蕉的盘子  BananaPlate

.....

(N) 装云南苹果的盘子  YunnanFruitPlate

如何解决上面的类型爆炸问题呢? 这就要用到泛型。而使用泛型,我们定义一个就可以了:

//盘子,可以装 任何东西,包括 食物 其他
class PlateDemo1<T> {

    //盘子里的东西
    private T someThing;

    public PlateDemo1(T t) {
        someThing = t;
    }
    ....
}

这样,就避免 了 盘子类型的  类型爆炸。尤其在Java中的集合类,如果不用泛型,不知道要定义多少的具体集合类。

那么 Java中的泛型,有一个 类型擦除 的特点:

  • Java的泛型,只在编译期有效。

  • 编译之后的字节码,已经抹除了泛型信息。

所谓的类型擦除(type erasure),指的是泛型只在编译时起作用,在进入JVM之前,泛型会被擦除掉,根据泛型定义的形式而被替换为相应的类型。这也说明了Java的泛型其实是伪泛型。

类型擦除简单来说,泛型类型在逻辑上可以看成是多个不同的类型,实际上都是相同类型。

比如:

Food food = new Fruit(); // 没问题
ArrayList<Food> list= new ArrayList<Fruit>(); // 报错

或者说下面的ArrayList ,在逻辑上看,可以看成是多个不同的类型,实际上都是相同类型

ArrayList<Food> list1
ArrayList<Fruit> list2
ArrayList<Apple> list3
.....

泛型类型在逻辑上可以看成是多个不同的类型,但实际上都是相同的类型。

看下面的例子

图片

类型参数在运行中并不存在,这意味着:

  • 运行期间,泛型不会添加任何的类型信息;

  • 不能依靠泛型参数,进行类型转换。

Java泛型的实现是靠类型擦除技术实现的,类型擦除是在编译期完成的,泛型擦除怎么做呢?

  • 在编译期,编译器会将泛型的类型参数都擦除成它指定的原始限定类型

  • 如果没有指定的原始限定类型则擦除为Object类型,之后在获取的时候再强制类型转换为对应的类型,

  • 因此生成的Java字节码中是不包含泛型中的类型信息的,即运行期间并没有泛型的任何信息。

无界泛型擦除

当泛型类型被声明为一个具体的泛型标识,或一个无界通配符时,泛型类型将会被替代为Object

这也比较容易理解,如  List<?>,PlateDemo1<?>,   当获取元素的时,因为不能够确定具体的类型,所以只能使用Object来接收,

在擦除的时候也是一样的道理,无法确定具体类型,所以擦除泛型时会将其替换为Object类型,如:

图片

上界擦除

当泛型类型被声明为一个上界通配符时,泛型类型将会被替代为相应上界的类型。

主要,这里的上界,指的是用于类型定义场景里边的上界:

图片

而不是变量定义场景里边用到到泛型上界,如下:

 List<? extends Fruit> producer =...;

用泛型上界定义class的时候,指的是用于类型定义,泛型类型将会被替代为相应上界的类型。

图片

下界擦除

下界通配符的擦除,同无界通配符

下届只能定义引用的时候用,在定义类型的时候用不了,所以下界擦除只能替换为Object

图片

下界擦除只能替换为Object

图片

Sentinel熔断降级,是如何实现的?

在微服务架构中,Sentinel 作为一种流量控制、熔断降级和服务降级的解决方案,得到了广泛的应用。Sentinel是一个开源的流量控制和熔断降级库,用于保护分布式系统免受大量请求的影响。

第一个维度,Sentinel主要功能:

一:熔断机制
  1. Sentinel使用滑动窗口统计请求的成功和失败情况。这些统计信息包括成功的请求数、失败的请求数等。

  2. 当某个资源(例如一个API接口)的错误率超过阈值或其他指标达到预设的条件,Sentinel将触发熔断机制。

  3. 一旦熔断触发,Sentinel将暂时阻止对该资源的请求,防止继续失败的请求对系统造成更大的影响。

二:降级机制
  1. Sentinel还提供了降级机制,可以在资源负载过重或其他异常情况下,限制资源的访问速率,以保护系统免受过多的请求冲击。

  2. 降级策略可以根据需要定制,可以是慢调用降级、异常比例降级等。

三:高可用性机制

Sentinel的高可用性主要通过以下方式来实现:

  1. 多节点部署:将Sentinel配置为多节点部署,确保即使一个节点发生故障,其他节点仍然能够继续工作。

  2. 持久化配置:Sentinel支持将配置信息持久化到外部存储,如Nacos、Redis等。这样,即使Sentinel节点重启,它可以加载之前的配置信息。

  3. 集群流控规则:Sentinel支持集群流控规则,多个节点可以共享流量控制规则,以协同工作来保护系统。

  4. 实时监控:Sentinel提供了实时监控和仪表板,可以查看系统的流量控制和熔断降级情况,帮助及时发现问题并采取措施。

四:自适应控制

Sentinel具有自适应控制的功能,它可以根据系统的实际情况自动调整流量控制和熔断降级策略,以适应不同的负载和流量模式。

总的来说,Sentinel的高可用性熔断降级机制是通过多节点部署、持久化配置、实时监控、自适应控制等多种手段来实现的。

这使得Sentinel能够在分布式系统中保护关键资源免受异常流量的影响,并保持系统的稳定性和可用性。

那么,Sentinel是如何实现这些功能的呢?在说说 Sentinel 的基本组件。

第二个维度, Sentinel 的基本组件:

Sentinel 主要包括以下几个部分:资源(Resource)、规则(Rule)、上下文(Context)和插槽(Slot)。

  • 资源是我们想要保护的对象,比如一个远程服务、一个数据库连接等。

  • 规则是定义如何保护资源的,比如我们可以通过设置阈值、时间窗口等方式来决定何时进行限流、熔断等操作。

  • 上下文是一个临时的存储空间,用于存储资源的状态信息,比如当前的 QPS 等。

  • 插槽属于责任链模式中的处理器/过滤器, 完成资源规则的计算和验证。

第三个维度, Sentinel 的流量治理几个核心步骤:

在 Sentinel 的运行过程中,主要分为以下几个核心步骤:

  1. 资源注册:当一个资源被创建时,需要将其注册到 Sentinel。在注册过程中,会为资源创建一个对应的上下文,并将资源的规则存储到插槽中。

  2. 流量控制:当有请求访问资源时,Sentinel 会根据资源的规则进行流量控制。如果当前 QPS 超过了规则设定的阈值,Sentinel 就会拒绝请求,以防止系统过载。

  3. 熔断降级:当资源出现异常时,Sentinel 会根据规则进行熔断或降级处理。熔断是指暂时切断对资源的访问,以防止异常扩散。降级则是提供一种备用策略,当主策略无法正常工作时,可以切换到备用策略。

  4. 规则更新:在某些情况下,我们可能需要动态调整资源的规则。Sentinel 提供了 API 接口,可以方便地更新资源的规则。

通过以上分析,我们可以看出,Sentinel 的核心思想是通过规则来管理和控制资源。这种设计使得 Sentinel 具有很强的可扩展性和灵活性。我们可以根据业务需求,定制各种复杂的规则。

第四个维度, Sentinel 的源码层面的两个核心架构:

回到源码层面,在 Sentinel 源码,包括以下二大架构:

  • 责任链模式架构

  • 滑动窗口数据统计架构

总指挥,Sentinel 是一种非常强大的流量控制、熔断降级和服务降级的解决方案。已经成为了替代Hystrix的主要高可用组件。

Sentinel底层滑动时间窗限流算法怎么实现的?

Sentinel是一个系统性的高可用保障工具,提供了限流、降级、熔断等一系列的能力,基于这些能力做了语意化概念抽象,这些概念对于理解实现机制特别有帮助,所以这里也复述一下。

对于流量控制,有个一个模型:

图片

流量控制有以下几个角度:

  • 资源的调用关系,例如资源的调用链路,资源和资源之间的关系;

  • 运行指标,例如 QPS、线程池、系统负载等;

  • 控制的效果,例如直接限流、冷启动、排队等。

Sentinel 的设计理念是让您自由选择控制的角度,并进行灵活组合,从而达到想要的效果。

Sentinel使用滑动时间窗口算法来实现流量控制,流量统计。滑动时间窗算法的核心思想是将一段时间划分为多个时间窗口,并在每个时间窗口内对请求进行计数,以确定是否允许继续请求。

以下是Sentinel底层滑动时间窗口限流算法的简要实现步骤:

  • 时间窗口划分:将整个时间范围划分为多个固定大小的时间窗口(例如1秒一个窗口)。这些时间窗口会随着时间的流逝依次滑动。

  • 计数器:为每个时间窗口维护一个计数器,用于记录在该时间窗口内的请求数。

  • 请求计数:当有请求到来时,将其计入当前时间窗口的计数器中。

  • 滑动时间窗口:定期滑动时间窗口,将过期的时间窗口删除,并创建新的时间窗口。这样可以保持时间窗口的滚动。

  • 限流判断:当有请求到来时,Sentinel会检查当前时间窗口内的请求数是否超过了预设的限制阈值。如果超过了限制阈值,请求将被拒绝或执行降级策略。

  • 计数重置:定期重置过期时间窗口的计数器,以确保计数器不会无限增长。

这种滑动时间窗口算法允许在一段时间内平滑控制请求的流量,而不是仅基于瞬时请求速率进行限流。

它考虑了请求的历史分布,更适用于应对突发流量和周期性负载的情况。

Sentinel可以用来帮助我们实现流量控制、服务降级、服务熔断,而这些功能的实现都离不开接口被调用的实时指标数据,本文便是关于 Sentinel 是如何实现指标数据统计的。

图片

上图中的右上角就是滑动窗口的示意图,是 StatisticSlot 的具体实现。

StatisticSlot 是 Sentinel 的核心功能插槽之一,用于统计实时的调用数据。

Sentinel 是基于滑动窗口实现的实时指标数据收集统计,底层采用高性能的滑动窗口数据结构 LeapArray 来统计实时的秒级指标数据,可以很好地支撑写多于读的高并发场景。

图片

滑动窗口的核心数据结构

  • ArrayMetric:滑动窗口核心实现类。

  • LeapArray:滑动窗口顶层数据结构,包含一个一个的窗口数据。

  • WindowWrap:每一个滑动窗口的包装类,其内部的数据结构用 MetricBucket 表示。

  • MetricBucket:指标桶,例如通过数量、阻塞数量、异常数量、成功数量、响应时间,已通过未来配额(抢占下一个滑动窗口的数量)。

  • MetricEvent:指标类型,例如通过数量、阻塞数量、异常数量、成功数量、响应时间等。

ArrayMetric 源码

滑动窗口的入口类为 ArrayMetric,实现了 Metric 指标收集核心接口,该接口主要定义一个滑动窗口中成功的数量、异常数量、阻塞数量,TPS、响应时间等数据。

public class ArrayMetric implements Metric {

    private final LeapArray<MetricBucket> data;

    public ArrayMetric(int sampleCount, int intervalInMs, boolean enableOccupy) {
        if (enableOccupy) {
            this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
        } else {
            this.data = new BucketLeapArray(sampleCount, intervalInMs);
        }
    }
  • int intervalInMs:表示一个采集的时间间隔,即滑动窗口的总时间,例如 1 分钟。

  • int sampleCount:在一个采集间隔中抽样的个数,默认为 2,即一个采集间隔中会包含两个相等的区间,一个区间就是一个窗口。

  • boolean enableOccupy:是否允许抢占,即当前时间戳已经达到限制后,是否可以占用下一个时间窗口的容量。

LeapArray  源码

LeapArray 用来承载滑动窗口,即成员变量 array,类型为 AtomicReferenceArray<WindowWrap<T>>,保证创建窗口的原子性(CAS)。

public abstract class LeapArray<T> {

    //每一个窗口的时间间隔,单位为毫秒
    protected int windowLengthInMs;
    //抽样个数,就一个统计时间间隔中包含的滑动窗口个数
    protected int sampleCount;
    //一个统计的时间间隔
    protected int intervalInMs;
    //滑动窗口的数组,滑动窗口类型为 WindowWrap<MetricBucket>
    protected final AtomicReferenceArray<WindowWrap<T>> array;
    private final ReentrantLock updateLock = new ReentrantLock();
    
    public LeapArray(int sampleCount, int intervalInMs) {
        this.windowLengthInMs = intervalInMs / sampleCount;
        this.intervalInMs = intervalInMs;
        this.sampleCount = sampleCount;
        this.array = new AtomicReferenceArray<>(sampleCount);
    }

MetricBucket  源码

Sentinel 使用 MetricBucket 统计一个窗口时间内的各项指标数据,这些指标数据包括请求总数、成功总数、异常总数、总耗时、最小耗时、最大耗时等,而一个 Bucket 可以是记录一秒内的数据,也可以是 10 毫秒内的数据,这个时间长度称为窗口时间。

public class MetricBucket {
    /**
     * 存储各事件的计数,比如异常总数、请求总数等
     */
    private final LongAdder[] counters;
    /**
     * 这段事件内的最小耗时
     */
    private volatile long minRt;
}

Bucket 记录一段时间内的各项指标数据用的是一个 LongAdder 数组,数组的每个元素分别记录一个时间窗口内的请求总数、异常数、总耗时。也就是说:MetricBucket 包含一个 LongAdder 数组,数组的每个元素代表一类 MetricEvent。LongAdder 保证了数据修改的原子性,并且性能比 AtomicLong 表现更好。

public enum MetricEvent {
    PASS,
    BLOCK,
    EXCEPTION,
    SUCCESS,
    RT,
    OCCUPIED_PASS
}

当需要获取 Bucket 记录总的成功请求数或者异常总数、总的请求处理耗时,可根据事件类型 (MetricEvent) 从 Bucket 的 LongAdder 数组中获取对应的 LongAdder,并调用 sum 方法获取总数。

public long get(MetricEvent event) {
    return counters[event.ordinal()].sum();
}

当需要 Bucket 记录一个成功请求或者一个异常请求、处理请求的耗时,可根据事件类型(MetricEvent)从 LongAdder 数组中获取对应的 LongAdder,并调用其 add 方法。

public void add(MetricEvent event, long n) {
     counters[event.ordinal()].add(n);
}

WindowWrap  源码

因为 Bucket 自身并不保存时间窗口信息,所以 Sentinel 给 Bucket 加了一个包装类 WindowWrap。Bucket 用于统计各项指标数据,WindowWrap 用于记录 Bucket 的时间窗口信息(窗口的开始时间、窗口的大小),WindowWrap 数组就是一个滑动窗口。

public class WindowWrap<T> {
    /**
     * 单个窗口的时间长度(毫秒)
     */
    private final long windowLengthInMs;
    /**
     * 窗口的开始时间戳(毫秒)
     */
    private long windowStart;
    /**
     * 统计数据
     */
    private T value;
}

总的来说:

  • WindowWrap 用于包装 Bucket,随着 Bucket 一起创建。

  • WindowWrap 数组实现滑动窗口,Bucket 只负责统计各项指标数据,WindowWrap 用于记录 Bucket 的时间窗口信息。

  • 定位 Bucket 实际上是定位 WindowWrap,拿到 WindowWrap 就能拿到 Bucket。

滑动窗口 统计 源码实现

如果我们希望能够知道某个接口的每秒处理成功请求数(成功 QPS)、每秒处理失败请求数(失败 QPS),以及处理每个成功请求的平均耗时(avg RT),注意这里我们只需要控制 Bucket 统计一秒钟的指标数据即可,但如何才能确保 Bucket 存储的就是精确到 1 秒内的数据呢?

Sentinel 是这样实现的:定义一个 Bucket 数组,根据时间戳来定位到数组的下标。

由于只需要保存最近一分钟的数据。那么 Bucket 数组的大小就可以设置为 60,每个 Bucket 的 windowLengthInMs(窗口时间)大小就是 1 秒。

内存资源是有限的,而这个数组可以循环使用,并且永远只保存最近 1 分钟的数据,这样可以避免频繁的创建 Bucket,减少内存资源的占用。

那如何定位 Bucket 呢?我们只需要将当前时间戳减去毫秒部分,得到当前的秒数,再将得到的秒数与数组长度取余数,就能得到当前时间窗口的 Bucket 在数组中的位置(索引)。

calculateTimeIdx 方法中,取余数就是实现循环利用数组。

如果想要获取连续的一分钟的 Bucket 数据,就不能简单的从头开始遍历数组,而是指定一个开始时间和结束时间,从开始时间戳开始计算 Bucket 存放在数组中的下标,然后循环每次将开始时间戳加上 1 秒,直到开始时间等于结束时间。

private int calculateTimeIdx(long timeMillis) {
    long timeId = timeMillis / windowLengthInMs;
    return (int)(timeId % array.length());
}

由于循环使用的问题,当前时间戳与一分钟之前的时间戳和一分钟之后的时间戳都会映射到数组中的同一个 Bucket,

因此,必须要能够判断取得的 Bucket 是否是统计当前时间窗口内的指标数据,这便要数组每个元素都存储 Bucket 时间窗口的开始时间戳。

比如当前时间戳是 1577017626812,Bucket 统计一秒的数据,将时间戳的毫秒部分全部替换为 0,就能得到 Bucket 时间窗口的开始时间戳为 1577017626000。

//计算时间窗口开始时间戳
protected long calculateWindowStart(long timeMillis) {
    return timeMillis - timeMillis % windowLengthInMs;
}

//判断时间戳是否在当前时间窗口内
public boolean isTimeInWindow(long timeMillis) {
    return windowStart <= timeMillis && timeMillis < windowStart + windowLengthInMs;
}

如何 定位 Bucket?

通过时间戳 定位 Bucket的。

当接收到一个请求时,可根据接收到请求的时间戳计算出一个数组索引,从滑动窗口(WindowWrap 数组)中获取一个 WindowWrap,从而获取 WindowWrap 包装的 Bucket,调用 Bucket 的 add 方法记录相应的事件。

/**
 * 根据时间戳获取 bucket
 * @param timeMillis 时间戳(毫秒)
 * @return 如果时间有效,则在提供的时间戳处显示当前存储桶项;如果时间无效,则为空
 */
public WindowWrap<T> currentWindow(long timeMillis) {
    if (timeMillis < 0) {
        return null;
    }
    // 获取时间戳映射到的数组索引
    int idx = calculateTimeIdx(timeMillis);
    // 计算 bucket 时间窗口的开始时间
    long windowStart = calculateWindowStart(timeMillis);

    // 从数组中死循环查找当前的时间窗口,因为可能多个线程都在获取当前时间窗口
    while (true) {
        WindowWrap<T> old = array.get(idx);
        // 一般是项目启动时,时间未到达一个周期,数组还没有存储满,没有到复用阶段,所以数组元素可能为空
        if (old == null) {
            // 创建新的 bucket,并创建一个 bucket 包装器
            WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
            // cas 写入,确保线程安全,期望数组下标的元素是空的,否则就不写入,而是复用
            if (array.compareAndSet(idx, null, window)) {
                return window;
            } else {
                Thread.yield();
            }
        }
        // 如果 WindowWrap 的 windowStart 正好是当前时间戳计算出的时间窗口的开始时间,则就是我们想要的 bucket
        else if (windowStart == old.windowStart()) {
            return old;
        }
        // 复用旧的 bucket
        else if (windowStart > old.windowStart()) {
            if (updateLock.tryLock()) {
                try {
                    // 重置 bucket,并指定 bucket 的新时间窗口的开始时间
                    return resetWindowTo(old, windowStart);
                } finally {
                    updateLock.unlock();
                }
            } else {
                Thread.yield();
            }
        }
        // 计算出来的当前 bucket 时间窗口的开始时间比数组当前存储的 bucket 的时间窗口开始时间还小,
        // 直接返回一个空的 bucket 就行
        else if (windowStart < old.windowStart()) {
            return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        }
    }
}

上面代码的实现是:通过当前时间戳计算出当前时间窗口的 (new) Bucket 在数组中的索引,通过索引从数组中取得 (old) Bucket;并计算 (new) Bucket 时间窗口的开始时间,与 (old) Bucket 时间窗口的开始时间作对比。

  1. 如果旧的 bucket 不存在,那么我们在 windowStart 处创建一个新的 bucket,然后尝试通过 CAS 操作更新环形数组。只有一个线程可以成功更新,保证原子性。

  2. 如果当前 windowStart 等于旧桶的开始时间戳,表示时间在桶内,所以直接返回桶。

  3. 如果旧桶的开始时间戳落后于所提供的时间,这意味着桶已弃用,我们可以复用该桶,并将桶重置为当前的 windowStart。注意重置和清理操作很难是原子的,所以我们需要一个更新锁来保证桶更新的正确性。只有当 bucket 已弃用才会上锁,所以在大多数情况下它不会导致性能损失。

  4. 不应该通过这里,因为提供的时间已经落后了,一般是时钟回拨导致的。

设计一个大并发、大数据的系统架构,说说设计思路

大并发/大数据的软件有如下特点:

  • 用户多,分布广泛

  • 大流量,高并发

  • 从小到大,渐进发展

  • 以用户为中心

  • 海量数据,服务高可用

  • 安全环境恶劣,易受网络攻击

  • 功能多,变更快,频繁发布

大并发/大数据的架构目标有如下几个:

  • 高性能:提供快速的访问体验。

  • 高可用:网站服务一直可以正常访问。

  • 可伸缩:通过硬件增加/减少,提高/降低处理能力。

  • 扩展性:方便地通过新增/移除方式,增加/减少新的功能/模块。

  • 安全性:提供网站安全访问和数据加密、安全存储等策略。

  • 敏捷性:随需应变,快速响应。

大并发/大数据的设计思路与原则

1.演进原则

优秀的架构和产品都是一步一步迭代出来的,用户量的不断增大,业务的扩展进行不断地迭代升级,最终演化成优秀的架构。

  • 早期项目,由于团队规模有限,技术经验不足,往往使用一个简单的单体架构、单节点DB。

  • 随着流量增加和业务演变,需要不断修正系统架构中的问题点,基于后面的几个原则,一点一点的进行系统演进

演进原则:高并发/大数据系统的演进是循序渐进的,以在高并发、大数据场景下不断优化用户体验为目标。

2.单一职责( Single Responsibility Principle)原则

定义:对一个类而言,应该仅有一个引起它变化的原因。

说明:一个类应该是相关性很高的封装,类只实现一个功能。

很多的时候,我们代码中有大量的上帝类,所谓上帝类,把不应该是一个类的功能也往自己身上揽,大包大揽,导致内聚性就会很差,内聚性差将导致代码很难被复用,不能复用,只能复制(Repeat Yourself),其结果就是一团乱麻。

3.开闭原则(Open Closed Principle)

定义:软件中的对象应该对于扩展是开放的,对于修改是关闭的。面对新需求,对程序的改动应该是通过增加代码实现的而不是修改现有代码来实现

说明:当软件需要变化时,我们尽可能的通过扩展的方式来实现变化,比如通过继承,通过装饰者模式来增加新的功能,而不是通过修改已有的接口来实现,一旦修改接口,上层调用的地方均需要修改。

开闭原则是面向对象设计的核心所在,遵循开闭原则的最好手段就是抽象

防止变异(Protected Variations)

问题:如何设计对象,子系统和系统,使其内部的变化或不稳定性不会对其他元素产生不良影响?

解决方案:分离变与不变,  识别变化或不稳定的地方,抽象出稳定的接口。

开发人员应该仅对程序中频繁出现变化的那些部分做出抽象,如果对于应用程序中每个部分都做刻意的抽象并不是个好主意。

拒绝不成熟的抽象和抽象本身一样重要。

4.高内聚低耦合/迪米特原则(Law of Demeter)

定义:

  • 如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的互相引用。

  • 如果中间一个类需要调用另一个类的某一个方法,可以通过第三者转发这个调用。

一个对象应该对其他对象有最少的了解也被称为最少知识原则。

该原则首先强调的是在类的结构设计上,应该尽可能低的设计成员的访问权限。

其根本思想是强调了类的松耦合,类之间的耦合越弱,越有利于复用,一个处在弱耦合的类被修改,不会波及有关系的类。

也就是说,一个类应该对自己需要耦合或调用的类知道的最少,类与类之间的关系越密切,耦合度越大,那么类的变化对其耦合的类的影响也会越大,这也是我们面向对象设计的核心原则:低耦合,高内聚。

什么是直接的朋友?

每个对象都必然与其他对象有耦合关系,两个对象的耦合就成为朋友关系,这种关系的类型很多,例如组合、聚合、依赖、关联等。

其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部

5.横向扩展(Scale-out)原则。

纵向扩展总是有 上限的, 大数据、大并发系统的核心思路之一就是:横向扩展。

横向扩展(Scale-out)的核心思路:采用分布式策略将系统的负载分散到多台服务器上,每台服务器处理一部分并发和流量。

横向扩展(Scale-out)可以充分利用现有硬件资源,提高系统的整体性能,使系统更能应对大规模并发请求。

6.缓存原则

缓存,广泛应用于系统设计的各个方面。

缓存,从操作系统到浏览器,从数据库到消息队列,从应用软件到操作系统,从操作系统到CPU,无处不在。

缓存,几乎所有复杂的服务和组件都在使用。

缓存,是空间换时间的思想。

7.异步原则

同步调用,意味着调用方在调用一个方法后会阻塞等待该方法的逻辑执行完成。

异步调用 与 同步相反。

异步调用 ,  主要是三大步骤:

  • 调用方发出请求后不需要等待,快速返回

  • 被调方可以慢慢执行,

  • 调用方通过回调函数、事件通知等方式获取执行的结果。

异步调用 方式在大规模高并发系统中被广泛采用。

以 12306 网站为例。

  • 当用户订票时,先将请求丢到消息队列中,然后立即告诉用户正在处理,然后立即返回。

  • 系统进行复杂的订票操作,如查询余票、下单、更改余票状态等,这些操作可能需要耗费较长时间

  • 当订票操作完成后,系统再通知用户订票成功或失败。

这种异步处理方式使得系统能够更好地应对高并发,减少了资源占用,提高了系统的性能和可扩展性。

大并发/大数据的分层架构

高并发/大数据的架构设计,一般是分层进行,可以从下面的5大层来建设和分析:

1:接入层

主要流量入口

2:应用层

负责具体业务和视图展示;

网站首页、用户中心、商品中心、购物车、红包业务、活动中心等,

3:服务层

根据业务领域每个子域单独一个服务,分而治之。

服务层为应用层提供服务支持;比如:订单服务、用户管理服务、红包服务、商品服务等

这个是我们重点要关注的架构设计,架构设计不合理,就很难抗住高并发,主要包括各种架构和模块的设计。

4:数据层

数据库和NoSQL,文件存储等。

关系数据库、nosql数据库等,提供数据存储查询服务。

5:基础设施层

这个是最基础的依赖,主要是一些服务的部署。

基础设施层一般包含了服务器、中间件、部署方式等等。

接入层的架构方案:

动静分离,分而治之

  • 动态资源使用  Nginx+LVS+KeepAlive 进行负载均衡

  • 静态资源使用 CDN进行加速

动态资源Nginx+LVS+KeepAlive 进行负载均衡

10Wqps网关接入层,LVS+Keepalived(DR模式)如何搭建?

静态资源使用CDN加速

CDN相当于加上一层缓存, 加载离用户最近的idc机房, 由cdn的运营商提供, 比如电信等

秒杀的静态页面通过到CDN上预热(CDN是内容分发网络,可以简单理解成互联网上的巨大的缓存,用于存放静态页面、图片、视频等,可以显著提高访问速度),用CDN扛流量,这样大量的商品详情页的访问请求就不用访问自己的网站(源站)。这样既可以提高访问速度,也没有给网站增加压力,同时也减少了网站带宽压力。

图片

应用层/服务层架构方案

1:业务解耦

对一个复杂的业务,需要分割成不同的模块单元,分而治之

一个大的问题域,需要分解为很多小的问题域,分而治之

就是是微服务划分、微服务架构

微服务架构解决大单体架构的的很多问题,比如扩展性、弹性伸缩能力、小规模团队的敏捷开发等等。

如何进行微服务架构,如何划分微服务:

  • 高内聚低耦合

  • 单一职责

  • 可扩展原则

  • 等等等等

如何进行业务解耦,如何划分微服务?

在实操过程中, 建议使用DDD的建模方法进行建模。

字节面试:微服务一定要DDD,为什么?TDD和DDD 有何关系?

美团面试:微服务如何拆分?原则是什么?

2:横向扩展设计

横向扩展设计,包括:应用集群、服务集群

应对高并发系统,单节点模式都不可能搞定,因此都需要搭建应用集群、服务集群,常见的微服务Provider的自动伸缩策略有以下两种:

1)通过Kubernetes HPA组件实现自动伸缩。

2)通过微服务Provider自动伸缩伺服组件实现自动伸缩。

3:缓存设计:多级缓存架构

缓存,广泛应用于系统设计的各个方面。

缓存,从操作系统到浏览器,从数据库到消息队列,从应用软件到操作系统,从操作系统到CPU,无处不在。

缓存,几乎所有复杂的服务和组件都在使用。

缓存,是空间换时间的思想。

缓存的可以提升系统性能,保护后端存储不被大流量打垮。

缓存的设计,需要分多个思路并行。

  • 普通数据一级缓存就够了

  • 热点数据要用到多级缓存

图片

4:异步原则:进程内的异步

进程内的异步, 是一个很有深度的问题,这个问题归纳为全链路异步

全链路异步,能大大的优化系统的并发量,单机解决高并发问题

从应用层的线程池,再到 IO层的Epoll 事件驱动(Nginx),都需要最大程度的异步。

全链路异步模式改造 具体的内容,请参考尼恩的深度文章:

全链路异步,让你的性能优化10倍+

另外,对于特殊的高并发场景,可以使用  Go+java 的混合架构, 进一步压榨 CPU的性能

借助 Go 语言协程,去提高并发能力。

5:异步原则:进程外的异步

消息队列也是一种异步化操作,是依赖外部的中间件如消息队列,进行的异步。

针对流量突峰,仅仅有缓存来抗量可能还不够,还需要使用消息队列来异步削峰。

使用消息队列后,可以将同步处理的请求改为 通过消费 MQ 消息来异步消费,这样可以大大减少系统处理的压力,增加系统的并发量。

常用的消息队列比如 kafka、Rocketmq。

6:预热原则

预热实际应用的场景有很多,比如在电商的大促到来前,我们可以把一些热点的商品提前加载到缓存中,防止大流量冲击DB。

预热原则一般有JVM预热、缓存预热、DB预热等,通过预热的方式让系统先“热”起来,为高并发流量的到来做好准备。

数据层架构方案

1:分库分表

数据存储量大的时候,就需要通过分库分表来存储。

分库分表模式虽然能显著提升数据库的容量,但会增加系统复杂性,因此在设计分库分表方案的时候需要结合具体业务场景,更全面地考虑。

2:读写分离

高并发系统,大多数都是读多写少,因此读写分离可以帮助主库抗量。

一般我们都是一主多从的架构,可以抗量,也可以保证数据不丢。

3:冷热分离

针对业务场景而言,如果数据有冷热之分的话,可以将历史冷数据与当前热数据分开存储,这样可以减轻当前热数据的存储量,可以提高性能。

4:使用NoSQL完成大数据的存储

当数据库中的数据多到一定规模时,数据库就不适用于复杂的查询了,往往只能满足普通查询的场景。

对于统计报表场景,在数据量大时不一定能跑出结果,而且在跑复杂查询时会导致其他查询变慢,对于全文检索、可变数据结构等场景,数据库天生不适用。

图片

基础设施层架构

基础设施层架构 包含:

  • 监控三大件: logging、tracing、metrics。

  • 各种中间件

  • cicd组件

网易面试:亿级用户,如何做微服务底层架构?

高并发核武器:单元化+异地多活设计

除了前面的高并发常规武器, 还有高并发的核武器::单元化+异地多活设计

1:单元化(Set化)设计

一个服务对外的使用方可能有 A 业务、B 业务,那么如何保证 AB 业务不会相互影响,那么就是单元化(Set化)设计。

所谓单元,是指一个能完成所有业务操作的自包含集合,在这个集合中包含了所有业务所需的所有服务,以及单元的数据分片

图片

单元化架构就是把单元作为系统部署的基本单位,在全站所有idc机房中部署数个单元.

每个idc机房里的单元数目不定,任意一个单元都部署了系统所需的所有的服务

任意一个单元的数据是首先拥有分片数据,但是为了切流的方便, 最终需要拥有全量数据

图片

传统意义上的 SOA 化(服务化)架构,服务是分层的,每层的节点数量不尽相同,上层调用下层时,随机选择节点。

单元化架构下,服务仍然是分层的,

不同的是每一层中的任意一个节点都属于且仅属于某一个单元,上层调用下层时,仅会选择本单元内的节点。

而要做到单元化,必须要满足以下要求:

  • 业务必须是可分片的,如 淘宝按照用户分片, 饿了么按照地理位置分片

  • 单元内的业务是自包含的,调用尽量封闭

单元化署就是把业务系统分为多个可扩展的逻辑分区,每个 SET 的逻辑分区都可以独立部署并提供服务,SET 也可以理解为 ”逻辑机房“ ,主要目的就是为了进行独立部署并且做到业务上的逻辑隔离。

关于 单元化SET 的具体例子:

  • 微信红包用户发一个红包时,微信红包系统生成一个ID作为这个红包的唯一标识。

  • 接下来这个红包的所有发红包、抢红包、拆红包、查询红包详情等操作,都根据这个ID关联。

  • 红包系统根据这个红包ID,按一定的规则(如按ID尾号取模等),垂直上下切分。

  • 切分后,一个垂直链条上的逻辑Server服务器、DB统称为一个SET。

单元化SET 部署之后,系统将所有红包请求这个巨大的洪流分散为多股小流,互不影响,分而治之。

现在稍微有点体量的公司都在做单元化,单元化的好处

  • 多AZ(可用区) 容灾、异地多活。
  • 服务器体量太大,单一IDC没有足够的机器。
  • 提升用户请求访问速度。

2:多 IDC + 异地多活

基础设施层一般包含了服务器、IDC、部署方式等等。

  • 多 IDC 部署。比如服务同时在广州、上海两地部署。这个依赖我们的服务是无状态的;

  • 其他的参考下异地多活架构等相关部署。

3:异地多活的系列问题

B站刚崩,唯品会又崩:亿级用户网站的架构硬伤与解决方案

100Wqps异地多活,得物是怎么架构的?

大家都崩,美团不崩:其高可用架构,巧夺天工!

美团面试:ES+Redis+MySQL高可用,如何试实现?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值