分布式 ID 的实现
分布式 ID 需要满足哪些需求?
基本需求:
- 全局唯一
- 高性能:生成速度快,对本地资源消耗小。
- 高可用:生成分布式 ID 的 服务 要保证高可用性。
- 方便易用:使用方便、快速接入。
此外,一个好的分布式 ID 还应该保证:
- 安全:ID 中不包含敏感信息。
- 有序递增:可以通过 ID 进行排序。
- 有具体的业务含义:可以让定位问题更透明化。
- 独立部署:分布式系统专门有一个发号器服务,用来生成分布式 ID。
1️⃣ UUID
Universally Unique Identifier,通用唯一识别码。由 32 位 16 进制数字构成,可以基于当前时间戳产生(通过当前时间、随机数、本机 MAC 地址计算得出),也可以基于随机数或者“名字”产生。Java 自带的 UUID 可以基于随机数和基于名字两种方式产生:
UUID uuid = UUID.randomUUID();
// 根据指定的字节数组产生
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
UUID 太长而不易于存储;无序,如果作为数据库主键(InnoDB)会严重影响性能。(因为 InnoDB 采用聚集索引)
2️⃣ 用数据库生成
用自增 ID 来表示分布式 ID,另外,将各个分库中同一个业务表的自增 ID 设为 不同的起始值,同时设置 固定的步长,步长等于分库的数量。比如 A 库产生 1, 3, 5...,B 库产生 2, 4, 6, ...
优势是 仅依赖于数据库自身,不需要其他资源,并且 ID 单调递增;缺点是 过于依赖数据库,当数据库异常时整个系统就不可用;另外主从集群下,数据一致性有时难以保证;ID 发号性能被单台 MySQL 的读写性能限制;
3️⃣ 用 Redis 实现
通过 incr
、incrby
这样的自增原子命令来实现,Redis 单线程的特点可以保证生成的 ID 唯一且有序。同样可以采用集群方式满足高并发的业务需求,这时也要设置不同的起始值和固定的步长。
4️⃣ snowflake 算法
用 64 位整数表示一个 ID,将这些整数分成四部分,每个部分代表不同的含义:
时间戳部分可以表示 \(2^{41}\) 个数,每个数代表某个毫秒,一共可以表示的时间大概是 69 年。
优缺点:
- 雪花算法生成 ID 的 性能高,且 ID 单调递增,不依赖第三方系统,以服务的方式部署稳定性高。
- 但是 强依赖机器时钟,如果机器上 时钟回拨,会导致 发号重复。
分布式锁的实现
为什么要有分布式锁?
我们在系统中修改已有数据时,需要先读取,然后进行修改保存,由于修改和保存不是原子操作,在并发场景下,部分对数据的操作可能会丢失。
在单服务器系统我们常用本地锁(ReentrantLock,synchronized)来避免并发带来的问题,然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。
分布式锁需要具备以下特征:
- 互斥性:同一时刻锁只能被一个线程持有。
- 超时释放:可以避免死锁。
- 可重入:进入临界区之后,可以直接使用已经获取到的锁(临界区中调用的方法也需要当前锁),而不需要再次请求锁。
- 高性能、高可用。
Redis 分布式锁
1️⃣ 基于 Redis 单节点的分布式锁
使用 SETNX
命令
- 加锁:
SETNX key value
命令只在key
不存在时才设置这个键值对,key
唯一标识一个锁,可以按照业务 需要锁定的资源 ID 来命名。 - 解锁:
DEL key
,删除键值对,从而其他线程可以获取该锁。 - 锁超时:
EXPIRE key timeout
:设置过期时间,保证即使发生异常导致锁没有被显式释放时,锁也可以在 超时后自动释放,避免死锁。
if (setnx(key, 1) == 1) { // 加锁
expire(key, 30); // 设置过期时间
try {
// 执行业务逻辑
} finally {
del(key); // 解锁
}
}
这种方式存在一些问题:
setnx
和expire
操作是 非原子性 的,如果在获取锁后发生异常,导致expire
命令没有执行,那么就永远不会解锁。解决方法是使用set key value nx ex timeout
命令,它将setnx
和expire
两个命令变成一个原子命令。- 但
set k v nx ex t
仍不能彻底解决分布式锁超时问题:- 锁被提前释放:假设线程 A 在加锁和解锁之间的逻辑执行的时间过长,超出了锁的过期时间,这时锁会被自动释放,线程 B 就可以获取这把锁,但此时线程 A 的逻辑还没有执行完,这就导致临界区代码 不能严格串行执行。
- 锁被误删:在上面情形中,A 执行完后,它会以为自己还持有锁,所以会继续执行
DEL
命令释放锁,如果此时线程 B 在临界区的逻辑还没有执行完,线程 A 实际上释放了线程 B 的锁。
为了避免以上情况,建议 不要在执行时间过长的场景中使用 Redis 分布式锁,同时一个比较安全的做法是 在执行 DEL
解锁之前对锁进行判断,验证当前锁的持有者是否是自己。具体方法就是:在加锁时将 value 设置为一个唯一的随机数,释放锁时先判断随机数是否一致,然后再执行释放操作,这样可以确保不会错误地释放其他线程持有的锁。但判断 value 和删除 key 这两个操作并不是原子性的,所以这个地方需要 使用 Lua 脚本 进行处理,它可以保证连续多个指令的原子性执行。
使用 Redisson 的分布式锁
上面的方法只是解决了一个线程释放其他线程持有的锁的问题,并没有解决 锁超时而自动提前释放 的问题。为此可以利用锁的可重入特性,让获得锁的线程开启一个定时器的守护线程,每 timeout/3
的时间执行一次,去检查该锁是否还存在,存在则对锁的过期时间重新设置为 timeout
,即 利用守护线程对锁进行续期,防止锁由于过期而提前释放。
开源框架 Redisson 实现了这个守护线程,且支持 Redis 集群部署。
2️⃣ 基于 Redis 集群实现的分布式锁 Redlock
上面加锁时只作用在一个 Redis 节点上,在主从集群环境下,主节点获取到锁后,在数据同步期间发生故障转移,导致锁数据丢失,其他线程仍可以获取到锁,不满足锁的互斥性。
Redlock 算法就是为了解决这个问题,主要思想是 多节点部署 Redis,有效防止单点故障:
假设有 N 个节点,这些节点 相互独立(不存在主从关系),客户端需要依次尝试从这些节点处获取锁(执行 setnx key value
,其中所用的 key
都相同,但 value
各不相同),如果成功获取锁(在超时之前得到响应),则记录下获取锁所用时间,当从 N/2+1
个节点都获取到锁,并且总用时没有超出锁的过期时间时,才认为加锁成功。 如果获取锁失败,客户端应该在 所有 Redis 实例上进行解锁(使用 Lua 脚本),因为有的 Redis 实例可能已经执行了 setnx key value
,只是因为网络原因未能向客户端成功发送响应。
分布式事务
CAP:
- Consistency:分布式系统的所有数据备份,在同一时刻是否为同样的值。
- Availability:能够及时处理请求,不会一直等待。
- Partition tolerance:能容忍网络分区,即在网络断开时,被分隔的节点仍能正常提供服务。
BASE 原则:
- Basically Available:系统故障时,允许损失部分可用性。
- Soft state:允许数据存在中间状态,即允许系统在不同节点之间进行数据同步时出现延时。
- Eventually consistent:最终一致性,系统中所有数据副本在经过一段时间的同步后,最终能够达到一致的状态。
BASE 是对 CAP 中一致性和可用性权衡的结果,核心思想是 牺牲强一致性而获得可用性,并允许数据在一段时间内不一致,只要最终达到一致即可。
分布式事务的解决方案
基于 ACID 的事务是 刚性事务,基于 BASE 的事务叫做 柔性事务。
刚性事务实现方案—— XA 协议
XA 协议是一个基于数据库层面的分布式事务协议,存在一个统揽全局的 事务管理器,每个节点有一个 本地资源管理器,后者一般由数据库实现。事务管理器是全局的调度者,负责对各个本地资源管理器发出提交或者回滚的命令。
两阶段提交 2PC
所谓两阶段是指:
- 在第一阶段(准备阶段),事务管理器(协调者)向所有资源管理器(参与者)发送 准备请求,参与者收到后会向协调者返回 prepared 或者 no,前者表示可以执行事务提交,后者表示回滚。
- 在第二阶段(提交阶段),协调者根据参与者的反馈做抉择,如果所有反馈都是 prepared,那么会向所有参与者发送 commit 消息;否则,发送 abort 消息。参与者根据消息类型执行提交或回滚操作。
存在的问题:
- 同步阻塞:当资源管理器都共同依赖于某一资源时,如果某个参与者出现通信超时,其他参与者就会出现阻塞。
- 单点故障:一旦事务管理器故障,整个系统都不可用。
- 数据不一致:在第二阶段,如果事务管理器只给一部分资源管理器发送了 commit 命令,网络就发生了异常,那么就会只有这部分事务参与者提交事务,导致系统数据不一致。
另外,2PC 只能用在两个数据库之间,因为只有数据库实现了 XA 协议。
柔性事务
-
TCC
-
Try 阶段:检查资源和 预留资源,比如在订单操作中,检查剩余库存是否够用,并预留一定量货物供本次事务使用。
-
Confirm 阶段:确认执行业务,在预留资源的基础上进行操作。
-
Cancel 阶段:如果有一个业务 预留资源失败,则取消 Try 阶段预留的所有资源。
-
-
Saga:Saga 由一系列本地事务构成,每一个本地事务执行完成后,会发布一条 消息 或者一个 事件 来触发 Saga 中下一个本地事务的执行。如果一个本地事务因为某些要求无法满足而失败,Saga 会为该事务之前成功提交的所有事务执行 补偿操作。