分布式系统
分布式系统是由多个独立的计算机节点组成的系统,这些节点通过网络进行通信,共同完成一个任务。对这个系统的用户来说,整个系统应当和一台计算机没有区别。
无论分布式系统在空间上如何分布,其一定满足以下特性:
- 分布性:系统中的节点分布在不同的地理位置
- 对等性:系统中的节点没有主次之分,都是对等的
- 自治性:系统中的节点可以独立运行,不依赖于其它节点
- 并发性:系统中的节点可以并发地执行任务
分布式系统需要解决这样几个问题:
- 缺乏全局时钟:节点可能会出现时钟不同步的情况,无法使用时间序列来控制
- 节点故障:节点可能会出现故障,导致数据丢失或者不一致
- 网络分区:节点之间可能会出现网络分区,导致通信失败,数据出现不一致
- 分布式三态:节点可能会出现成功、失败、超时这三种情况,需要分别处理
- 数据丢失:节点的通信和存储都可能会出现数据丢失的情况
衡量一个分布式系统,通常有以下几个指标:
- 性能:系统的响应时间、吞吐量等
- 可靠性:系统的可用性、容错性等
- 可扩展性:系统的扩展性、负载均衡等
- 一致性:系统的数据一致性、事务一致性等
理论和算法
我们这里需要讨论的分布式系统是存在互联及数据交换的系统。
分布式理论
CAP
CAP 包括三个特性:
- 一致性(Consistency):所有节点上的数据是一致的
- 可用性(Availability):除故障节点外,所有请求都能够得到响应
- 分区容忍性(Partition Tolerance):系统在网络分区(即部分节点之间的通信失败)的情况下仍然能够正常工作
CAP 定理指出,一个分布式系统最多只能同时满足这三个特性中的两个:
-
CA
对于 CA 的情况,一旦网络分区,假如要满足 C,那么在一个节点写入的时候,就必须禁止其它节点的写操作,这样就违背了 A;类似的,如果所有节点都能读写,那么就会出现不一致的情况,违背了 C。
因此,CA 只能再不考虑网络分区的情况下存在。不过,要是不考虑网络分区,谈何分布式系统?
-
CP
当网络发生分区时,系统可以保证一致性和分区容忍性。这种情况下,系统会牺牲可用性。
这种模式适用于需要保证数据一致性的场景,比如金融系统等。
-
AP
当网络发生分区时,系统可以保证可用性和分区容忍性。这种情况下,系统会牺牲一致性。
这种模式适用于需要保证系统可用性的场景,比如社交网络等。
综上,分布式系统必须在 CP 和 AP 之间做出选择。
BASE
BASE 是对 CAP 的一种补充,它是指:
-
基本可用(Basically Available):系统保证基本的可用性,允许损失部分一致性。例如,允许服务的响应时间延迟。
-
软状态(Soft state):系统中的数据可以存在中间状态,而不需要一直保持一致。例如,缓存中的数据可能过期。
-
最终一致性(Eventual Consistency):经过一定的时间后,系统中的数据最终会达到一致状态。例如,数据同步可能需要一段时间,但在最终状态下,数据是一致的。
前两种特性较容易实现,但达成最终一致性需要一定的方法,例如:
- 读时修复:读取数据时,如果发现数据不一致,可以进行修复
- 写时修复:写入数据时,如果发现数据不一致,可以进行修复,这是性能最好的算法
- 异步修复:定期检查数据,进行修复,这也是最常用、最容易实现的方法
分布式算法
一致性 Hash
如果我们有一些数据需要分散存储在多个节点上,可以计算其哈希值,然后对机器的数量取模,将数据存储在对应的节点上。
然而,这种方法有一个问题:当节点数量发生变化时,所有数据都需要重新计算哈希值,然后重新分配。这样会导致大量数据迁移,影响系统性能。
一致性 Hash 算法解决了这个问题:
- 首先将所有哈希值映射到一个环上
- 每个节点对应环上的一个点
- 当有数据需要存储时,计算其哈希值,然后将其映射到环上,这个数据会被存储到距离其最近的节点上
- 当某个节点增加或者删除时,只会影响到其相邻的节点
这样,一致性 Hash 算法可以保证数据迁移量最小,提高系统性能。
但这依然存在一个问题:当节点数量较少时,数据可能会不均匀地分布在环上。这导致有些节点负载较重,有些节点负载较轻。为了解决这个问题,可以引入虚拟节点:
- 环上均匀分配多个虚拟节点
- 数据正常存储到虚拟节点上
- 每个节点会均匀地托管多个虚拟节点
- 当节点增加或者删除时,只需要按照虚拟节点为单位,进行重新分配
Paxos
Paxos 是一个分布式一致性算法,它的目标是在一个分布式系统中,多个节点之间达成一致的决策。
我们之前讲过拜占庭将军问题,Paxos 算法就是解决这个问题的一种方法。
拜占庭有 10 个将军,其中有 4 个将军是叛徒。他们现在兵临城下,需要就进攻还是撤退达成一致。每个将军只能通过信使与另一位将军进行沟通,消息可能会延迟、破坏或丢失。如何保证最终所有将军都能达成一致意见?
Paxos 算法的核心思想是:
- 提议者(Proposer):负责接受客户端请求,根据请求生成提案,并向接受者发送提案。这个提案包括提案编号和提案内容
- 接受者(Acceptor):负责接受提案,并根据提案编号和提案内容决定是否接受提案。无论是否接受提案,就会将结果发送给学习者
- 学习者(Learner):负责接受接受者的结果,并根据结果决定是否执行提案,然后将结果返回给客户端
其过程如下:
- Prepare 阶段
-
Prepare
:提议者生成全局唯一且递增的提案编号ProposalID
,然后向所有接受者发送Prepare
请求,其只携带ProposalID
-
Promise
:接受者收到Prepare
请求后- 承诺不再接受
ProposalID
\(\leq\) 当前流程的Prepare
请求携带的ProposalID
- 承诺不再接受
ProposalID
\(<\) 当前流程的Propose
请求携带的ProposalID
- 不违背以前做出的承诺的情况下,回复已经
Accept
过的提案中,ProposalID
最大的那个提案的ProposalID
和ProposalValue
- 承诺不再接受
-
- Accept 阶段
-
Propose
:提议者收到大多数接受者的Promise
应答后,从中选出ProposalID
最大的ProposalValue
,作为本次提案。然后携带ProposalID
和ProposalValue
向所有接受者发送Propose
请求 -
Accept
:接受者收到Propose
请求后,在不违背以前做出的承诺的情况下,接受并持久化提案
-
- Learn 阶段
-
Learn
:提议者收到大多数接受者的Accept
应答后,就可以执行提案,并将形成的决议发送给学习者
-
拜占庭将军问题中,还考虑了消息可能被篡改、恶意行为等情况。这在当今的区块链技术中是很重要的。然而,对于普通的分布式系统,我们可以不去考虑这些情况,只需要保证大多数节点能够达成一致即可。
Paxos 算法虽然过程简单,但要理论证明一个实现的正确性是非常困难的。因此,Paxos 算法的实现通常会比较复杂。
之前看到,Paxos 要想通过一个提案,需要在提议者和接受者之间来回通信 2 次。我们可以进行改进,一次性确定多个提案,形成多个决议。这就是 Multi-Paxos 算法。
Multi-Paxos 算法的核心思想是:
- 所有提议者选出一个 Leader,然后由 Leader 向所有接受者发送提案
- 由于只有一个节点会进行提议,因此可以跳过 Prepare 阶段,直接进行 Accept 阶段
可以看出,Multi-Paxos 算法比 Paxos 算法更加高效。
Zookeeper 就是使用了 Multi-Paxos 的变形算法 Zab 来保证数据的一致性。
Raft
Raft 是对 Paxos 的一种改进实现,它是一种更容易理解的分布式一致性算法。其目前被 Kafka、Etcd、Consul 等系统广泛使用。
Raft 有三种角色:
- 领导者(Leader):负责接受客户端请求、生成日志、向其它节点发送日志
- 跟随者(Follower):接受 Leader 的日志,并将其应用到状态机
- 候选人(Candidate):在选举过程中,节点会成为候选人,然后发起选举
Raft 中的时间被划分为多个任期(Term),选举 Leader 会触发任期的增加。
日志同步
Raft 通过日志复制来保证一致性。
日志在 Raft 中被称为 log
,其有多个 entry
。entry
包含了 <term, index, command>
三个字段:
-
term
:Leader 在生成该entry
时的任期编号 -
index
:该entry
在日志中的索引位置 -
command
:客户端请求的命令
Raft 满足如果两个日志中的 entry
在同一个索引位置,那么它们的 term
和 command
必须一致。
通常情况下,Leader 收到客户端请求:
- 首先将请求作为一个新的
entry
添加到自己的日志中 - 然后向 Follower 发起
AppendEntries
RPC,要求 Follower 复制entry
- 当大多数 Follower 复制成功后,Leader 会将日志应用到状态机,然后返回结果给客户端
Leader 的宕机或者网络的故障可能导致 Leader 和 Follower 之间的日志不一致。Raft 通过 nextIndex
来解决这个问题:
- 一个新的 Leader 上任后,会为每个 Follower 维护一个
nextIndex
,表示下一个要复制的entry
的索引位置,其初始值为 Leader 的日志长度 + 1 - 当 Leader 发起
AppendEntries
RPC 时,其内容包含了<term[nextIndex - 1], nextIndex - 1>
这两个元信息 - Folloer 收到
AppendEntries
RPC 后,会检查自己的日志是否存在对应位置的entry
,以及term
是否一致- 如果没有这么多数量的
entry
或者term
不一致,就会返回失败 - 如果一致,就会将自己日志中
nextIndex
及之后的内容全部删除,然后将 Leader 的日志追加到自己的日志中
- 如果没有这么多数量的
- Follower 如果收到失败响应,会将该 Follower 的
nextIndex
减 1,然后再次发起AppendEntries
RPC。这个过程会一直重复到 Follower 的日志和 Leader 的日志一致
选举
Raft 中 Leader 以一定频率向所有 Follower 发送心跳包,以保持 Leader 地位。
如果某个 Follower 发现,在 electionTimeout
时间内没有收到心跳包,它就会发起一次选举:
- 首先将自己的任期
currentTerm
加 1,然后将自己的状态切换为候选人 - 然后给自己投一票,并向其它节点发送
RequestVote
RPC 请求,其携带有<term, candidateId, lastLogIndex, lastLogTerm>
,表示自己的任期、自己的编号、自己的日志最后一个entry
的索引位置和term
- 其它节点收到
RequestVote
RPC 后,会检查自己的任期和候选人的任期- 如果候选人的任期比自己大,那么就会投票给候选人
- 如果候选人的任期比自己小,那么就会拒绝请求并让候选人更新任期
- 候选人统计收到的投票数量
- 如果收到大多数节点的投票,那么就会成为 Leader
- 如果收到了某个声称自己是 Leader 节点的心跳包
- 如果该节点的任期比自己大,那么就会切换为 Follower
- 如果该节点的任期比自己小,那么就会拒绝请求并让它更新任期
- 如果有多个节点同时竞选,且没有一个能收到大多数节点的投票,那么就会等待
electionTimeout
时间后再次发起选举
为了防止多个节点同时竞选,在失败后无限循环不停地发起选举且选不出 Leader,Raft 引入了随机化的 electionTimeout
时间。这让大多数时候只有一个节点会发起选举。
脑裂
我们前面已经解决了 Follower 和 Leader 之间的日志不一致问题。但是,如果出现网络分区,将会导致多个 Leader 同时存在,这就是脑裂问题。
出现脑裂后,Leader 会将自己的 term
和别的 Leader 进行比较。如果别的 Leader 的 term
更大,那么就会主动退位为 Follower,并从别的 Leader 处复制日志。
Gossip
Raft 协议是一个中心化的强一致性协议,它需要 Leader 来协调所有节点的操作。但是,Leader 也是一个单点故障,一旦 Leader 宕机,整个系统就会瘫痪一段时间,直到新的 Leader 选举出来。
Gossip 协议是一种去中心化的弱一致性协议,它通过节点之间的随机通信来传播信息。其目前被 Redis 集群、Cassandra 等系统广泛使用。
由于节点之间的通信是随机的,所以达成一致的时间是不确定的。为了尽快达到一致状态,Gossip 有两种优化策略:
Anti-Entropy
反熵是一种定期检查节点状态的策略
- 每个节点都会定期选择一个节点,然后向这个节点发送自己的状态信息
- 节点收到状态信息后,如果发现收到的状态信息比自己的新,那么就会更新自己的状态信息
为了在确定的时间里达到一致状态,可以设计一个闭环的 Anti-Entropy 策略:
- 所有节点组成一个有向环
- 每个节点只向环中的下一个节点发送状态信息
从理论上来讲,这个换只要循环两圈,就能够保证所有节点的状态一致。
反熵适合节点数较少且变化不频繁的情况;但是如果节点数较多,或者节点状态变化频繁,那么反熵的效率就会变得很低。
Epidemic
传染病模型是一种随机传播信息的策略
- 每个节点在更新后,会变为一个感染者
- 感染者会向相邻节点发送状态信息,或者说是感染其它节点
- 感染者会以一定的概率康复,不再传播信息
传染病模型的优点是简单,适合节点较多的情况;但是缺点是如果信息包过大,会导致网络拥堵。
分布式锁
要讲分布式锁,就不得不提系统设计中的三种锁:
- 线程锁:用来给方法、代码块加锁,保证同一时间只有一个线程能够执行
- 进程锁:用来给一个共享资源加锁,保证同一时间只有一个进程能够访问
- 分布式锁:当多个进程在不同的机器上,需要访问同一个共享资源时,就需要分布式锁
类似别的锁,分布式锁需要满足以下几个特性:
- 互斥性:同一时间只有一个进程能够获取锁
- 无死锁:即使进程获取锁失败,也不会导致死锁
- 容错性:即使锁服务宕机,也不会导致锁失效
基于数据库
锁表
最简单的分布式锁实现就是使用数据库的锁表。其原理是:
- 创建一个表,用来存储锁的信息
- 当进程需要获取锁时,向表中插入一条记录
- 当进程需要释放锁时,删除表中的记录
例如,我们可以创建一个 lock
表,其结构如下:
CREATE TABLE lock (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` INT NOT NULL COMMENT '锁定的资源',
`description` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '描述',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';
然后,我们可以使用以下 SQL 来获取锁:
INSERT INTO lock (resource, description) VALUES (1, 'test')
如果返回值为 1,说明获取锁成功;如果返回值为 0,说明获取锁失败。
然后,我们可以使用以下 SQL 来释放锁:
DELETE FROM lock WHERE resource = 1
这种方式的优点是简单易用,但是存在一些问题:
- 死锁:如果进程获取锁后,宕机了,那么锁就会一直存在
- 超时:如果进程获取锁后,没有释放锁,那么就会导致其它进程无法获取锁
- 性能:频繁的插入和删除操作会导致数据库性能下降
悲观锁
悲观锁是一种基于数据库的锁实现,其原理是:
- 在对任意记录进行修改前,先尝试为该记录加上排他锁
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁
- 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常
例如,我们可以使用以下 SQL 来获取锁,使用之前需要先关闭自动提交:
BEGIN;
SELECT `stock` FROM `t_goods` WHERE `id` = 1 FOR UPDATE;
INSERT INTO `t_orders` (`goods_id`, `user_id`) VALUES (1, 1);
UPDATE `t_goods` SET `stock` = `stock` - 1 WHERE `id` = 1;
COMMIT;
其中,FOR UPDATE
是加锁的关键字,它会为 t_goods
表的 id = 1
的记录加上排他锁。
乐观锁
乐观锁是一种基于数据库的锁实现,其原理是:
- 在对任意记录进行修改前,先尝试读取该记录的版本号
- 如果版本号没有变化,那么就可以对记录做修改
- 如果版本号发生变化,那么就会抛出异常,说明该记录正在被修改
例如,我们可以使用以下 SQL 来获取锁:
SELECT `stock`, `version` FROM `t_goods` WHERE `id` = 1;
UPDATE `t_goods` SET `stock` = `stock` - 1, `version` = `version` + 1 WHERE `id` = 1 AND `version` = #{version};
其中,version
是记录的版本号,每次修改都会增加。如果修改时 version
和查询时的 version
不一致,那么就会抛出异常。
基于 Redis
SET NX PX
NX
是 SET
命令的一个选项,表示只有当键不存在时,才会设置成功。而 PX
表示设置键的过期时间。
SET productId:lock randomValue NX PX 30000
解锁时,我们需要先判断持有的 randomValue
是否和 Redis 中的 randomValue
一致,然后再删除:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 productId:lock randomValue
Redlock
Redlock 是 Redis 官方提供的一种分布式锁实现。
假设我们有 \(n\) 个 Redis 主节点,现在有多个进程需要获取锁:
- 进程生成一个随机值
randomValue
- 进程轮询 \(n\) 个 Redis 主节点,使用
SET productId:lock randomValue NX PX 20
命令尝试获取锁 - 如果有 \(n/2+1\) 个 Redis 主节点成功获取锁,那么就认为获取锁成功
- 否则依次遍历删除已经获取的锁
Redission
Redission 是 Redis 官方提供的一种分布式锁实现。
RLock lock = redisson.getLock("lock");
try {
lock.lock();
// do something
} finally {
lock.unlock();
}
Redission 会自动续期锁,避免锁过期后被释放。
- 它的所有操作都通过 Lua 脚本来执行,保证了原子性
- 默认加锁时间只有 30 秒,避免了死锁
- 通过
watchdog
机制,保证了锁的续期
基于 Zookeeper
Zookeeper 可以创建一个用于发号的节点,为想要获取锁的进程分配一个序号。序号最小的进程就可以获取锁。
每个进程在尝试获取锁时,都会检查自己的序号是否是最小的。如果是,那么就获取锁;否则就等待。
public class DistributedLock {
private ZooKeeper zk;
private String lockPath;
private String currentPath;
private String waitPath;
public DistributedLock() {
try {
zk = new ZooKeeper("localhost:2181", 3000, null);
if (zk.exists("/locks", false) == null) {
zk.create("/locks", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
} catch (Exception e) {
e.printStackTrace();
}
}
public void lock() {
try {
currentPath = zk.create("/locks/seq-", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> children = zk.getChildren("/locks", false);
Collections.sort(children);
if (currentPath.equals("/locks/" + children.get(0))) {
return;
}
String currentNum = currentPath.substring("/locks/seq-".length());
for (int i = 0; i < children.size(); i++) {
if (currentNum.equals(children.get(i))) {
waitPath = "/locks/" + children.get(i - 1);
break;
}
}
CountDownLatch countDownLatch = new CountDownLatch(1);
zk.exists(waitPath, new Watcher() {
@Override
public void process(WatchedEvent event) {
if (event.getType() == Event.EventType.NodeDeleted) {
countDownLatch.countDown();
}
}
});
countDownLatch.await();
} catch (Exception e) {
e.printStackTrace();
}
}
public void unlock() {
try {
zk.delete(currentPath, -1);
} catch (Exception e) {
e.printStackTrace();
}
}
}
分布式 ID
很多时候,数据库的主键会使用自增 ID。然而,在分布式系统中,同一个表可能会分布在多个机器上,我们需要保证生成的 ID 在全局上是唯一的。
UUID
UUID(Universally Unique Identifier)是一种全局唯一的标识符,由 32 位 16 进制数字所构成。UUID 理论上的总数为 \(16^32=2^128 \sim 3.4 \times 10^38\)。太多了!根本用不完!
UUID 的格式为 8-4-4-4-12
,例如 550e8400-e29b-41d4-a716-446655440000
。正常在使用时,会去掉连字符。
UUID 有 5 种版本:
- 基于时间的 UUID:根据时间戳、MAC 地址和随机数生成。这种 UUID 的唯一性取决于 MAC 地址的唯一性,但它也可能暴露 MAC 地址,不太安全
- 基于 DCE 安全的 UUID:与基于时间的 UUID 类似,但会把时间戳的前 4 位替换为 POSIX 的 UID 或 GID
- 基于名称的 MD5 UUID:计算名字和名字空间的 MD5 哈希值得到。它保证了相同名字空间下的唯一性
- 基于随机数的 UUID:使用伪随机数生成。这也是 JDK 默认的 UUID 生成方式
- 基于名称的 SHA-1 UUID:计算名字和名字空间的 SHA-1 哈希值得到
在 Java 中,可以使用 java.util.UUID
类来生成 UUID。
import java.util.UUID;
public class UUIDTest {
public static void main(String[] args) {
// 基于随机数的 UUID
UUID randomUuid = UUID.randomUUID();
System.out.println(randomUuid.toString().replace("-", ""));
// 基于名称的 MD5 UUID
byte[] name = "testName".getBytes();
UUID nameUuid = UUID.nameUUIDFromBytes(name);
System.out.println(nameUuid.toString().replace("-", ""));
}
}
UUID 只需要本地生成,不需要网络通信,因此生成速度很快。但是,UUID 存在一些问题:
- UUID 是 128 位的,存储空间较大
- 信息不安全,可能会暴露 MAC 地址
- 无序,不适合作为数据库的主键
数据库生成
本地生成的方式还可以使用数据库来生成 ID。
尽管数据库的 ID 是从 1 开始自增的,但我们可以使用以下两个字段来生成全局唯一的 ID:
-
auto_increment_offset
:自增 ID 的起始值 -
auto_increment_increment
:自增 ID 的步长
比如我们有 3 台机器,就可以分别设置为:
- 机器 1:
auto_increment_offset=1, auto_increment_increment=3
- 机器 2:
auto_increment_offset=2, auto_increment_increment=3
- 机器 3:
auto_increment_offset=3, auto_increment_increment=3
这种方式只依赖于数据库,且 ID 是单调递增的。然而,这种方式也存在一些问题:
- 依赖于数据库,可能会成为瓶颈
- 主从切换时,可能会出现 ID 重复
Redis 实现
Redis 可以使用 INCR
命令来生成全局唯一的 ID。
INCR key
该命令是原子的,可以保证 ID 的唯一性。
然而,单机 Redis 可能存在性能瓶颈,需要使用 Redis 集群,并配合之前使用不同步长的方式来生成 ID。
Snowflake
Snowflake 是 Twitter 开源的分布式 ID 生成算法,其生成的 ID 是 64 位的,由以下几部分组成:
- 1:符号位,始终为
0
- 2-42:时间戳,毫秒级,41 位,可以使用 69 年
- 43-52:机器 ID,10 位,可以有 1024 台机器
- 53-64:自增序列号,12 位,每毫秒最多生成 4096 个 ID
这样一来,Snowflake 每秒可以在每台机器上生成 4096 个 ID。
雪花算法的优点是:
- ID 是有序的,适合作为数据库的主键
- ID 是全局唯一的
- 不依赖第三方组件,可以独立生成
雪花算法的缺点是:
- 依赖于时间戳,如果时间回拨,可能会生成重复 ID
分布式缓存
Redis
这个我们在深入 Redis 源码系列中花费了大量笔墨来讲解,这里就不再赘述。
Memcached
Memcached 是一个高性能的分布式内存对象缓存系统,其特点是:
-
简单:Memcached 只提供了简单的
set
、get
、delete
等操作 - 高性能:Memcached 使用内存来存储数据,因此读写速度非常快
- 分布式:Memcached 可以通过多个节点来存储数据,提高了可用性和性能
类似于 Redis 集群 Cluster 中的数据分配方式,Memcached 也是通过一致性哈希算法来分配数据的。
在内存方面,Memcached 会将数据分为多个 slab,每个 slab 大小为 1MB。
一个 slab 中包含多个相同大小的 chunk,每个 chunk 中都保存了一个 item 结构体,包含了 key、value 等信息。尽管同一个 slab 中的 chunk 大小相同,但不同 slab 中的 chunk 大小可能不同。
memcached 采用预分配、分组管理的方式来管理内存:
- 向 memcached 添加数据时,会先根据数据大小,找到最合适的 slab
- 找到 slab 后,memcached 会检查 slab 中是否有空闲的 chunk
- 如果有,就将数据写入 chunk 中
- 如果没有,就会分配一个新的同类型 slab,并将数据写入新的 slab 中
Memcached 相比 Redis:
- 数据类型:Memcached 只支持字符串,而 Redis 支持字符串、列表、集合、有序集合、哈希表等多种数据类型
- 持久化:Memcached 不支持持久化,而 Redis 支持 RDB 和 AOF 两种持久化方式
- 集群:Memcached 通过一致性哈希算法来分配数据,而 Redis 有多种集群方式
-
功能:Memcached 只提供了简单的
set
、get
、delete
等操作,而 Redis 提供了更多的操作
分布式事务
详见消息队列。
分布式系统应用
API 网关
在微服务背景下,系统被拆分为多个服务,每个服务都有自己的 API。服务之间的调用会变得非常复杂,这时就需要一个 API 网关来统一管理。
API 网关是一个系统的入口,用来统一管理和转发请求。它主要包括以下几个功能:
- 请求转发:将请求转发到具体的服务
- 负载均衡:根据实例的负载情况,选择一个实例来处理请求
- 安全认证:对请求进行认证,保证请求的安全性
- 参数校验:对请求参数进行校验,保证请求的合法性
- 日志记录:记录请求的日志,方便后续排查问题
- 监控报警:监控服务的运行情况,及时发现问题
- 流量控制:对请求进行限流,保证服务的稳定性
- 熔断降级:对请求进行熔断降级,保证服务的可用性
- 响应缓存:对响应进行缓存,提高服务的性能
- 响应聚合:将多个服务的响应聚合在一起,然后再返回给客户端
- 灰度发布:对请求进行灰度发布,将请求分流到不同的服务
- 异常处理:对请求的异常进行处理,保证服务的稳定性
- API 文档:生成 API 文档,方便开发人员查看
- 协议转换:对请求的协议进行转换,保证服务的兼容性
- 证书管理:对证书进行管理,保证请求的安全性
在一个常规的微服务架构中,客户端的请求会先打到 Nginx,然后再转发到 API 网关。API 网关可能有多个,它们再将请求转发到具体的服务。
Spring Cloud Gateway
Spring Cloud Gateway 是 Spring Cloud 的一个子项目,用来构建 API 网关。
一个请求在 Spring Cloud Gateway 中的处理流程如下:
- Gateway Handler Mapping:根据请求的 URL,找到对应的路由
- Gateway Web Mapping:根据路由的配置,找到对应的服务
-
Pre-request
- Filter:很多 Filter 组成一条 Filter Chain,用来对请求进行处理,包括参数校验、安全认证、流量控制等
- Proxied Service:服务处理请求
-
Post-response
- Filter:很多 Filter 组成一条 Filter Chain,用来对响应进行处理,包括日志记录、监控报警、响应缓存等
- Response:返回响应
Spring Cloud Gateway 的可以配置多个路由,每个路由可以配置多个断言和过滤器。
一个简单的 Spring Cloud Gateway 配置如下:
spring:
cloud:
gateway:
routes:
- id: test_route # 路由 ID
uri: http://localhost:8080 # 转发地址
predicates: # 断言
- Path=/test/** # 匹配路径
- Method=GET # 匹配方法
filters: # 过滤器
- StripPrefix=1 # 去掉前缀
- RewritePath=/test/(?<segment>.*), /$\{segment} # 重写路径
- id: test_route2
uri: http://localhost:8081
predicates:
- Path=/test2/**
- Method=POST
filters:
- StripPrefix=1
- RewritePath=/test2/(?<segment>.*), /$\{segment}
Spring Cloud Gateway 的断言包括:
断言 | 例子 | 描述 |
---|---|---|
After |
After= 2020-01-01T00:00:00 +08:00[Asia/Shanghai]
| 请求时间在指定时间之后 |
Before |
Before= 2020-01-01T00:00:00 +08:00[Asia/Shanghai]
| 请求时间在指定时间之前 |
Between |
Between= 2020-01-01T00:00:00 +08:00[Asia/Shanghai], 2020-01-02T00:00:00 +08:00[Asia/Shanghai]
| 请求时间在指定时间之间 |
Cookie | Cookie=name, value | 请求中包含指定 Cookie |
Header | Header=name, value | 请求中包含指定 Header |
Host | Host=**.example.com | 请求的 Host 匹配指定 Host |
Method | Method=GET | 请求的方法匹配指定方法 |
Path | Path=/test/** | 请求的路径匹配指定路径 |
Query |
Query=param Query=param, value
| 请求的参数匹配指定参数 |
Weight | Weight=group, 1 | 请求的权重匹配指定权重 |
RPC 框架
在微服务架构中,服务之间的调用会变得非常频繁。为了简化服务之间的调用,我们可以使用 RPC 框架。
RPC(Remote Procedure Call)是一种远程调用的协议,它允许一个进程调用另一个进程的过程。在代码层面,RPC 会将远程调用封装成一个函数调用。
调用方在调用时,只需要知道函数名和参数,而不需要关心函数是如何实现的。RPC 框架会通过代理类,将调用方的请求序列化成二进制数据,然后通过网络传输到服务方,服务方再将二进制数据反序列化成请求,然后调用对应的函数。
不过,其实 RPC 框架跟 HTTP 协议区别并不大。我们知道,TCP 协议有粘包、拆包的问题,其根本原因是 TCP 是流式协议,没有消息边界。而 HTTP 协议是基于 TCP 协议的,它在消息头中有 Content-Length
字段,用来表示消息的长度。RPC 框架也是类似的,它会为 TCP 包加入消息边界,以便于解决粘包、拆包的问题。与 HTTP 不同的时,TCP 包的内容全部使用二进制数据,而 HTTP 使用文本数据。
我个人认为,偏要使用 HTTP 去做远程调用,是完全没有问题的。甚至来讲,很多 RPC 框架的效率还不如 HTTP。只不过,由于技术惯性以及 RPC 框架的专一特性,如今大家还在使用 RPC 框架。
Dubbo
Dubbo 是阿里巴巴开源的一款高性能 Java RPC 框架,它不止提供了远程调用的功能,还提供了服务治理、负载均衡、容错、降级、限流等功能。
Dubbo 包括以下几个角色:
- Container:服务容器,用来加载和启动服务
- Provider:服务提供者,会向服务中心注册自己提供的服务
- Consumer:服务消费者,会从服务中心订阅服务
- Registry:服务注册中心,用来注册和发现服务
- Monitor:服务监控中心,用来监控服务的运行情况
gRPC
gRPC 是 Google 开源的一款高性能 RPC 框架,它使用 Protocol Buffers 作为序列化工具,支持多种语言。
gRPC 采用 Protobuf 作为序列化工具。
Comments