分布式 ID
- 当单机 MySQL 已经无法支撑系统的数据量时,就需要进行分库分表(推荐 Sharding-JDBC)。在分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键全局唯一了。这个时候就需要生成分布式 ID了。
分布式 ID 应满足的需求
- 一个最基本的分布式 ID 需要满足下面这些要求:
- 全局唯一:ID 的全局唯一性肯定是首先要满足的!
- 高性能:分布式 ID 的生成速度要快,对本地资源消耗要小。
- 高可用:生成分布式 ID 的服务要保证可用性无限接近于 100%。
- 有序递增:如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且很多时候 ,我们还很有可能会直接通过 ID 来进行排序。
- 除了这些之外,一个比较好的分布式 ID 还应保证:
- 安全:ID 中不包含敏感信息。
- 方便易用:拿来即用,使用方便,快速接入!
- 有具体的业务含义:生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。
- 独立部署:也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。不过,这样同样带来了网络调用消耗增加的问题。总的来说,如果需要用到分布式 ID 的场景比较多的话,独立部署的发号器服务还是很有必要的。
分布式 ID 的生成策略
1)UUID
- UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数(8-4-4-4-12)。
- JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。
//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa UUID.randomUUID()
- 优点:生成速度通常比较快、简单易用。
- 缺点:
- 存储消耗空间大(32 个字符串,128 位)。
- 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)。
- 无序(非自增)。
- 没有具体业务含义。
- 需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)。
2)Snowflake(雪花算法)
- Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:
sign
(1bit):符号位(标识正负),始终为 0,代表生成的 ID 为正数。timestamp
(41 bits):一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 41 毫秒(约 69 年)。datacenter id
+worker id
(10 bits):一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整),这样就可以区分不同集群/机房的节点。sequence
(12 bits):一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(212 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。
- 优点:生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID)。
- 缺点:
- 需要解决重复 ID 问题(ID 生成依赖时间,在获取时间的时候,可能会出现时间回拨的问题,也就是服务器上的时间突然倒退到之前的时间,进而导致会产生重复 ID)。
- 依赖机器 ID 对分布式环境不友好(当需要自动启停或增减机器时,固定的机器 ID 可能不够灵活)。
3)Redis自增
- 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
- 符号位:1bit,永远为0。
- 时间戳:31bit,以秒为单位,可以使用69年。
- 序列号:32bit,秒内的计数器,支持每秒产生232个不同ID。
锁
悲观锁
- 悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。
- 也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
- 对于单机多线程来说,在 Java 中,我们通常使用
ReentrantLock
类、synchronized
关键字这类 JDK 自带的本地锁来控制一个 JVM 进程内的多个线程对本地共享资源的访问。
乐观锁
- 乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
- 版本号机制
- 一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。
- 当数据被修改时,version 值会+1。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
- CAS 算法
- CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
- CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
- 当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
- 问题:
- "ABA"问题:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。
- 循环时间长开销大:CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
- 只能保证一个共享变量的原子操作:CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。
分布式锁
- 通过加
synchronized
锁可以解决在单机情况下的一人一单等安全问题,但是在集群模式下就不行了。 - 分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁就诞生了。
分布式锁应满足的需求
- 一个最基本的分布式锁需要满足:
- 互斥:任意一个时刻,锁只能被一个线程持有。
- 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
- 可重入:一个节点获取了锁之后,还可以再次获取锁。
- 除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件:
- 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。
- 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。
分布式锁实现方案
基于 Redis 实现分布式锁
- 在 Redis 中,
SETNX
命令是可以帮助我们实现互斥。SETNX
即SET if Not eXists
(对应 Java 中的setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做。 - 为了避免锁无法被释放,我们可以给这个 key(也就是锁) 设置一个过期时间 。一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX OK
- 释放锁的话,直接通过
DEL
命令删除对应的 key 即可。 - 为了防止误删其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
基于 Redis 的分布式锁存在的问题
- 不可重入
- 同一个线程无法多次获取同一把锁。
- 解决:利用hash结构,记录线程标示和重入次数。
- 不可重试
- 获取锁只尝试一次就返回false,没有重试机制。
- 解决:利用信号量控制锁重试
- 超时释放
- 虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁释放,存在安全隐患。
- 解决:Watch Dog
- 主从一致性
- 如果 Redis 提供了主从集群,主从同步存在延迟,当主宕机时,如果并未同步主中的锁数据,则会出现锁失效。
- 解决:Redisson 的 multiLock,多个独立的 Redis 节点,必须在所有节点都获取重入锁,才算获取锁成功。
Redisson
- Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
- Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
- 默认情况下,每过 10 秒,看门狗就会执行续期操作,将锁的超时时间设置为 30 秒。看门狗续期前也会先判断是否需要执行续期操作,需要才会执行续期,否则取消续期操作。