单机锁
当Java应用程序被部署到一台服务器时,我们只需要使用单机锁就可以保证一段代码在同一时刻只被一个线程访问。我们常用的单机锁如synchronized、可重入锁等都能保证在同一个JVM进程内的多个线程同步访问一段程序来保证数据一致性。synchronized 锁是属于JVM级别的,也就是我们俗称的“单机锁”。
现在基本大部分公司使用的都是集群部署,思考下以上架构在集群部署情况下还能保证库存数据的一致性吗?
答案是不能的,synchronized关键字只能保证单服务器内的JVM进程的不同线程同步,是不能用做分布式环境中来保证线程同步的。为了解决这个问题,分布式锁为此而生。
作用:要在多个节点中提供锁定,在分布式系统并发控制共享资源,确保同一时刻只有一个访问可以调用,避免多个调用者竞争调用和数据不一致问题,保证数据的一致性。
synchronized,意即同时只允许一个线程访问,其它线程必须先等待。当 synchronized 用在方法上时,表示同一时间只允许一个线程执行这个方法。
分布式锁
与分布式锁相对就的是单机锁,我们在写多线程程序时,避免同时操作一个共享变量产生数据问题,通常会使用一把锁来互斥以保证共享变量的正确性,其使用范围是在同一个进程中。如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?现在的业务应用通常是微服务架构,这也意味着一个应用会部署多个进程,多个进程如果需要修改MySQL中的同一行记录,为了避免操作乱序导致脏数据,此时就需要引入分布式锁了。
实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请加锁。而这个外部系统,必须要实现互斥能力,即两个请求同时进来,只会给一个进程加锁成功,另一个失败。这个外部系统可以是数据库,也可以是Redis或Zookeeper,但为了追求性能,我们通常会选择使用Redis或Zookeeper来做。
分布式锁常用有三种实现:
- 数据库
- Redis
- Zookeeper
分布式锁运用场景
- 秒杀系统
- 抢单系统
- 接口幂等性校验
数据库实现分布式锁
悲观锁实现
悲观锁总是假设并发操作可能会导致数据出错,所以每个事务在访问数据之前先加锁,防止其他事务访问其目标数据。
- 获取锁:在MySQL中,可以通过
SELECT ... FOR UPDATE
命令实现悲观锁。在并发量不大的情况下适用较好,但是它的缺点是需要增加时间等待和锁争用所带来的性能损耗,随着并发量增加,会成为瓶颈,高并发场景下不适用。 - 释放锁:在使用完锁之后,使用 COMMIT 或 ROLLBACK 等事务结束语句来释放已经获取的锁。
START TRANSACTION;
SELECT count FROM t WHERE id = 1 FOR UPDATE;
-- 执行该语句后,MySQL数据库会自动对查询结果中对应的记录加上排他锁(X锁),其他事务在访问该记录时,只能读取该记录,而不能修改或删除。
UPDATE t SET count = count + 1 WHERE id = 1;
COMMIT;
Java伪代码:
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Transactional(rollbackFor = Exception.class)
public void updateUser(int userId) throws Exception{
// 1. 开启事务
TransactionStatus status = dataSourceTransactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 2. 查询需要锁定的记录,并对其加锁
User user = userRepository.findByUserId(userId); // SELECT * FROM user WHERE user_id = ? FOR UPDATE
// 3. 对记录进行更新操作
user.setName("new name");
userRepository.save(user); // UPDATE user SET name = ? WHERE user_id = ?
// 4. 提交事务
dataSourceTransactionManager.commit(status);
} catch (Exception e) {
// 5. 事务回滚
dataSourceTransactionManager.rollback(status);
throw new RuntimeException(e.getMessage());
}
}
}
乐观锁实现
实现原理,主要是利用数据库的唯一索引来实现,唯一索引天然具有排他性,这刚好符合我们对锁的要求:同一时刻只能允许一个竞争者获取锁。加锁时我们在数据库中插入一条锁记录,利用业务id进行防重。当第一个竞争者加锁成功后,第二个竞争者再来加锁就会抛出唯一索引冲突,如果抛出这个异常,我们就判定当前竞争者加锁失败。防重业务id需要我们自己来定义,例如我们的锁对象是一个方法,则我们的业务防重id就是这个方法的名字,如果锁定的对象是一个类,则业务防重id就是这个类名。
使用数据库做分布式锁需要注意的是,前提是并发不是特别高,否则不建议使用数据库实现分布式锁。
简单说基于数据库实现分布式锁原理是:通过唯一索引保持排他性,加锁时插入一条记录,解锁是删除这条记录。
基于数据库表的乐观锁
基于数据库表的乐观锁。该实现方式通过在数据库中建立一张分布式锁表,并在获取锁时插入一条记录,释放锁时删除该记录来实现对资源并发访问的控制。如果插入记录成功代表获取锁成功,否则表示锁已经被其他线程占用。该实现方式相比于悲观锁不会阻塞线程,但需要保证并发量不高,且数据库性能稳定。
CREATE TABLE distributed_lock (
id bigint(20) NOT NULL AUTO_INCREMENT,
unique_mutex varchar(255) NOT NULL COMMENT '业务防重id',
holder_id varchar(255) NOT NULL COMMENT '锁持有者id',
create_time datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY mutex_index (unique_mutex)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
id字段是数据库的自增id,unique_mutex字段就是我们的防重id,也就是加锁的对象,此对象唯一。在这张表上我们加了一个唯一索引,保证unique_mutex唯一性。holder_id代表竞争到锁的持有者id。
加锁:如果当前sql执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,当前锁已经被其他竞争者获取。
insert into distributed_lock (unique_mutex, holder_id) values ('unique_mutex', 'holder_id');
解锁:解锁很简单,直接删除此条记录即可。
delete from distributed_lock where unique_mutex='unique_mutex' and holder_id='holder_id';
分析
是否可重入:就以上的方案来说,我们实现的分布式锁是不可重入的,即是是同一个竞争者,在获取锁后未释放锁之前再来加锁,一样会加锁失败,因此是不可重入的。解决此问题也很简单:加锁时判断记录中是否存在unique_mutex的记录,如果存在且holder_id和当前竞争者id相同,则加锁成功。这样就可以解决不可重入问题。
锁释放时机:设想如果一个竞争者获取锁时候,进程挂了,此时distributed_lock表中的这条记录就会一直存在,其他竞争者无法加锁。为了解决这个问题,每次加锁之前我们先判断已经存在的记录的创建时间和当前系统时间之间的差是否已经超过超时时间,如果已经超过则先删除这条记录,再插入新的记录。另外在解锁时,必须是锁的持有者来解锁,其他竞争者无法解锁。这点可以通过holder_id字段来判定。
基于添加版本号的乐观锁
MySQL实现乐观锁的另一种方式是在表中添加版本号(version)字段。在更新数据时,先查询出待更新的记录,并获取该记录的版本号。然后在更新该记录时,将版本号加1。如果在更新该记录期间有其他人对同一记录进行了修改,则该记录的版本号已经被更新,此时更新操作将失败。
案例:假设我们有一张订单表 orders,其中包含 order_id, user_id, amount 和 version 等字段,我们希望对订单金额进行更新,那么可能的 SQL 语句如下:
-- 查询订单并获取版本号
SELECT amount, version FROM orders WHERE order_id = ?;
-- 检查版本号是否匹配
UPDATE orders SET amount = ? , version = version + 1 WHERE order_id = ? AND version = ?;
乐观锁适合于并发度不高,冲突不频繁的场景。因为当并发度较高时,检测版本号容易造成大量的重试和回滚操作,会影响性能。此时,更适合使用悲观锁等机制来实现并发控制。
两者优缺点及使用场景
悲观锁:使用时需要显式地对需要访问的数据进行加锁,保持该锁直到完成所有操作。因此,悲观锁适合于并发环境下高争用、高冲突的场景,并且易于实现。但是,由于需要频繁地加锁和释放锁,会带来处理时间的额外开销,并且在高并发环境下,会增加锁冲突的风险,容易导致死锁问题等。
乐观锁:乐观锁是基于数据版本号或唯一索引的方式实现的,并不会对资源进行加锁和释放锁的操作,相比悲观锁更轻量级。在读取数据时,先获取数据的版本号,然后在提交更新时,根据相同的版本号判断是否有其他客户端已经修改过该数据,如果没有,则正常执行更新操作;如果有,则需要进行冲突检测并重试更新操作,以此保证数据的一致性。这种方式可以减少锁的竞争和死锁问题,同时还能为并发系统提供更好的可扩展性和性能。但是,对于重度写入密集的场景,乐观锁需要频繁地检查版本号或时间戳,也会带来一定的性能损失。
综上所述,对于读多写少的场景,使用乐观锁较为合适;而对于写多读少、并发访问紧张的场景,使用悲观锁更为合适。就具体业务而言,可根据数据特点、并发情况等因素来选择不同的并发控制方案。
Redis实现分布式锁
简单实现
作为分布式锁实现过程中的共享存储系统,Redis可以使用键值对来保存锁变量,在接收和处理不同客户端发送的加锁和释放锁的操作请求。那么,键值对的键和值具体是怎么定的呢?我们要赋予锁变量一个变量名,把这个变量名作为键值对的键,而锁变量的值,则是键值对的值,这样一来,Redis就能保存锁变量了,客户端也就可以通过Redis的命令操作来实现锁操作。
想要实现分布式锁,必须要求Redis有互斥的能力。可以使用SETNX命令,其含义是SET IF NOT EXIST,即如果key不存在,才会设置它的值,否则什么也不做。两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。
以下展示了Redis使用key/value对保存锁变量,以及两个客户端同时请求加锁的操作过程。
加锁操作完成后,加锁成功的客户端,就可以去操作共享资源,例如,修改MySQL的某一行数据,添加数据等等。
操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?直接使用DEL命令删除这个key即可。这个逻辑非常简单,整体的流程写成伪代码就是下面这样。
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key
这套基本实现存在一个很大的问题,当客户端1拿到锁后,如果发生下面的场景,就会造成死锁。
- 程序处理业务逻辑异常,没及时释放锁
- 进程挂了,没机会释放锁
以上情况会导致已经获得锁的客户端一直占用锁,其他客户端永远无法获取到锁。
优化 | 避免死锁
为了解决上面说的死锁问题,最简单的方案是在申请锁时,给锁设置一个过期时间,假设操作共享资源的时间不会超过10s,那么加锁时,给这个key设置10s过期即可。 但以上操作还是有问题,加锁、设置过期时间是2条命令,有可能只执行了第一条,第二条却执行失败,例如:- SETNX执行成功,执行EXPIRE时由于网络问题,执行失败
- SETNX执行成功,Redis异常宕机,EXPIRE没有机会执行
- SETNX执行成功,客户端异常崩溃,EXPIRE没有机会执行
总之这两条命令如果不能保证是原子操作,就有潜在的风险导致过期时间设置失败,依旧有可能发生死锁问题。Redis 2.6.12之后,扩展了SET命令的参数,可以在SET的同时指定EXPIRE时间,这条操作是原子的,例如以下命令是设置锁的过期时间为10秒。
SET lock_key 1 EX 10 NX
:::tips 命令说明:作用是设置一个名为 “lock_key” 的字符串类型的键值对,并在此过程中给该键设置一个10秒过期时间。”NX” 参数表示只有在该键不存在时才能设置成功,如果该键已经存在则设置失败。
这条命令通常可以用于实现分布式锁机制,当多个客户端同时发起请求时,只有一个客户端获得锁,其他客户端需要等待并尝试重新获取锁。在实现分布式锁时,通常会先使用 “SET lock_key requestId EX expireTime NX” 命令来尝试获取该锁,如果返回值为 true,则表示获取到了锁;而如果返回值为 false,则需要等待一段时间后重试。另外,在释放锁的时候通常还需要使用 “del lock_key” 来删除锁对应的键。
:::
至此,解决了死锁问题。继续优化 | 锁被别人给释放了
至此,解决了死锁问题,但还是有其他问题。想像下面这个这样一种场景:
- 客户端1加锁成功,开始操作共享资源
- 客户端1操作共享资源耗时太久,超过了锁的过期时间,锁失效(锁被自动释放)
- 客户端2加锁成功,开始操作共享资源
- 客户端1操作共享资源完成,在finally块中手动释放锁,但此时它释放的是客户端2的锁。
这里存在两个严重的问题:
- 锁过期
- 评估操作共享资源的时间不准确导致的,如果只是一味增大过期时间,只能缓解问题降低出现问题的概率,依旧无法彻底解决问题。原因在于客户端在拿到锁之后,在操作共享资源时,遇到的场景是很复杂的,既然是预估的时间,也只能是大致的计算,不可能覆盖所有导致耗时变长的场景。
- 释放了别人的锁
- 原因在于释放锁的操作是无脑操作,并没有检查这把锁的归属,这样解锁不严谨。如何解决呢?
解决办法
客户端在加锁时,设置一个只有自己知道的唯一标识进去,例如可以是自己的线程ID,如果是redis实现,就是SET key unique_value EX 10 NX。之后在释放锁时,要先判断这把锁是否归自己持有,只有是自己的才能释放它。//释放锁 比较unique_value是否相等,避免误释放
if redis.get("key") == unique_value then
return redis.del("key")
这样就可以解决上述问题。但是 这里释放锁使用的是GET + DEL两条命令,这时又会遇到原子性问题了。试想:
- 客户端1执行GET,判断锁是自己的
- 客户端2执行了SET命令,强制获取到锁(虽然发生概念很低,但要严谨考虑锁的安全性)
- 客户端1执行DEL,却释放了客户端2的锁
所以以上GET + DEL两个命令还是必须原子的执行才行。怎样原子执行两条命令呢?
答案是Lua脚本,可以把以上逻辑写成Lua脚本,让Redis执行。因为Redis处理每个请求是单线程执行的,在执行一个Lua脚本时其它请求必须等待,直到这个Lua脚本处理完成,这样一来GET+DEL之间就不会有其他命令执行了。
下面是使用Lua脚本(unlock.script)实现的释放锁操作的伪代码,其中,KEYS[1]表示lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是我们在执行 Lua脚本时作为参数传入的。
//Lua脚本语言,释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
到这里整个加锁、解锁流程就更严谨了,先小结一下,基于Redis实现的分布式锁,一个严谨的流程如下:
- 加锁时要设置过期时间SET lock_key unique_value EX expire_time NX
- 操作共享资源
- 释放锁:Lua脚本,先GET判断锁是否归属自己,再DEL释放锁
有了这个严谨的锁模型,我们还需要重新思考之前的那个问题,锁的过期时间不好评估怎么办。
再次优化 | 如何确定锁过期时间
前面提到过,过期时间如果评估得不好,这个锁就会有提前过期的风险,一种妥协的解决方案是,尽量冗余过期时间,降低锁提前过期的概率,但这个方案并不能完美解决问题。是否可以设置这样的方案,加锁时,先设置一个预估的过期时间,然后开启一个守护线程,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行续期,重新设置过期时间。
这是一种比较好的方案,已经有一个库把这些工作都封装好了,它就是Redisson。Redisson是一个Java语言实现的Redis SDK客户端,在使用分布式锁时,它就采用了自动续期的方案来避免锁过期,这个守护线程我们一般叫它看门狗线程。这个SDK提供的API非常友好,它可以像操作本地锁一样操作分布式锁。客户端一旦加锁成功,就会启动一个watch dog看门狗线程,它是一个后台线程,会每隔一段时间(这段时间的长度与设置的锁的过期时间有关)检查一下,如果检查时客户端还持有锁key(也就是说还在操作共享资源),那么就会延长锁key的生存时间。
如果客户端在加锁成功后就宕机了呢?宕机了那么看门狗任务就不存在了,也就无法为锁续期了,锁到期自动失效。
到这里,Redis分布式锁的实现步骤就结束了,优化到最后一版,才算是完整可靠的分布式锁。但是,上面所有的操作我们都是基于单机版的Redis所开发的,下面我们展开学习下Redis不同的部署方式有什么问题。
架构问题 | Redis的部署方式对锁的影响
Redis发展到现在,几种常见的部署架构有:
- 单机模式;
- 主从模式;
- 哨兵(sentinel)模式;
- 集群模式;
我们使用Redis时,一般会采用主从集群+哨兵的模式部署,哨兵的作用就是监测redis节点的运行状态。普通的主从模式,当master崩溃时,需要手动切换让slave成为master,使用主从+哨兵结合的好处在于,当master异常宕机时,哨兵可以实现故障自动切换,把slave提升为新的master,继续提供服务,以此保证可用性。那么当主从发生切换时,分布式锁依旧安全吗?
想像这样的场景:
- 客户端A在master上执行SET命令,加锁成功
- 此时,master异常宕机,SET命令还未同步到slave上(主从复制是异步的)
- 哨兵将slave提升为新的master,但这个锁在新的master上丢失了,导致客户端B来加锁成功了,两个客户端共同操作共享资源
所以当引入Redis副本后,分布式锁还是可能受到影响。即使Redis通过sentinel(哨兵)保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况。
集群模式 | Redlock实现高可靠的分布式锁
为了避免Redis实例故障而导致的锁无法工作的问题,Redis的开发者 Antirez提出了分布式锁算法Redlock。Redlock算法的基本思路,是让客户端和多个独立的Redis实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个Redis实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
来具体看下Redlock算法的执行步骤。Redlock算法的实现要求Redis采用集群部署模式,无哨兵节点,需要有N个独立的Redis实例(官方推荐至少5个实例)。接下来,我们可以分成3步来完成加锁操作。
第一步,客户端获取当前时间。
第二步,客户端按顺序依次向N个Redis实例执行加锁操作。
这里的加锁操作和在单实例上执行的加锁操作一样,使用SET命令,带上NX、EX/PX选项,以及带上客户端的唯一标识。当然,如果某个Redis实例发生故障了,为了保证在这种情况下,Redlock算法能够继续运行,我们需要给加锁操作设置一个超时时间。如果客户端在和一个Redis实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个Redis实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
第三步,一旦客户端完成了和所有Redis实例的加锁操作,客户端就要计算整个加锁过程的总耗时。
客户端只有在满足两个条件时,才能认为是加锁成功:
- 条件一:客户端从超过半数(大于等于 N/2+1)的Redis实例上成功获取到了锁;
- 条件二:客户端获取锁的总耗时没有超过锁的有效时间。
为什么大多数实例加锁成功才能算成功呢?多个Redis实例一起来用,其实就组成了一个分布式系统。在分布式系统中总会出现异常节点,所以在谈论分布式系统时,需要考虑异常节点达到多少个,也依旧不影响整个系统的正确运行。这是一个分布式系统的容错问题,这个问题的结论是:如果只存在故障节点,只要大多数节点正常,那么整个系统依旧可以提供正确服务。
在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成共享资源操作,锁就过期了的情况。
当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端就要向所有Redis节点发起释放锁的操作。为什么释放锁,要操作所有的节点呢,不能只操作那些加锁成功的节点吗?因为在某一个Redis节点加锁时,可能因为网络原因导致加锁失败,例如一个客户端在一个Redis实例上加锁成功,但在读取响应结果时由于网络问题导致读取失败,那这把锁其实已经在Redis上加锁成功了。所以释放锁时,不管之前有没有加锁成功,需要释放所有节点上的锁以保证清理节点上的残留的锁。
在Redlock算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua脚本就可以了。这样一来,只要N个Redis实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。所以,在实际的业务应用中,如果你想要提升分布式锁的可靠性,就可以通过Redlock算法来实现。