简栈文化

Java技术人的成长之路~

一、Kafka消费者组是什么?

Consumer GroupKafka提供的可扩展且具有容错性的消费者机制。在组内多个消费者实例(Consumer Instance ),它们共享一个公共的ID即 Group ID 。组内的所有消费者协调在一起消费订阅主题(Subscribed Topics)的所有分区(Partition)。当然一个分区只能有同一个消费者组的一个Consumer 实例消费。
Consumer Group 有三个特性:

  • Consumer Group 下可以有一个或多个Consumer 实例。 这里的实例可以是一个单独的进程,也可以是同一进程下的线程;
  • Group ID 是一个字符串, 在Kafka集群中唯一标识,Consumer Group
  • Consumer Group
    下所有实例订阅主体的单个分区,只能分配给组内某个Consumer实例消费。同一个分区消息可能被多个Group 消费。

二、Kafka消费者组解决了哪些问题?

传统的消息系统中,有两种消息引擎模型:点对点模型(消息队列)、发布/订阅模型
传统的两种消息系统各有优势,我们里对比一下:

  • 传统的消息队列模型的缺陷在于消息一旦被消费,就会从队列中删除,而且只能被下游的一个Consumer消费。严格的说这不是它的缺陷,
    这是它的一个特性。但很显然这种模型的伸缩性(Scalability)很差,因为下游的多个Consumer 都要“抢”
    这个共享消息队列的消息;
  • 发布/订阅模型,允许消息被多个Consumer 消费,但它的问题也是伸缩性不高,因为订阅者都必须订阅所有主体的所有分区。

Kafka 为规避传统消息两种模型的缺点,引入了 Consumer Group 机制:

  • Consumer Group 订阅多个主题后,组内的每个实例不要求一定要订阅主题的所有分区,它只会消费部分分区中的消息;
  • Consumer Group 之间彼此队里,互不影响,它们可以订阅同一组主题而互不干涉。加上Broker端的消息留存机制,KafkaConsumer Group 完美的避开了伸缩性差的问题;
  • kafka 是用Consumer Group机制,实现了,传统两大消息引擎。如果所有实例属于同一个Group,那么它实现的就是消息队列模型;如果所有实例分别属于不同的Group,且订阅了相同的主题,那么它就实现了发布/订阅模型;

三、Consumer Group 实例数量多少才合理?

最理想的情况是Consumer实例的数量应该等于该Group订阅主题的分区总数。例如:Consumer Group 订阅了 3个主题,分别是A、B、C,它们的分区数依次是1、2、3,那么通常情况下,为该Group 设置6Consumer实例是比较理想的情形。

如果设置小于或大于6的实例可以吗?当然可以,如果你有3个实例,那么平均下来每个实例大约消费2个分区(6/3=2);如果你设置了9个实例,那么很遗憾,有3个实例(9-6=3)将不会被分配任何分区,它们永远处于空闲状态。

四、消费位移

消费者在消费的过程中要记录自己消费了多少数据,即消费位置信息,在Kafka中叫:位移(offset)。
看上去该Offset就是一个数字而已,其实对于Consumer Group 而言,它是一组KV对,Key是分区,V对应Consumer 消费该分区的最新位移。
老版本的Consumer Group把位移保存在Zookeeper中。将位移保存在Zookeeper外部系统显然好处是减少了Kafka Broker 端的状态保存开销。现在比较流行的提法是将服务器节点做成无状态的, 这样可以自由扩缩容,实现超强的伸缩性。不过在实际使用场景中,发现ZooKeeper 这类元框架并不是适合进行频繁的写更新,而Consumer Group 的位移更新却是一个非常频繁的操作。 这种大吞吐量的写操作极大的拖慢了ZooKeeper 集群的性能,在新版本的Consumer Group 中,Kafka 社区采用了将Consumer Group 位移保存在Broker 端的内部主题中。

五、Rebalance

Rebalance 本质上是一种协议,规定了一个Consumer Group 下所有Consumer 如何达成一致,来分配订阅Topic的每个分区。比如:某个Group 下有20个Consumer 实例, 它订阅了一个具有100个分区的Topic。正常情况下,Kafka 平均会为每个Consumer 分配5个分区。这个分配的过程叫Rebalance
Consumer Group触发 Rebalance有三种情况:

  • 组成员数量发生变化,比如有新的Consumer 实例加入组或离开组,抑或是有Consumer实例崩溃被“踢出”组。
  • 订阅主题数量发生变更。 Consumer Group 可以使用正则表达式订阅主题,比如
    consumer.subscribe(Pattern.complile(“t.*c”))就表明该Group订阅所有t开头,字母c结尾的主题。在Consumer Group 运行时,新创建一个满足这样条件的主题,那么会触发订阅该主题所有Group 开始Rebalance
  • 订阅主题分区数发生变化,Kakfka 一个主题,当分区数增加时,就会触发订阅该主题的所有Group开启Rebalance

Consumer Group 发生Rebalance 的过程:某个 Consumer Group 下有两个Consumer ,比如AB,当第三个成员C加入时,Kafka会触发Rebalance,并根据默认的分配策重新分配A、B、C分配分区,如下图:
http://static.cyblogs.com/kafka_rebalance.png

注意:目前Rebalance 的设计是所有Consumer实例共同参与,全部重新分配所有分区,Rebalance过程所有Consumer 实例都会停止消费,等待Rebalance 完成。Rebalance 很慢,一个Group 内有几百个Consumer实例,成功进行一次Rebalance需要好几个小时。 目前社区没有终极解决方案,最好的解决方案是规避Rebalane的发生。

参考地址

举个例子

在讲重排序之前,先来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.cyblogs.thread;

import java.util.HashSet;
import java.util.Set;

/**
* Created with leetcode-cn
*
* @Description: 验证重排序代码
* @Author: chenyuan
* @Date: 2021/3/26
* @Time: 15:05
*/
public class VolatileSerialCase {

static int x = 0, y = 0;
static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
// 用set来保存数据,保证不会重复
Set<String> resultSet = new HashSet<String>();

for (int i = 0; i < 10000000; i++) {
x = 0;
y = 0;
a = 0;
b = 0;

Thread one = new Thread(() -> {
a = y;
x = 1;
});


Thread two = new Thread(() -> {
b = x;
y = 1;
});

one.start();
two.start();
one.join();
two.join();
// 等待2个线程都跑完了再把结果添加到Set中去
resultSet.add("a=" + a + ",b=" + b);
System.out.println(resultSet);
}
}
}

上面一段代码是非常经典来讲CPU对指令重排序的案例。因为我们经过一段时间的Run出的结果很惊讶:

1
[a=0,b=0, a=1,b=0, a=0,b=1, a=1,b=1]

对于a=1,b=1的出现,是会让人非常的奇怪的。出现这个情况,那代码执行的顺序可能是:

1
2
3
4
5
6
7
8
9
10
Thread one = new Thread(() -> {
a = y; // 第3步
x = 1; // 第1步
});

Thread two = new Thread(() -> {
b = x; // 第4步
y = 1; // 第2步
});
// 也就是说,在2个线程中,都出现了下面的代码执行到了上面的代码前面去了。

如果是这样子的话,那我们还敢写多线程的代码吗?如果没有一定的规范与约定,那肯定是没人可以写好代码。

其实这些约定都是在JSR-133内存模型与线程规范里面,它就像是Java的产品需求文档或者说明书。

http://static.cyblogs.com/Jietu20210327-174611.jpg

百度云盘:链接: https://pan.baidu.com/s/1cO5d95Za8lyz8dMaN0i9lA 密码: l08w ,大家可以去下载查阅,这些都比较底层,并不能几句话,几篇文章可以讲清楚。

为什么会重排序?

看完上面,你可能会有疑问,为什么会有重排序呢?

我的程序按照我自己的逻辑写下来好好的没啥问题, Java 虚拟机为什么动我的程序逻辑?

你想想 CPU 、内存这些都是非常宝贵的资源, Java 虚拟机如果在重排序之后没啥效果,肯定也不会做这种费力不讨好的事情。

Java 虚拟机之所以要进行重排序就是为了提高程序的性能。你写的程序,简简单单一行代码,到底层可能需要使用不同的硬件,比如一个指令需要同时使用 CPU 和打印机设备,但是此时 CPU 的任务完成了,打印机的任务还没完成,这个时候怎么办呢? 不让 CPU 执行接下来的指令吗? CPU 的时间那么宝贵,你不让它工作,确定不是在浪费它的生命?

重排序:重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图所示:

http://static.cyblogs.com/20180326170243607.png

  • 上述的1属于编译器重排序
  • 2和3属于处理器重排序

这些重排序可能会导致多线程程序出现内存可见性问题。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

重排序带来的问题

回到文章刚开始举的那个例子,重排序提高了 CPU 的利用率没错,提高了程序性能没错,但是我的程序得到的结果可能是错误的啊,这是不是就有点儿得不偿失了?

因为重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致

凡是问题,都有办法解决,要是没有,那就再想想。

它是怎么解决的呢? 这就需要来说说,顺序一致性内存模型JMM (Java Memory Model , Java 内存模型)

我们知道Java线程的所有操作都是在工作区进行的,那么工作区和主存之间的变量是怎么进行交互的呢,可以用下面的图来表示。

http://static.cyblogs.com/e0e01e43ly1g186enjfwfj20k80degmr.jpg
Java通过几种原子操作完成工作区内存主存的交互

  1. lock:作用于主存,把变量标识为线程独占状态。
  2. unlock:作用于主存,解除变量的独占状态。
  3. read:作用于主存,把一个变量的值通过主存传输到线程的工作区内存。
  4. load:作用于工作区内存,把read操作传过来的变量值储存到工作区内存的变量副本中。
  5. use:作用于工作内存,把工作区内存的变量副本传给执行引擎。
  6. assign:作用于工作区内存,把从执行引擎传过来的值赋值给工作区内存的变量副本。
  7. store:作用于工作区内存,把工作区内存的变量副本传给主存。
  8. write:作用于主存,把store操作传过来的值赋值给主存变量。

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。as-if-serial语义把单线程程序保护了起来,as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

happens-before

终于讲到了 happens-before ,先来看 happens-before 关系的定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果就会对第二个操作可见
  • 两个操作之间如果存在 happens-before 关系,并不意味着 Java 平台的具体实现就必须按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按照 happens-before 关系来执行的结果一直,那么 JMM 也允许这样的重排序

看到这儿,你是不是觉得,这个怎么和 as-if-serial 语义一样呢。没错, happens-before 关系本质上和 as-if-serial 语义是一回事。

as-if-serial 语义保证的是单线程内重排序之后的执行结果和程序代码本身应该出现的结果是一致的, happens-before 关系保证的是正确同步的多线程程序的执行结果不会被重排序改变。

一句话来总结就是:如果操作 A happens-before 操作 B ,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。

Java 中,对于 happens-before 关系,有以下规定:

  • 程序顺序规则:一个线程中的每一个操作, happens-before 于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁, happens-before 于随后对这个锁的加锁
  • volatile 变量规则:对一个 volatile 域的写, happens-before 与任意后续对这个 volatile 域的读
  • 传递性:如果 A happens-before B , 且 B happens-before C ,那么 A happens-before C
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

参考地址

Paxos解决什么问题

大家对Paxos的看法基本是“晦涩难懂”,虽然论文和网上文章也很多,但总觉得“云山雾罩”,也不知道其具体原理以及到底能解决什么问题。

究其原因,一方面是很多Paxos的资料都是在通过形式化的证明去论证算法的正确性,自然艰深晦涩;另一方面,基于Paxos的成熟工程实践并不多。本章试图由浅入深,从问题出发,一点点地深入Paxos的世界。

一个基本的并发问题

先看一个基本的并发问题,如图116所示。假设有一个KV存储集群,三个客户端并发地向集群发送三个请求。请问,最后在get(X)的时候,X应该等于几?

http://static.cyblogs.com/Jietu20210228-000345.jpg

图116(K,V)集群多写答案是:X=1、X=3或X=5都是对的!但X=4是错的!因为从客户端角度来看,三个请求是并发的,但三个请求到达服务器的顺序是不确定的,所以最终三个结果都有可能。

这里有很关键的一点:把答案换一种说法,即如果最终集群的结果是X=1,那么当Client1发送X=1的时候,服务器返回X=1;当Client2发送X=3的时候,服务器返回X=1;当Client3发送X=5的时候,服务器返回X=1。相当于Client1的请求被接受了,Client2、Client3的请求被拒绝了。如果集群最终结果是X=3或者X=5,是同样的道理。而这正是Paxos协议的一个特点。

什么是“时序”

把问题进一步细化:假设KV集群有三台机器,机器之间互相通信,把自己的值传播给其他机器,三个客户端分别向三台机器发送三个请求,如图117所示。

http://static.cyblogs.com/Jietu20210228-000644.jpg

图117三台机器组成的(K,V)集群多写示意图假设每台机器都把收到的请求按日志存下来(包括客户端的请求和其他Node的请求)。当三个请求执行完毕后,三台机器的日志分别应该是什么顺序?

结论是:不管顺序如何,只要三台机器的日志顺序是一样的,结果就是正确的。如图118所示,总共有3的全排列,即6种情况,都是正确的。比如第1种情况,三台机器存储的日志顺序都是X=1、X=3、X=5,在最终集群里,X的值肯定等于5。其他情况类似。

http://static.cyblogs.com/Jietu20210228-000811.jpg

而下面的情况就是错误的:机器1的日志顺序是1、3、5,因此最终的值就是X=5;机器2是3、5、1,最终值是X=1;机器3的日志顺序是1、5、3,最终值是X=3。三台机器关于X的值不一致,如图109所示。

http://static.cyblogs.com/Jietu20210228-000852.jpg

通过这个简单的例子就能对“时序”有一个直观的了解:虽然三个客户端是并发的,没有先后顺序,但到了服务器的集群里必须保证三台机器的日志顺序是一样的,这就是所谓的“分布式一致性”。

Paxos解决什么

问题在例子中,Node1收到了X=1之后,复制给Node2和Node3;Node2收到X=3之后,复制给Node1和Node3;Node3收到X=5之后,复制给Node1和Node2。

客户端是并发的,三个Node之间的复制也是并发的,如何保证三个Node最终的日志顺序是一样的呢?也就是图118中6种正确情况中的1种。

比如Node1先收到客户端的X=1,之后收到Node3的X=5,最后收到Node2的X=3;Node2先收到客户端的X=3,之后收到Node1的X=1,最后收到Node3的X=5……

如何保证三个Node中存储的日志顺序一样呢?这正是接下来要讲的Paxos要解决的问题!

复制状态机

在上文谈到了复制日志的问题,每个Node存储日志序列,Node之间保证日志完全一样。可能有人会问:为何要存储日志,直接存储最终的数据不就行了吗?

可以把一个变量X或一个对象看成一个状态机。每一次写请求,就是一次导致状态机发生变化的事件,也就是日志。

以上文中最简单的一个变量X为例,假设只有一个Node,3个客户端发送了三个修改X的指令,最终X的状态就是6,如图1110所示。

http://static.cyblogs.com/Jietu20210228-001101.jpg

图1110状态机X示意图把变量X扩展成MySQL数据库,客户端发送各种DML操作,这些操作落盘成Binlog。然后Binlog被应用,生成各种数据库表格(状态机),如图1111所示。

http://static.cyblogs.com/Jietu20210228-001145.jpg

这里涉及一个非常重要的思想:要选择持久化变化的“事件流(也就是日志流)”,而不是选择持久化“数据本身”(也就是状态机)。为何要这么做呢?原因有很多,列举如下:

(1)日志只有一种操作,就是append。而数据或状态一直在变化,可以add、delete、update。把三种操作转换成了一种,对于持久化存储来说简单了很多!

(2)假如要做多机之间数据同步,如果直接同步状态,状态本身可能有一个很复杂的数据结构(比如关系数据库的关联表、树、图),并且状态也一直在变化,要保证多个机器数据一致,要做数据比对,就很麻烦;而如果同步日志,日志是一个一维的线性序列,要做数据比对,则非常容易!

总之,无论从持久化,还是数据同步角度来看,存储状态机的输入事件流(日志流),都比存储状态机本身更容易。

基于这种思路,可以把状态机扩展为复制状态机。状态机的原理是:一样的初始状态+一样的输入事件=一样的最终状态。因此,要保证多个Node的状态完全一致,只要保证多个Node的日志流是一样的即可!即使这个Node宕机,只需重启和重放日志流,就能恢复之前的状态,如图1012所示。

http://static.cyblogs.com/Jietu20210228-001253.jpg

因此,就回到了上文最后的问题:复制日志!复制日志=复制任何数据(复制任何状态机)。因为任何复杂的数据(状态机)都可以通过日志生成!

一个朴素而深刻的思想

Paxos的出现先经过了Basic Paxos的形式化证明,之后再有Multi Paxos,最后是应用场景。因为最开始没有先讲应用场景,所以直接看Basic Paxos的证明会很晦涩。本文将反过来,就以上文最后提出的问题为例,先介绍应用场景,再一步步倒推出PaxosMulti Paxos

当三个客户端并发地发送三个请求时,图118所示的6种可能的结果都是对的。因此,要找一种算法保证虽然每个客户端是并发地发送请求,但最终三个Node记录的日志的顺序是相同的,也就是图108所示的任取一种场景即可。

这里提出一个朴素而深刻的说法:全世界对数字1,2,3,4,5,……顺序的认知是一样的!所有人、所有机器,对这个的认知都是一样的!

当我说2的时候,全世界的人,都知道2排在1的后面、3的前面!2代表一个位置,这个位置一定在(1,3)之间。

把这个朴素的想法应用到计算机里面多个Node之间复制日志,会变成如下这样。当Node1收到X=1的请求时,假设要把它存放到日志中1号位置,存放前先询问另外两台机器1号位置是否已经存放了X=3或X=5;如果1号位置被占了,则询问2号位置……依此类推。如果1号位置没有被占,就把X=1存放到1号位置,同时告诉另外两个Node,把X=1存放到它们各自的1号位置!同样,Node2和Node3按此执行。

这里的关键思想是:虽然每个Node接收到的请求顺序不同,但它们对于日志中1号位置、2号位置、3号位置的认知是一样的,大家一起保证1号位置、2号位置、3号位置存储的数据一样!

在例子中可以看到,每个Node在存储日志之前先要问一下其他Node,之后再决定把这条日志写到哪个位置。这里有两个阶段:先问,再做决策,也就是Paxos2PC的原型!

把问题进一步拆解,不是复制三条日志,只复制一条。先确定三个Node的第1号日志,看有什么问题?

Node1询问后发现1号位置没有被占,因此它打算把X=1传播给Node2和Node3;同一时刻,Node2询问后发现1号位置也没有被占,因此它打算把X=3传播给Node1和Node3;同样,Node3也打算把X=5传播给Node1和Node2。

结果不就冲突了吗?会发现不要说多条日志,就算是只确定第1号位置的日志,都是个问题!

而BasicPaxos正是用来解决这个问题的。

首先,1号位置要么被Node1占领,大家都存放X=1;要么被Node2占领,大家都存放X=3;要么是被Node3占领,大家都存放X=5,少数服从多数!为了达到这个目的,BasicPaxos提出了一个方法,这个方法包括两点:

第1,Node1在填充1号位置的时候,发现1号位置的值被大多数确定了,比如是X=5(node3占领了1号位置,Node2跟从了Node3),则Node1就接受这个事实:1号位置不能用了,也得把自己的1号位置赋值成X=5。然后看2号位置能否把X=1存进去。同样地,如果2号也被占领了,就只能把它们的值拿过来填在自己的2号位置。只能再看3号位置是否可行……

第2,当发现1号位置没有被占,就锁定这个位置,不允许其他Node再占这个位置!除非它的权利更大。如果发现1号位置为空,在提交的时候发现1号位置被其他Node占了,就会提交失败,重试,尝试第二个位置,第三个位置……

所以,为了让1号位置日志一样,可能要重试好多次,每个节点都会不断重试2PC。这样不断重试2PC,直到最终各方达成一致的过程,就是Paxos协议执行的过程,也就是一个Paxosinstance,最终确定一个值。而MultiPaxos就是重复这个过程,确定一系列值,也就是日志中的每一条!

接下来将基于这种思想详细分析Paxos算法本身。

BasicPaxos算法

在前面的场景中提到三个Client并发地向三个Node发送三条写指令。对应到Paxos协议,就是每个Node同时充当了两个角色:Proposer和Acceptor。在实现过程中,一般这两个角色是在同一个进程里面的。

当Node1收到Client1发送的X=1的指令时,Node1就作为一个Proposer向所有的Acceptor(自己和其他两个Node)提议把X=1日志写到三个Node上面。

同理,当Node2收到Client2发送的X=3的指令,Node2就作为一个Proposer向所有的Acceptor提议;Node3同理。

下面详细阐述Paxos的算法细节。首先,每个Acceptor需要持久化三个变量(minProposalId,acceptProposalId,acceptValue)。在初始阶段:minProposalId=acceptProposalId=0,acceptValue=null。然后,算法有两个阶段:P1(Prepare阶段)和P2(Accept阶段)。

P1(Prepare阶段)

http://static.cyblogs.com/Jietu20210227-234242.jpg

Prepare阶段P1a:Proposer广播prepare(n),其中n是本机生成的一个自增ID,不需要全局有序,比如可以用时间戳+IP。P1b:Acceptor收到prepare(n),做如下决策:

1
2
3
4
5
if n > minProposalId,回复Yes
同时,minProposalId=n(持久化)
返回(acceptProposalId,acceptValue)
else
回复 No

P1c:Proposer如果收到半数以上的yes,则取acceptorProposalId最大的acceptValue作为v,进入第二个阶段,即开始广播accept(n,v)。如果acceptor返回的都是null,则取自己的值作为v,进入第二个阶段!否则,n自增,重复P1a。

P2(Accept阶段)

http://static.cyblogs.com/Jietu20210227-234722.jpg

P2a:Proposer广播accept(n,v)。这里的n就是P1阶段的n,v可能是自己的值,也可能是第1阶段的acceptValue。P2b:Acceptor收到accept(n,v),做如下决策:

1
2
3
4
5
6
if n > minProposalId,回复Yes。同时
minProposalId=acceptProposalId=n(持久化)
acceptValue=value
return minProposalId
else
回复 No

P2c:Proposer如果收到半数以上的yes,并且minProposalId=n,则算法结束。否则,n自增,重复P1a。

通过分析算法,会发现BasicPaxos有两个问题:

(1)Paxos是一个“不断循环”的2PC。在P1C或者P2C阶段,算法都可能失败,重新进行P1a。这就是通常所说的“活锁”问题,即可能陷入不断循环。

(2)每确定一个值,至少需要两次RTT(两个阶段,两个网络来回)+两次写盘,性能也是个问题。而接下来要讲的MultiPaxos就是要解决这两个问题。

MultiPaxos算法

问题1:活锁问题

在前面已经知道,BasicPaxos是一个不断循环的2PC。所以如果是多个客户端写多个机器,每个机器都是Proposer,会导致并发冲突很高,也就是每个节点都可能执行多次循环才能确定一条日志。极端情况是每个节点都在无限循环地执行2PC,也就是所谓的“活锁问题”。

为了减少并发冲突,可以变多写为单写,选出一个Leader,只让Leader充当Proposer。其他机器收到写请求,都把写请求转发给Leader;或者让客户端把写请求都发给Leader。

Leader的选举方法很多,下面列举两种:

方案1:无租约的Leader选举

Lamport在他的论文中给出了一个Leader选举的简单算法,算法如下:

(1)每个节点有一个编号,选取编号最大的节点为Leader;

(2)每个节点周期性地向其他节点发送心跳,假设周期为Tms;

(3)如果一个节点在最近的2Tms内还没有收到比自己编号更大的节点发来的心跳,则自己变为Leader;

(4)如果一个节点不是Leader,则收到请求之后转发给Leader。可以看出,这个算法很简单,但因为网络超时原因,很可能出现多个Leader,但这并不影响MultiPaxos协议的正确性,只是增大并发写冲突的概率。我们的算法并不需要强制保证,任意时刻只能有一个Leader。

方案2:有租约的Leader选举

另外一种方案是严格保证任意时刻只能有一个leader,也就是所谓的“租约”。租约的意思是在一个限定的期限内,某台机器一直是Leader。即使这个机器宕机,Leader也不能切换。必须等到租期到期之后,才能开始选举新的Leader。这种方式会带来短暂的不可用,但保证了任意时刻只会有一个Leader。具体实现方式可以参见PaxosLease。

问题2:性能问题

我们知道BasicPaxos是一个无限循环的2PC,一条日志的确认至少需要两个RTT+两次落盘(一次是Prepare的广播与回复,一次是Accept的广播与回复)。如果每条日志都要两个RTT+两次落盘,这个性能就很差了。而MultiPaxos在选出Leader之后,可以把2PC优化成1PC,也就只需要一个RTT+一次落盘了。

基本思路是当一个节点被确认为Leader之后,它先广播一次Prepare,一旦超过半数同意,之后对于收到的每条日志直接执行Accept操作。在这里,Perpare不再是对一条日志的控制了,而是相对于拿到了整个日志的控制权。一旦这个Leader拿到了整个日志的控制权,后面就直接略过Prepare,直接执行Accept。

如果有新的Leader出现怎么办呢?新的Leader肯定会先发起Prepare,导致minProposalId变大。这时旧的Leader的广播Accept肯定会失败,旧的Leader会自己转变成一个普通的Acceptor,新的Leader把旧的顶替掉了。

下面是具体的实现细节:在BasicPaxos中,2PC的具体参数形式如下:

1
2
prepare(n)
accept(n,v)

在MultiPaxos中,增加一个日志的index参数,即变成了如下形式:

1
2
prepare(n,index)
accept(n,v,index)
问题3:被choose的日志,状态如何同步给其他机器

对于一条日志,当Proposer(也就是Leader)接收到多数派对Accept请求的同意后,就知道这条日志被“choose”了,也就是被确认了,不能再更改!

但只有Proposer知道这条日志被确认了,其他的Acceptor并不知道这条日志被确认了。如何把这个信息传递给其他Accepotor呢?

方案1:Proposer主动通知

给accept再增加一个参数:

1
accept(n,v,index,firstUnchooseIndex)

Proposer在广播accept的时候,额外带来一个参数firstUnchosenIndex=7。意思是7之前的日志都已经“choose”了。Acceptor收到这种请求后,检查7之前的日志,如果发现7之前的日志符合以下条件:acceptedProposal[i]==request.proposal(也就是第一个参数n),就把该日志的状态置为choose。

解决方案2:Acceptor被动查询

当一个Acceptor被选为Leader后,对于所有未确认的日志,可以逐个再执行一遍Paxos,来判断该条日志被多数派确

认的值是多少。

因为BasicPaxos有一个核心特性:一旦一个值被确定后,无论再执行多少遍Paxos,该值都不会改变!因此,再执行1遍Paxos,相当于向集群发起了一次查询!

至此,MultiPaxos算法就介绍完了。回顾这个算法,有两个精髓:

精髓之1:一个强一致的“P2P网络”

任何一条日志,只有两种状态(choose,unchoose)。当然,还有一种状态就是applied,也就是被确认的日志被apply到状态机。这种状态跟Paxos协议关系不大。

choose状态就是这条日志,被多数派接受,不可更改;

unchoose就是还不确定,引用阿里OceanBase团队某工程师的话,就是“薛定谔的猫”,或者“最大commit原则”。一条unchoose的日志可能是已经被choose了,只是该节点还不知道;也可能是还没有被choose。要想确认,那就再执行一次Paxos,也就是所谓的“最大commit原则”。

整个MultiPaxos就是类似一个P2P网络,所有节点互相双向同步,对所有unchoose的日志进行不断确认的过程!在这个网络中可以出现多个Leader,可能出现多个Leader来回切换的情况,这都不影响算法的正确性!

精髓之2:“时序”

MultiPaxos保证了所有节点的日志顺序一模一样,但对于每个节点自身来说,可以认为它的日志并没有所谓的“顺序”。什么意思呢?

(1)假如一个客户端连续发送了两条日志a,b(a没有收到回复,就发出了b)。对于服务器来讲,存储顺序可能是a、b,也可能是b、a,还可能在a、b之间插入了其他客户端发来的日志!

(2)假如一个客户端连续发送了两条日志a、b(a收到回复之后,再发出的b)。对于服务器来讲,存储顺序可能是a、b;也可能是a、xxx、b(a与b之间插入了其他客户端的日志),但不会出现b在a的前面。

所以说,所谓的“时序”,只有在单个客户端串行地发送日志时,才有所谓的顺序。多个客户端并发地写,服务器又是并发地对每条日志执行Paxos,整体看起来就没有所谓的“顺序”。

参考地址

  • 文章摘自《软件架构设计》余春龙/著

Spring是一个非常优秀的开源项目,而且基本是互联网的标配。随着这几年的源码阅读习惯,有用一套自己的源码阅读笔记项目已经是水到渠成。今天就来在本地编译一份,以便于后面记录核心笔记用。

环境准备

1.准备好源代码

为什么贴这些url地址,是因为提醒我们所有人,官网与github是我们最先关注的地方。

gradle.properties

1
2
3
4
5
version=5.3.5-SNAPSHOT
org.gradle.jvmargs=-Xmx1536M
org.gradle.caching=true
org.gradle.parallel=true
kotlin.stdlib.default.dependency=false

从上面可以看出来,我们现在用到是spring 5.3.5-SNAPSHOT版本。后面会遇到一些编译问题,避免大家踩坑,我直接说。这里依赖的JDK需要在JDK11。

2.JDK准备

下载JDK11的版本:https://www.oracle.com/java/technologies/javase-jdk11-downloads.html

但是,但是,但是官网的实在是太慢了,所以我这里就直接改为openjdk11了。传送门:https://mirrors.tuna.tsinghua.edu.cn/AdoptOpenJDK/11/jdk/x64/ 下载速度就看你家里的带宽了,我基本在3M+左右~

http://static.cyblogs.com/Jietu20210219-223632.jpg

没有对比就没有伤害啊。

http://static.cyblogs.com/Jietu20210219-223756.jpg

配置好项目中使用的JDK版本,这边因为诉求不一样,平时基本还是用的JDK8

3.Gradle安装

因为我这里是Mac系统,所以一般的软件我都会采用brew来实现,因为实在太方便了。一行命令搞定~

1
brew install gradle

下面是我安装的gradle信息,是最新的版本6.7。

1
2
3
4
5
6
7
8
9
10
11
➜  ~ gradle -v
------------------------------------------------------------
Gradle 6.7
------------------------------------------------------------
Build time: 2020-10-14 16:13:12 UTC
Revision: 312ba9e0f4f8a02d01854d1ed743b79ed996dfd3
Kotlin: 1.3.72
Groovy: 2.5.12
Ant: Apache Ant(TM) version 1.10.8 compiled on May 10 2020
JVM: 1.8.0_172 (Oracle Corporation 25.172-b11)
OS: Mac OS X 10.15.7 x86_64
4.Intellij IDEA 2020

因为spring在代码中有对Kotlin有一些依赖(低版本的可能会出现依赖问题),建议搭建升级到比价新的版本。

http://static.cyblogs.com/Jietu20210219-224353@2x.jpg

构建源码

因为spring依赖与gradle,而且都是国外的网站,相对来说网速会非常的慢。这里也是采用更换镜像的方式来加速处理。

build.gradle

1
2
3
4
5
6
7
8
9
10
repositories {
// 优先本地
mavenLocal()
maven { url 'https://maven.aliyun.com/nexus/content/groups/public/' } //阿里云
maven { url 'https://maven.aliyun.com/nexus/content/repositories/jcenter' }
maven { url "https://repo.spring.io/snapshot" } // Reactor

mavenCentral()
maven { url "https://repo.spring.io/libs-spring-framework-build" }
}

settings.gradle

1
2
3
4
5
6
7
8
9
repositories {
// 新增配置
maven { url 'https://maven.aliyun.com/nexus/content/groups/public/' } //阿里云
maven { url 'https://maven.aliyun.com/nexus/content/repositories/jcenter' }
maven { url "https://repo.spring.io/snapshot" }

gradlePluginPortal()
maven { url 'https://repo.spring.io/plugins-release' }
}

配置一下gradle的参数

http://static.cyblogs.com/Jietu20210219-224901.jpg

构建成功验证

找到ApplicationContext接口,显示它的一个类关系图。如果出现下图所示类图界面说明构建成功了!(构建过程就是找依赖对象的过程)

http://static.cyblogs.com/Jietu20210219-173001@2x.jpg

编译源码

查看官方import-into-idea.md文档,如下内容:

1
2
3
4
1.Precompile spring-oxm with ./gradlew :spring-oxm:compileTestJava
2.Import into IntelliJ (File -> New -> Project from Existing Sources -> Navigate to directory -> Select build.gradle)
3.When prompted exclude the spring-aspects module (or after the import via File-> Project Structure -> Modules)
4.Code away
1.编译spring-oxm模块

先编译spring-oxm下的compileTestjava,点击右上角gradle打开编译视图,找到spring-oxm模块,然后在other下找到compileTestjava,双击即可!

http://static.cyblogs.com/Jietu20210219-225904.jpg

2.编译spring-core模块

利用同样的方式,编译spring-core模块,出现BUILD SUCCESSFUL代码成功。

http://static.cyblogs.com/Jietu20210219-230123.jpg

3.都编译完成且成功之后,开始编译整个工程

打开右侧Gradle插件 spring->build->build
编译时间,每个人电脑的性能不一样,所需时间也不一样。

http://static.cyblogs.com/Jietu20210219-231519.jpg

我这里基本上全部成功,但是在Task :spring-webflux:test模块有一个test跑失败。算是有一点小瑕疵,但不影响整个编译。

http://static.cyblogs.com/Jietu20210219-231701.jpg

源码测试

构建成功了,源码编译成功了,能否用还不知道,还要测试通过才说明源码可以用了。直接在spring-context里面创建我们的一个service,然后通过ApplicationContext去获取它,并执行它的一个方法。

http://static.cyblogs.com/Jietu20210220-090140.jpg

代码部分我已经push上去了:https://gitee.com/vernon/Spring-Framework,后续就可以自由自在的在上面标注以及验证了。

参考地址

二值状态统计

这里的二值状态就是指集合元素的取值就只有 0 和 1 两种。在签到打卡的场景中,我们只用记录:

  • 签到(1)
  • 未签到(0)

所以它就是非常典型的二值状态,在签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。

这个时候,我们就可以选择 Bitmap。这是 Redis 提供的扩展数据类型。我来给你解释一下它的实现原理。Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态。

你可以把 Bitmap 看作是一个 bit 数组。Bitmap 提供了 GETBIT/SETBIT 操作,使用一个偏移值 offsetbit 数组的某一个 bit 位进行读和写。不过,需要注意的是,Bitmap 的偏移量是从 0 开始算的,也就是说 offset 的最小值是 0。当使用 SETBIT 对一个 bit 位进行写操作时,这个 bit 位会被设置为 1。

Bitmap 还提供了 BITCOUNT 操作,用来统计这个 bit 数组中所有“1”的个数。那么,具体该怎么用 Bitmap 进行签到统计呢?我还是借助一个具体的例子来说明。

统计一个人签到

假设我们要统计 ID 3000 的用户在 2020 年 8 月份的签到情况,就可以按照下面的步骤进行操作。

  • 第一步,执行下面的命令,记录该用户 8 月 3 号已签到。

    1
    SETBIT uid:sign:3000:202008 2 1 
  • 第二步,检查该用户 8 月 3 日是否签到。

    1
    GETBIT uid:sign:3000:202008 2 
  • 第三步,统计该用户在 8 月份的签到次数。

    1
    BITCOUNT uid:sign:3000:202008

这样,我们就知道该用户在 8 月份的签到情况了,是不是很简单呢?

1 亿个用户 10 天的签到

接下来,你可以再思考一个问题:如果记录了 1 亿个用户 10 天的签到情况,你有办法统计出这 10 天连续签到的用户总数吗?

在介绍具体的方法之前,我们要先知道,Bitmap 支持用 BITOP 命令对多个 Bitmap 按位做“与”“或”“异或”的操作,操作的结果会保存到一个新的 Bitmap 中。

我以按位“与”操作为例来具体解释一下。从下图中,可以看到,三个 Bitmapbm1bm2bm3,对应 bit 位做“与”操作,结果保存到了一个新的 Bitmap 中(示例中,这个结果 Bitmap 的 key 被设为“resmap”)。

https://static001.geekbang.org/resource/image/41/7a/4151af42513cf5f7996fe86c6064f97a.jpg

回到刚刚的问题,在统计 1 亿个用户连续 10 天的签到情况时,你可以把每天的日期作为 key,每个 key 对应一个 1 亿位的 Bitmap,每一个 bit 对应一个用户当天的签到情况。

接下来,我们对 10 个 Bitmap 做“与”操作,得到的结果也是一个 Bitmap。在这个 Bitmap 中,只有 10 天都签到的用户对应的 bit 位上的值才会是 1。最后,我们可以用 BITCOUNT 统计下 Bitmap 中的 1 的个数,这就是连续签到 10 天的用户总数了。

现在,我们可以计算一下记录了 10 天签到情况后的内存开销。每天使用 1 个 1 亿位的 Bitmap,大约占 12MB 的内存(10^8/8/1024/1024),10 天的 Bitmap 的内存开销约为 120MB,内存压力不算太大。不过,在实际应用时,最好对 Bitmap 设置过期时间,让 Redis 自动删除不再需要的签到记录,以节省内存开销。

所以,如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用 Bitmap,因为它只用一个 bit 位就能表示 0 或 1。在记录海量数据时,Bitmap 能够有效地节省内存空间。

参考地址

环境准备

我们通过Docker快速的拉取一个环境,这样子对于学习来说成本比价低。直接参照https://hub.docker.com/r/mysql/mysql-server/ 来做就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 快速拉取一个mysql的image
➜ ~ docker pull mysql/mysql-server
Using default tag: latest
latest: Pulling from mysql/mysql-server
501550cfd5c0: Pull complete
e0509d775110: Pull complete
d5a01765d011: Pull complete
970507e942eb: Pull complete
Digest: sha256:0bb21c0f1aa9296e7deafacec5703b80e4d923dfdfcaa2efbe0c8377a8592128
Status: Downloaded newer image for mysql/mysql-server:latest
docker.io/mysql/mysql-server:latest

# 启动mysql容器
➜ ~ docker run --name=mysql1 -d mysql/mysql-server:latest
780c312f6fa1d3b001cb7c97fddb7df39fea61f27732b5fb9b59d5a29b12cfde

# 查看CONTAINER
➜ ~ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
780c312f6fa1 mysql/mysql-server:latest "/entrypoint.sh mysq…" 59 seconds ago Up 58 seconds (healthy) 3306/tcp, 33060-33061/tcp mysql1

# 查看默认的密码
➜ ~ docker logs mysql1 2>&1 | grep GENERATED
[Entrypoint] GENERATED ROOT PASSWORD: enohmYS*ecvISAqYh@GafRoDP3v

# 直接登录进去
➜ ~ docker exec -it mysql1 mysql -uroot -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 17
Server version: 8.0.23

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

# 修改一下容易记忆的密码,方便下次来使用
mysql> ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
Query OK, 0 rows affected (0.01 sec)

这是我认为非常快速的一个环境搭建的方式。

数据准备

一般像Orace、MySQL这些好的软件都会提供一些官网的数据来让方便学习着来学习。https://dev.mysql.com/doc/index-about.html

http://static.cyblogs.com/Jietu20210127-205659@2x.jpg

那我们就下载sakila database来学习一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mysql> show tables;
+----------------------------+
| Tables_in_sakila |
+----------------------------+
| actor |//演员表
| address |//地址表
| category |//影片的分类
| city |//城市信息
| country |//国家信息
| customer |//观看影片的用户信息
| film |//影片信息
| film_actor |//影片演员关联表
| film_category |//影片分类关联表
| film_text |//影片的文本信息,更新film的时候通过触发器保持film_text的同步
| inventory |//库存信息
| language |//影片的语言
| payment |//租赁付款信息
| rental |//某个影片库存的租赁信息
| staff |//商店员工信息
| store |//影片的商店信息
+----------------------------+
22 rows in set (0.00 sec)

整体的来看一下数据的一个关系

http://static.cyblogs.com/sakila.png

通过Sakila了解一些索引问题

http://static.cyblogs.com/Jietu20210127-213735.jpg

这里会有一个非常奇怪的问题,就是第一条sql竟然走了idx_actor_last_name索引。

1
EXPLAIN  SELECT actor_id FROM actor WHERE actor_id + 1 = 4;

可以理解为是走的B+树,叶子节点里面刚好存储的是主键,而主键刚好是actor_id,select的时候刚好是主键一个值吗?

参考地址

2020年,感觉真的是一个不平凡的年。是时间流逝非常快的一年,也是成长最快的一年。感谢身边所有的人~

谈口罩

还记得年初的时候,怕被疫情隔在家里了,正月初三就开车到了深圳。那时候听新闻说可能要带半年或者一年的口罩,当时感觉不可思议,根本不可能。现在一年已经已经过去了,但口罩还依然要继续……

说到口罩,记得刚刚开始在老家带口罩的时候,家里好多人不理解,为什么要戴口罩?(估计跟很多老美一样,觉得不尊重,觉得自己没病等)到了“恐慌”的时候,家里人比我们更着急,更加上心。在这次疫情中,真的也是感受到了人间百态,看到了白衣天使的伟大,看到了一些黑心的商家,看到了那些平凡的人做着不平凡的事儿,感受到了近些年来祖国的伟大(非常感谢国内短视频)。

今年给我最大的感受是繁忙、成长、充实,总的来说是收获的一年吧。

写作与公众号

年初的时候开启了我的个人公共账号「简栈文化」,一共发表了文章112篇,几乎全部都是跟技术相关的,在编写与梳理这些知识的时候,内心是充满热情与喜悦的。特别是大家去催更的时候,或者有问题请教你的时候,跟你讨论的时候,所有的熬夜与辛苦都是值得的,因为你获得的知识与朋友。后面也是因为工作原因,更新就慢慢的停了下来,但内心一直牵挂着写作这件事儿。

谈工作

今年可以说自己是换了工作,也可以说自己没有换工作,因为做的事情不同了,从楼上换到了楼下,但最大的老板还是同样的老板。不过,还是真的很感谢保险与国际的同事,都给了我很大的帮助、鼓励、肯定。在保险从事的是信贷相关的业务,从小白变成了有一丝丝信贷经验的从业者,也有着自己的小团队,而且氛围非常好,非常的优秀。到国际做的是证券业务,团队又是从0开始搭建,业务又不是很熟悉,感觉一下突然回到了4年前。经过了4个月左右的时间,终于又把团队搭建起来了,同时也在努力的去学习业务与技术。更重要的,我自己对自己的要求更高了,想做的更好。这次的团队跟之前一样,无可挑剔,更加的棒~

读书

这一年虽然看的书籍不是特别多(差不多12本),但是因为这一年的写作原因,内心是非常愿意静下来去好好看一本的。不管是技术的书籍,还是人物传记的书籍,总能在从中学到一些东西,哪怕只有其中只有某一句话、一件事儿、一个技术难点、一个技巧也是非常开心的。现在看书主要是在Kindle上面,技术书籍就会买纸质版本的,非常棒的书籍纸质版与电子版都买,还是为了图方便。不管如何,只要找到自己喜欢的方式,做任何事情都是对的~

感情

这一年,跟我老婆吵架非常的少,但记忆中好像也有这么12次是比较严重的,但都能很快过去。都说一个成功男人的背后一定有一个优秀的女人,我非常赞同。我觉得在思想上,我家那位比我要强,我是属于追赶她的情况。今年,一起去南京休闲游完了几天,特别喜欢这种慢悠悠,自由自在的旅行。一转眼,我们已经认识了10年,本想2人在很久以前就一直筹划着要过一个非常难忘的10周年,为此要做很多的准备,也是因为工作忙碌的原因,算是过了一个开心的10周年吧。这一年我从以前什么都不管慢慢开始的变为想去用心准备点什么,付出一点什么。为了明年去更多的没有去过的地方,特此还跟我老婆买了南航的随心飞,希望能去更多的地方看看这个美好的世界,去更多的地方打打卡。这几天还对摄影有点兴趣,日后可以好好学习学习

健康&保险

最近2年左右的时间,真的听说了太多太多亲人们身体不好做手术的,家里的老奶奶、舅姥姥等去世的消息。其实人到30~40岁之间,应该是蛮有经济压力的,自己的梦想还有很多未实现,爸爸妈妈的年纪也开始慢慢到了一个体质变弱、疾病变多的情况,所以今年我们为2边的家人都配置了意外险、百万医疗险,为自己也配置了这些,也终于在2020年的最后一天为我们俩配置了重疾险(本来想着买港险的,可这疫情一拖就是一年)。虽然这些保险远远还不够,但是也不用太着急,首先有了基本的保障,后面再慢慢的去添加就好了。起码,真的哪天有意外来临,也不会变得很被动。

朋友

今年一年跟我老婆也获得一些新朋友,特别是公共的朋友。在深圳这个城市,能够找到一些私下要好的朋友是很难的,因为大家的生活节奏都很快,大部分都不认识隔壁的邻居是谁,每天都是很早的去上班,很晚才回家来。如果有一些私下的朋友,节假日的时候可以一起凑个饭局,一起郊游,一起钓个鱼什么的都是一件很幸福的事情。所以,珍惜现在已有的好朋友,结交更多更优秀的人。

我知道立再多的flag也不如把一个flag做好来的重要,我希望自己在2021年里能更多知道自己的内心,知道自己追求的是什么?如何让自己的内心世界丰富起来,如果让自己做任何的事情都不会被别人牵着鼻子走,如何去影响、号召更多的其他人一起来做事。

2021年给自己的目的是:抓重点、求突破、顾家庭。

发现问题

今天本来是为了解决一个Apollo配置与Code同时变更不一致问题,我需要去通过SPI的方式去重写Apollo刷新Remote配置。所以,我就写一个很小的DEMO来验证每次变更Apollo配置对我服务端的取数。

1
2
3
4
5
6
7
@Data
@Component
public class MarketConfig {
@Value("${market.test.chenyuan}")
private String testName;

}

对应在Apollo的配置

1
market.test.name = Test1111

Controller层的代码

1
2
3
4
5
6
7
@Resource
private MarketConfig marketConfig;

@RequestMapping(value = "/sayHello", method = RequestMethod.POST)
private String sayHello(){ // 注意,这里由于我的不小心写成了private
return marketConfig.getMarketTestName();
}

然后我就发起请求来验证我的取数,结果给我报了NullpointException的异常。因为这种代码几乎每天都在写,一下次说我的Bean没有注入进来,还有点方。

然后,我细心的debug了看了一下。如图所示:

http://static.cyblogs.com/Jietu20201204-085800.jpg

图片可以说明:

  • Spring容器100%已经存在了MarketConfigBean对象;

  • 但是MarketConfig在该Controller里面100%没有注入进来 ;

  • this显示出了一个代理的路径;

    于是,我在同样的Controller去请求另外一个Method,但可以正常访问。

http://static.cyblogs.com/Jietu20201204-090305.jpg

  • Spring容器100%已经存在了MarketConfigBean对象;
  • 而且是可以确定这次MarketConfig是有注入进来的(没有展示图,不太方便)
  • this直接是显示的自己的地址

由于我就比较了一下方法的区别,最主要的区别点就是在于一个是private,另外一个是public

其实,这里我就很快明白了。因为我们这边在Controller层做了一些AOP来做监控与埋点。现阶段主要用的是CAT(现在做了很多的一些封装,后面可以分享下,如何做到无浸入性以及与Apollo的打通)。

AOP 里面用的是反射机制,用private修饰的类是注入失败的,因为拿不到,只能用public或者protected修饰。

参考地址

前奏

MySQL 的默认事务隔离级别为 Repeatable Read。而 ORACLESQLServer 等的默认隔离级别使用的是 Read Committed 模式,为什么呢?

开始我们的内容,相信大家一定遇到过下面的一个面试场景

面试官:“讲讲 mysql 有几个事务隔离级别?”

你:“读未提交,读已提交,可重复读,串行化四个!默认是可重复读”
面试官:“为什么 mysql 选可重复读作为默认的隔离级别?”
(你面露苦色,不知如何回答!)
面试官:“你们项目中选了哪个隔离级别?为什么?”
你:“当然是默认的可重复读,至于原因…呃…”
(然后你就可以回去等通知了!)

为了避免上述尴尬的场景,请继续往下阅读!
Mysql 默认的事务隔离级别是可重复读 (Repeatable Read),那互联网项目中 Mysql 也是用默认隔离级别,不做修改么?
OK,不是的,我们在项目中一般用读已提交 (Read Commited) 这个隔离级别!
what!居然是读已提交,网上不是说这个隔离级别存在不可重复读和幻读问题么?不用管么?好,带着我们的疑问开始本文!

我们先来思考一个问题,在 OracleSqlServer 中都是选择读已提交 (Read Commited) 作为默认的隔离级别,为什么 Mysql 不选择读已提交 (Read Commited) 作为默认隔离级别,而选择可重复读 (Repeatable Read) 作为默认的隔离级别呢?

Why?Why?Why?

这个是有历史原因的,当然要从我们的主从复制开始讲起了!
主从复制,是基于什么复制的?
是基于 binlog 复制的!这里不想去搬 binlog 的概念了,就简单理解为 binlog 是一个记录数据库更改的文件吧~
binlog 有几种格式?
OK,三种,分别是:

  • statement: 记录的是修改 SQL 语句
  • row:记录的是每行实际数据的变更
  • mixed:statement 和 row 模式的混合

Mysql 在 5.0 这个版本以前,binlog 只支持 STATEMENT 这种格式!而这种格式在读已提交 (Read Commited) 这个隔离级别下主从复制是有 bug 的,因此 Mysql 将可重复读 (Repeatable Read) 作为默认的隔离级别!
接下来,就要说说当 binlogSTATEMENT 格式,且隔离级别为读已提交 (Read Commited) 时,有什么 bug 呢?如下图所示,在主 (master) 上执行如下事务

http://static.cyblogs.com/image_editor_38dbfa43-6a3f-4812-a26c-c9dc984d4ccc.jpg

此时在主 (master) 上执行下列语句

1
select * from test;

输出如下

1
2
3
4
5
6
±–+
| b |
±–+
| 3 |
±–+
1 row in set

但是,你在此时在从 (slave) 上执行该语句,得出输出如下

1
Empty set

这样,你就出现了主从不一致性的问题!原因其实很简单,就是在 master 上执行的顺序为先删后插!而此时 binlog 为 STATEMENT 格式,它记录的顺序为先插后删!从 (slave) 同步的是 binglog,因此从机执行的顺序和主机不一致!就会出现主从不一致!

如何解决?

解决方案有两种!

  • 隔离级别设为可重复读 (Repeatable Read), 在该隔离级别下引入间隙锁。当 Session 1 执行 delete 语句时,会锁住间隙。那么,Ssession 2 执行插入语句就会阻塞住!
  • 将 binglog 的格式修改为 row 格式,此时是基于行的复制,自然就不会出现 sql 执行顺序不一样的问题!奈何这个格式在 mysql5.1 版本开始才引入。因此由于历史原因,mysql 将默认的隔离级别设为可重复读 (Repeatable Read),保证主从复制不出问题!

那么,当我们了解完 mysql 选可重复读 (Repeatable Read) 作为默认隔离级别的原因后,接下来我们将其和读已提交 (Read Commited) 进行对比,来说明为什么在互联网项目为什么将隔离级别设为读已提交(Read Commited)!

对比

OK,我们先明白一点!项目中是不用读未提交 (Read UnCommitted) 和串行化 (Serializable) 两个隔离级别,原因有二

采用读未提交 (Read UnCommitted), 一个事务读到另一个事务未提交读数据,这个不用多说吧,从逻辑上都说不过去!
采用串行化 (Serializable),每个次读操作都会加锁,快照读失效,一般是使用 mysql 自带分布式事务功能时才使用该隔离级别!(笔者从未用过 mysql 自带的这个功能,因为这是 XA 事务,是强一致性事务,性能不佳!互联网的分布式方案,多采用最终一致性的事务解决方案!)
也就是说,我们该纠结都只有一个问题,究竟隔离级别是用读已经提交呢还是可重复读?
接下来对这两种级别进行对比,讲讲我们为什么选读已提交 (Read Commited) 作为事务隔离级别!
假设表结构如下

1
2
3
4
5
CREATE TABLE `test` (
`id` int(11) NOT NULL,
`color` varchar(20) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB

数据如下

1
2
3
4
5
6
7
8
±—±------+
| id | color |
±—±------+
| 1 | red |
| 2 | white |
| 5 | red |
| 7 | white |
±—±------+

为了便于描述,下面将

  • 可重复读 (Repeatable Read),简称为 RR
  • 读已提交 (Read Commited),简称为 RC

缘由一

RR 隔离级别下,存在间隙锁,导致出现死锁的几率比 RC 大的多!
此时执行语句

1
select * from test where id = 2 for update;

RR 隔离级别下,存在间隙锁,可以锁住 (2,5) 这个间隙,防止其他事务插入数据!而在 RC 隔离级别下,不存在间隙锁,其他事务是可以插入数据!

RC 隔离级别下并不是不会出现死锁,只是出现几率比 RR 低而已!

缘由二

RR 隔离级别下,条件列未命中索引会锁表!而在 RC 隔离级别下,只锁行
此时执行语句

1
update test set color = 'blue' where color = 'red';

RC 隔离级别下,其先走聚簇索引,进行全部扫描。加锁如下:

http://static.cyblogs.com/image_editor_4485ac7d-e85b-4b76-962e-5d420e1ac0a4.png

但在实际中,MySQL 做了优化,在 MySQL Server 过滤条件,发现不满足后,会调用 unlock_row 方法,把不满足条件的记录放锁。
实际加锁如下

http://static.cyblogs.com/image_editor_d329942e-3fd7-4a88-b6ae-b294989c34b5.png

然而,在 RR 隔离级别下,走聚簇索引,进行全部扫描,最后会将整个表锁上,如下所示

http://static.cyblogs.com/image_editor_b504b652-9960-495a-a5ce-37826d0acdc7.jpg

缘由三

RC 隔离级别下,半一致性读 (semi-consistent) 特性增加了 update 操作的并发性!
5.1.15 的时候,innodb 引入了一个概念叫做 “semi-consistent”,减少了更新同一行记录时的冲突,减少锁等待。
所谓半一致性读就是,一个 update 语句,如果读到一行已经加锁的记录,此时 InnoDB 返回记录最近提交的版本,由 MySQL 上层判断此版本是否满足 updatewhere 条件。若满足 (需要更新),则 MySQL 会重新发起一次读操作,此时会读取行的最新版本 (并加锁)!
具体表现如下:
此时有两个 SessionSession1Session2
Session1 执行

1
update test set color = 'blue' where color = 'red';

先不 Commit 事务!
与此同时 Ssession2 执行

1
update test set color = 'blue' where color = 'white';

Session2 尝试加锁的时候,发现行上已经存在锁,InnoDB 会开启 semi-consistent read,返回最新的 committed 版本 (1,red),(2,white),(5,red),(7,white)。MySQL 会重新发起一次读操作,此时会读取行的最新版本 (并加锁)!
而在 RR 隔离级别下,Session2 只能等待!

两个疑问

RC 级别下,不可重复读问题需要解决么?
不用解决,这个问题是可以接受的!毕竟你数据都已经提交了,读出来本身就没有太大问题!Oracle 的默认隔离级别就是 RC,你们改过 Oracle 的默认隔离级别么?

RC 级别下,主从复制用什么 binlog 格式?
OK, 在该隔离级别下,用的 binlogrow 格式,是基于行的复制!Innodb 的创始人也是建议 binlog 使用该格式!

最后总结

  • 数据库默认隔离级别: mysql —repeatable、oracle,sql server —read commited
  • mysql binlog 的格式三种:statementrowmixed
  • 为什么 mysql 用的是 repeatable 而不是 read committed:5.0 之前只有 statement 一种格式,而主从复制存在了大量的不一致(bug),故选用 repeatable
  • 为什么其他数据库默认的隔离级别都会选用 read commited 原因有二:repeatable 存在间隙锁会使死锁的概率增大,在 RR 隔离级别下,条件列未命中索引会锁表!而在 RC 隔离级别下,只锁行
  • RC 级用别下,主从复制用什么 binlog 格式:row 格式,是基于行的复制!如果使用 statement 格式,会导致主从不一致。

参考地址

首先,我们来看一下Eureka的架构图,有一个整体的认识。传送门:https://github.com/Netflix/eureka/wiki/Eureka-at-a-glance

http://static.cyblogs.com/eureka_architecture.png

Eureka核心功能

服务注册

在微服务启动时,首先,服务提供者需要将自己的服务注册到服务注册中心,服务提供者在启动的时候会发送REST请求将自己注册到服务注册中心上,并带上一些元信息。服务注册中心接收到REST请求,会将元信息存储在一个双层Map中,第一层key是服务名,第二层key是具体服务的实例名。

注意:在服务注册时,需要确认一下eureka.client.register-with-eureka=true是否正确,如果为false是禁止向服务注册中心注册的。

服务同步

当服务成功的注册到了注册中心之后,由于注册中心可能是高可用的集群,那么我们的服务可能只注册到了一个集群中的一个注册中心上,被一个注册中心所维护,而不被另外一个注册中心所维护,那么这个时候,我们就需要将这个注册中心的信息同步给集群中其他的注册中心,这就叫服务同步。那么他是如何实现的呢?

由于在集群中,一个注册中心互为其他注册中心的服务,当服务提供者请求到一个服务注册中心后,它会将请求转发到其他服务注册中心,实现注册中心之间的服务同步。

通过服务同步,服务提供者的服务信息可以通过集群中的任何一个服务注册中心获取。

服务续约

在注册完成后。服务提供者会维护一个心跳告诉注册中心服务,心跳间隔大约是30S,防止注册中心剔除服务, 正常情况下,如果Eureka Server90秒没有收到Eureka客户的续约,它会将实例从其注册表中删除。这个过程称为服务续约。

服务获取

当一切的注册相关工作完成后,我们自然要获取服务清单,那么如何获取服务呢? 启动服务消费者后,消费者会发送一个REST请求给服务注册中心,来获取上面注册的服务清单。 而服务注册中心会维护一份只读清单返回给消费者客户端,该缓存清单30s更新一次。

服务调用

消费者获取服务清单后,可以通过服务名获取到具体服务实例与实例的元数据信息。这个时候,我们可以通过Ribbon调用我们的目标服务,默认采用轮询的方式,从而实现负载均衡。

服务下线

当我们需要对服务实例进行正常的关闭操作时,它会触发一个服务下线的REST请求给服务端。注册中心接收到请求后,将该服务状态置为DOWN,并把下线时间传播出去。

失效剔除

有的时候,我们的服务意外挂掉了,那么Eureka如何识别出我们异常的服务,并将其剔除呢?

服务注册中心启动时会创建定时任务,默认60s一次,将当前清单中超时(90s)没有续约的服务剔除。

自我保护

当失效剔除机制引入的时候,就会有一个问题,如果一个地区网络特别不稳定,那么服务可能不会续约,但我们还需要这个服务存在。这个时候,我们怎么解决呢?

还好,Eureka拥有自我保护机制,可以很好的解决这个问题。Eureka Server在运行期间,会统计心跳失败的比例在15分钟之内是否低于85%,如果低于,就会将当前实例注册信息保护起来,同时提示一个警告,一旦进入保护模式,Eureka Server将会尝试保护其服务注册表中的信息,不再删除服务注册表中的数据。也就是不会注销任何微服务。 但是保护机制也有可能会出现问题,导致服务实例不能够被正确剔除。比如在保护期间,实例出现问题,那么客户端很容易拿到实际已经不存在的服务实例,会出现调用失败。

源码细节与验证

1
2
3
4
5
6
7
8
9
10
11
protected void postInit() {
renewsLastMin.start();
if (evictionTaskRef.get() != null) {
evictionTaskRef.get().cancel();
}
evictionTaskRef.set(new EvictionTask());
// 服务剔除,private long evictionIntervalTimerInMs = 60 * 1000;
evictionTimer.schedule(evictionTaskRef.get(),
serverConfig.getEvictionIntervalTimerInMs(),
serverConfig.getEvictionIntervalTimerInMs());
}

参考地址

0%