文章目录
1. 引言
在分布式系统中,分布式锁是一个常见的需求,用于在多个节点之间协调对共享资源的访问。通常情况下,使用 Redis
等内存数据库实现分布式锁是更流行的选择,因为它们通常提供更快的响应时间和更高的吞吐量。但 MySQL 也可以实现分布式锁,并且在某些业务场景下,使用 MySQL 实现分布式锁是合理的选择。
2. mysql为什么可以做分布式锁?
MySQL能够作为分布式锁的实现基础,主要依赖于其ACID特性(特别是原子性和持久性)和唯一键约束。以下从多维度分析
3. mysql分布式锁实现方式有哪些?
在 MySQL 中,常见的实现分布式锁的方法有以下几种:
①: 基于唯一约束
唯一约束锁的适用场景及原因如下:
- 简单互斥控制
-
原因
:唯一约束利用数据库的唯一索引特性,天然保证同一时间只有一个插入操作成功,实现互斥非常简单直接。 案例
:分布式定时任务只需要在任务执行前插入一条代表该任务名的记录,插入成功则获得执行权。
-
- 单资源操作
原因
:这种锁只能锁定单个资源(即插入的特定字符串),无法处理多个资源的关联锁定。-
案例
:订单号生成器只需要确保每个订单号唯一,属于单资源操作。
- 集群任务调度
-
原因
:集群中多个节点同时尝试插入相同的任务标识,只有最先插入成功的节点获得执行权,且插入操作在数据库层面是原子的。 -
案例
:每天凌晨3点的对账任务,集群中只有一个节点执行。
-
大致流程如下:
- 创建一个锁表,例如
uni_key_lock
,包含字段 lock_name(唯一索引)和 expire_time 等。 - 获取锁时,尝试插入一条记录(锁名作为唯一键)。如果插入成功,表示获取锁成功。
- 释放锁时,删除该记录。
- 为了避免死锁,可以设置一个过期时间,并使用定时任务清理过期的锁。
实现过程如下:
-
先创建一个表
uni_key_lock
,用来存储锁的创建和销毁,lock_name
业务锁的字段必须为唯一索引,建表如下:CREATE TABLE `uni_key_lock` ( `id` bigint NOT NULL COMMENT '主键', `lock_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '手册key ', PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `lockName_ux` (`lock_name`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-
开发业务类
UniKeyLockHolder
,基于唯一索引表数据的插入实现加锁和解锁- 加锁:向
uni_key_lock
插入一条数据,因为唯一索引的存在,只有一个事务可以插入成功,插入成功即为获取锁! - 解锁:删除刚刚插入的数据
@Component public class UniKeyLockHolder { @Autowired private UniKeyLockMapper uniKeyLockMapper; /** * 加锁:业务字符串lockName作为锁, 是唯一索引,插入成功则表示加锁成功,插入失败则表示加锁失败 */ public boolean lock(String lockName) { try { UniKeyLock uniKeyLock = new UniKeyLock(); uniKeyLock.setLockName(lockName); int insert = uniKeyLockMapper.insert(uniKeyLock); return true; } catch (Exception e) { return false; } } /** * 解锁:直接删除lockName,删除锁 */ public void unLock(String lockName) { try { uniKeyLockMapper.delete(Wrappers.<UniKeyLock>lambdaQuery().eq(UniKeyLock::getLockName, lockName)); } catch (Exception e) { throw e; } } }
- 加锁:向
-
测试方法:使用并行流开启
20
个事务,模拟并发操作。parallel()
默认线程数为cpu核心数-1
,我这里是16核 - 1 = 15个线程,如果想要自定义线程数。- 请自定义
forkjoin
线程池。ForkJoinPool customPool = new ForkJoinPool(10);
- 使用线程池执行测试方法:
customPool.submit(() -> testMysqlLock());
,然后使用customPool.shutdown();
等待线程结束!
@Override public void testMysqlLock() { // 唯一索引锁 IntStream.range(0, 20).parallel().forEach(i -> { try { // 获取锁 if (uniKeyLockHolder.lock("businessLock")) { // 获取锁成功,执行修改逻辑 LambdaUpdateWrapper<WmsDeviceLifecycle> wrapper = new LambdaUpdateWrapper<WmsDeviceLifecycle>().eq(WmsDeviceLifecycle::getId, 1L).eq(WmsDeviceLifecycle::getModelId, 0).set(WmsDeviceLifecycle::getProductId, 1); int update = wmsDeviceLifecycleMapper.update(new WmsDeviceLifecycle(), wrapper); if (update == 1) { log.info("第 {} 次执行业务逻辑+++++++", i); } } else { // 获取锁失败,打印日志 log.info("第 {} 次没有获取到锁", i); } } finally { //释放锁 uniKeyLockHolder.unLock("businessLock"); } }); }
-
测试结果:实现了分布式锁,线程占锁期间,其他线程不执行业务,仅打印没有获取到锁
②: 基于悲观锁(select … for update)
悲观锁(select … for update)的适用场景及原因如下:
- 强一致要求
-
原因
:SELECT … FOR UPDATE 会在事务中锁定数据,直到事务结束才释放,期间其他事务无法修改这些数据,保证读取的数据始终是最新且一致的。 案例
:转账操作中需要同时锁定转出和转入账户,避免出现中间状态被其他事务读取或修改。
-
- 复杂事务链
原因
:悲观锁支持在同一个事务中对多个资源顺序加锁,确保整个操作链的原子性。-
案例
:电商下单涉及库存扣减、优惠券使用、订单创建等多个操作,需要在一个事务中锁定所有相关资源。
- 可接受的中低并发
-
原因
:由于悲观锁会阻塞其他等待锁的事务,当并发量高时,大量线程阻塞会导致系统吞吐量急剧下降。 -
案例
:内部管理系统,用户量不大,但对数据一致性要求高,适合悲观锁。
-
大致流程如下:
- 使用事务,通过 SELECT … FOR UPDATE 对锁表中的一行进行锁定。
- 成功获取锁后,执行业务逻辑,然后提交事务以释放锁。
- 其他事务在尝试获取同一把锁时会被阻塞,直到第一个事务提交。
实现过程如下:
-
悲观锁的实现可以借助上面带有唯一所以的表
uni_key_lock
,在此表基础上使用select ... for update
来实现,注意这个sql尽量走索引,否则会锁表,我们案例中使用的lock_name
正好是唯一索引,可以用于测试,因为要锁一行数据,所以手动先给这个表里加一行数据
-
开发业务类
ForUpdateLockHolder
,基于悲观锁 实现加锁和解锁- 加锁:需要手动开启事务,当并发请求时,所有并发业务都会先执行
SELECT * FROM uni_key_lock WHERE lock_name = ? FOR UPDATE
,但由于select ... for update
悲观锁的存在,只会有一个业务线程获取锁成功。其他业务线程执行时,都会被阻塞,直到第一个线程提交他的事务。第一个线程在获取到锁之后,再执行自己的业务逻辑,发生异常就回滚,执行完毕就解锁。- 注意:我这里加完锁线程
sleep
了1
秒,用于模拟业务操作耗时。可以展示扩展问题的现象
- 注意:我这里加完锁线程
- 解锁:手动提交事务
commit
@Component @Slf4j public class ForUpdateLockHolder { @Autowired private UniKeyLockMapper uniKeyLockMapper; @Autowired private JdbcTemplate jdbcTemplate; @Autowired private PlatformTransactionManager platformTransactionManager; // =================================== 使用 Mysql 的悲观锁 进行加锁和解锁: 需要手动管理事务的开启和关闭================ // 这个方法未加锁成功的事务,在mysql默认的锁超时时间内,会一直等待前边的锁释放,然后才轮到自己执行。 // 也就是业务 都会执行,只是慢一点而已 public void lockForUpdateOrderly(String lockName, Runnable runnable) { DefaultTransactionDefinition transactionDefinition = new DefaultTransactionDefinition(); // 设置事务隔离级别为读已提交 transactionDefinition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); // 设置事务传播行为为 REQUIRED transactionDefinition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); // 开启事务 TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition); try { // 每一个业务进来后,先对某一行加加悲观锁: 这里先借用一下 uni_key_lock 这张表,查询lock_name需要走索引,否则会锁表,影响效率 // 如果这里查不到就会进入异常,回滚事务 Map<String, Object> entity = jdbcTemplate.queryForMap("SELECT * FROM uni_key_lock WHERE lock_name = ? FOR UPDATE ", lockName); Thread.sleep(1000); // 业务逻辑执行 runnable.run(); } catch (Exception e) { // 异常回滚 platformTransactionManager.rollback(transaction); } finally { // 提交事务,释放锁 platformTransactionManager.commit(transaction); } } }
- 加锁:需要手动开启事务,当并发请求时,所有并发业务都会先执行
-
测试方法:使用并行流开启
20
个更新事务,模拟15个线程并发操作。@Override public void testMysqlLock() { // 悲观锁 IntStream.range(0, 20).parallel().forEach(i -> { // 其他事务阻塞式获取,获取不到锁就会等待 forUpdateLockHolder.lockForUpdateOrderly("businessLock", () -> { LambdaUpdateWrapper<WmsDeviceLifecycle> wrapper = new LambdaUpdateWrapper<WmsDeviceLifecycle>().eq(WmsDeviceLifecycle::getId, 1L).eq(WmsDeviceLifecycle::getModelId, 0).set(WmsDeviceLifecycle::getProductId, 1); int update = wmsDeviceLifecycleMapper.update(new WmsDeviceLifecycle(), wrapper); log.info("第 {} 次执行业务逻辑+++++++ 更新结果{}", i, update); }); }); }
-
测试结果:实现了分布式锁,同时只有一个事务执行了业务操作,其他线程在等待解锁之后,也会依次执行自己的业务
从结果来看,这种方式的悲观锁,同时确实只有一个事务执行了业务操作,其他线程在等待解锁之后,也会依次执行自己的业务,是满足分布式锁的要求的。
接下来扩展一下思路,假如要求其他线程获取不到锁后,不执行自己的业务呢?就类似第①种方式(唯一索引的分布式)的执行结果,那这种要怎么实现呢?
-
扩展需求:要求其他未抢到锁的事务,放弃执行自己的业务,仅打印日志:没有获取到锁。重写业务类
ForUpdateLockHolder
- 修改加锁sql,增加
NOWAIT
标识:SELECT * FROM uni_key_lock WHERE lock_name = ? FOR UPDATE NOWAIT
,标识其他线程如果没有获取到锁,不再等待,抛出异常UncategorizedSQLException
- 捕获
UncategorizedSQLException
异常,并打印日志:没有获取到锁
@Component @Slf4j public class ForUpdateLockHolder { @Autowired private UniKeyLockMapper uniKeyLockMapper; @Autowired private JdbcTemplate jdbcTemplate; @Autowired private PlatformTransactionManager platformTransactionManager; // =================================== 使用 Mysql 的悲观锁 进行加锁和解锁: 需要手动管理事务的开启和关闭================ // 这个方法未加锁成功的事务,直接抛出异常,不在执行 // 也就是业务枪锁,只有一个成功的话,就只有一个事务会执行,其他全部死亡 public void lockForUpdateReturn(String lockName, Runnable runnable) { DefaultTransactionDefinition transactionDefinition = new DefaultTransactionDefinition(); // 设置事务隔离级别为读已提交 transactionDefinition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); // 设置事务传播行为为 REQUIRED transactionDefinition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); // 开启事务 TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition); try { // 每一个业务进来后,先对某一行加加悲观锁: 这里先借用一下 uni_key_lock 这张表,查询lock_name需要走索引,否则会锁表,影响效率 // 如果这里查不到就会进入异常,回滚事务 Map<String, Object> entity = jdbcTemplate.queryForMap("SELECT * FROM uni_key_lock WHERE lock_name = ? FOR UPDATE NOWAIT", lockName); Thread.sleep(1000); // 业务逻辑执行 runnable.run(); } catch (UncategorizedSQLException e) { // 锁获取失败时的处理 log.info("当前事务没有获取到forUpdate悲观锁:{}, skipping execution", lockName); // 这里可以选择什么都不做,表示跳过执行 } catch (Exception e) { // 异常回滚 platformTransactionManager.rollback(transaction); } finally { // 释放锁 platformTransactionManager.commit(transaction); } } }
- 修改加锁sql,增加
-
扩展需求测试方法
@Override public void testMysqlLock() { // 悲观锁 IntStream.range(0, 20).parallel().forEach(i -> { forUpdateLockHolder.lockForUpdateReturn("businessLock", () -> { LambdaUpdateWrapper<WmsDeviceLifecycle> wrapper = new LambdaUpdateWrapper<WmsDeviceLifecycle>().eq(WmsDeviceLifecycle::getId, 1L).eq(WmsDeviceLifecycle::getModelId, 0).set(WmsDeviceLifecycle::getProductId, 1); int update = wmsDeviceLifecycleMapper.update(new WmsDeviceLifecycle(), wrapper); log.info("第 {} 次执行业务逻辑+++++++ 更新结果{}", i, update); }); }); }
-
扩展需求测试结果:实现了分布式锁,同时只有一个事务执行了业务操作,其他线程未获取到锁,不再执行自己的业务,仅打印日志
③: 基于乐观锁
乐观锁的适用场景及原因如下:
- 超高并发需求
-
原因
:乐观锁没有加锁操作,通过版本号检测冲突,在冲突率较低的情况下,性能远高于悲观锁。 案例
:短视频点赞,点赞操作频繁且冲突率低(一个视频同时被多人点赞),即使冲突也可以通过重试解决。
-
- 最终一致可接受
原因
:乐观锁可能存在短暂的数据不一致(冲突检测到重试成功前),但最终会达到一致。-
案例
:购物车合并,即使出现冲突,通过重试后系统状态仍然一致,不需要严格的实时一致。
- 可重试业务
-
原因
:乐观锁在更新失败后(版本不匹配)需要重试,因此业务逻辑必须可以安全地多次执行(幂等)。 -
案例
:用户积分变动,积分更新操作是幂等的,重试不影响最终结果。
-
大致流程如下:
- 在表中增加版本号字段,更新时检查版本号。但这种方法不适用于锁,因为锁需要独占,而乐观锁通常用于避免冲突的更新,不适合互斥锁的场景。
实现过程如下:
-
创建一张用于乐观锁 加锁和解锁的表
optimistic_lock
CREATE TABLE `optimistic_lock` ( `id` bigint NOT NULL AUTO_INCREMENT, `lock_name` varchar(50) DEFAULT NULL, `expire_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '锁过期时间', `lock_status` int NOT NULL DEFAULT '0' COMMENT '0--正常 1--被锁', PRIMARY KEY (`id`), UNIQUE KEY `uidx_lock_name` (`lock_name`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3 COMMENT='乐观锁实现分布式锁';
然后手动插入一条数据用于乐观锁 加锁
-
开发业务类
ForUpdateLockHolder
,实现基于悲观锁 实现加锁和解锁- 加锁:并发事务进来时都会执行条件更新sql:
update optimistic_lock set lock_status=1, expire_at = NOW() + INTERVAL 1 MINUTE where lock_name ='%s' and lock_status = 0 ;
,如果当前lock_status
为1,命中不到where条件,表示此刻的锁已被其他事务已经占有,本次更新操作会返回0 ,不再执行业务。 - 解锁:重置
lock_name
的锁状态
@Component @Slf4j public class OptimisticLockHolder { @Autowired private OptimisticLockMapper optimisticLockMapper; // =================================== 使用 Mysql 乐观锁 加锁和解锁 ================ /** * 加锁:根据条件更新表数据,如果更新成功,返回true,否则返回false */ public boolean lock(String lockName) { try { // 查询并更新 0-未被锁定的锁,并且设置加锁状态为1 过期时间为1分钟 LambdaUpdateWrapper<OptimisticLock> wrapper = new LambdaUpdateWrapper<OptimisticLock>() .eq(OptimisticLock::getLockName, lockName) .eq(OptimisticLock::getLockStatus, 0) .set(OptimisticLock::getLockStatus, 1) .set(OptimisticLock::getExpireAt, LocalDateTime.now().plusMinutes(1)); int update = optimisticLockMapper.update(new OptimisticLock(), wrapper); // 乐观锁用条件更新实现,如果满足条件,执行更新后,返回值是1 ,否则返回值是0 return update == 1; } catch (Exception e) { return false; } } /** * 解锁:恢复锁lockName的原始锁状态为0,设置过期时间为当前时间 */ public void unLock(String lockName) { try { // 解锁时,直接设置加锁状态为0,过期时间为当前时间 LambdaUpdateWrapper<OptimisticLock> wrapper = new LambdaUpdateWrapper<OptimisticLock>() .eq(OptimisticLock::getLockName, lockName) .set(OptimisticLock::getLockStatus, 0) .set(OptimisticLock::getExpireAt, LocalDateTime.now()); optimisticLockMapper.update(new OptimisticLock(), wrapper); } catch (Exception e) { throw e; } } }
- 加锁:并发事务进来时都会执行条件更新sql:
-
测试方法:20个事务,用15个线程模拟并发
@Override public void testMysqlLock() { // 乐观锁 IntStream.range(0, 20).parallel().forEach(i -> { try { if (optimisticLockHolder.lock("businessLock")) { //加锁成功,执行业务 LambdaUpdateWrapper<WmsDeviceLifecycle> wrapper = new LambdaUpdateWrapper<WmsDeviceLifecycle>().eq(WmsDeviceLifecycle::getId, 1L).eq(WmsDeviceLifecycle::getModelId, 0).set(WmsDeviceLifecycle::getProductId, 1); wmsDeviceLifecycleMapper.update(new WmsDeviceLifecycle(), wrapper); log.info("第 {} 次执行业务逻辑+++++++", i); } else { //加锁失败,仅打印日志 log.info("第 {} 次没有获取到锁", i); } } finally { //释放锁 optimisticLockHolder.unLock("businessLock"); } }); }
-
测试结果:实现了分布式锁,线程占锁期间,其他线程不执行业务,仅打印没有获取到锁
4. 哪些场景下适合用mysql做分布式锁?
虽然 Redis 分布式锁(如 Redlock)在性能上更有优势,但以下场景可能更适合使用 MySQL 实现分布式锁:
对锁的可靠性要求非常高,且对性能要求不是极端敏感
- MySQL 的事务特性和持久性(ACID)确保了锁操作的可靠性和一致性。在需要严格保证锁的正确性且可以接受毫秒级延迟的场景下,MySQL 是更好的选择。
- 例如,金融系统中的某些关键操作,需要确保在分布式环境下操作的绝对正确性,而 Redis 锁可能因为节点故障或网络分区导致锁的不可靠(即使 Redlock 算法也存在争议)。
业务本身需要基于数据库事务进行
- 如果业务操作在一个数据库事务中,需要将锁的获取和业务操作放在同一个事务中,以保证原子性。
- 例如,在一个事务中需要锁定某个资源,然后更新多个表,这时使用 MySQL 的行锁或排他锁可以天然地保证锁和业务操作的原子性
在金融支付业务中,数据的一致性和可靠性是至关重要的。MySQL锁(基于数据库的分布式锁)提供了一些关键特性,这些特性在金融场景中非常有用:
强一致性
:MySQL作为关系型数据库,使用ACID事务,可以保证锁操作的原子性和一致性。在支付这种对数据一致性要求极高的场景中,能够确保锁的状态和业务数据的状态在同一个事务中提交,避免锁与业务数据不一致的情况。持久性
:MySQL的锁可以持久化到磁盘,即使数据库重启,锁状态也可以通过表中的记录进行恢复(或者通过redoLog
恢复)。而Redis锁在重启后可能会丢失(除非使用AOF持久化并且每次操作都fsync,但这样性能会下降)。可审计性
:锁的操作会记录在数据库的事务日志中或者自定义的锁记录表中,便于事后审计。在金融行业中,监管要求严格,需要能够追踪每一步操作。
5. mysql分布式锁的局限性
MySQL锁在性能上通常不如Redis锁,因此在低延迟、高并发的支付场景中可能会成为瓶颈。所以,在高并发支付场景下,通常会进行权衡,对于核心资金操作(如账户扣款、生成交易记录)使用MySQL锁,而并发控制(如库存扣减)可能会用Redis锁。
6. 交易系统使用mysql做读写互斥锁的案例
6.1 业务背景
举一个P2P的场景,这里先介绍一下 p2p 的业务
比如小明需要 10 万买车,但是手头上没钱,此时可以在 p2p 平台上申请一个 10 万的借款,然后 p2p平台会发布一个借款项目,开始募集资金。
其他网民可以去投资这个项目,每个月借款人小明会进行还款,投资人会拿到收益。
当投资人每次投资的时候,会产生一份债权,可以把债权理解为借款人欠你钱的一个凭证。
如果投资人急着用钱,但是此时投资还未到期,此时投资人可以发起债权转让,将投资人的债权卖给给其他人,这样投资人就可以及时拿到本金了。
这里面涉及到 2 个关键的业务:
借款人(小明)执行还款
:借款人执行还款的时候,会将资金发到投资人账户中,涉及到投资人账户资金的变动,还有债权信息的变化等,整个还款过程涉及到调用银行系统,过程比较复杂,耗时相对比较长投资人发起债权转让
:投资人发起债权转让,也涉及到债权的编号和投资人账户的资金的变动
由于这 2 个业务都会操作债权记录和投资人账户资金,为了保证资金的正确性,降低系统的复杂度,可以选择让这 2 种业务互斥
- 某笔借款执行还款的过程中,那么这笔借款关联的所有债权记录不允许发起转让
- 如果某笔借款记录当前没有在还款处理中,那么这笔借款记录关联的债权都可以同时发起债权转让
下文的代码也都是基于上述互斥业务进行的
6.1 业务抽象
上述业务所在应用如果是集群部署,那么java 中的 ReadWriteLock 读写锁就排不上用场了,把如上业务抽象成
X
:互斥的资源idW
:借款人还款操作R
:投资人债权转让操作
对 X 资源,可以执行 2 种操作:W 操作、R 操作,2 种操作需要满足下面条件
-
执行操作的机器分布式在不同的节点中,也就是分布式的
-
W(借款人还款) 操作是独享的,也就是说同一时刻只允许有一个操作者对 X 执行 W 操作
-
R (投资人债权转让)操作是共享的,也就是说同时可以有多个执行者对 X 资源执行 R 操作
-
W 操作和 R 操作是互斥的,什么意思呢?也就是说 W 操作和 R 操作不能同时存在
6.3 解决过程
mysql 中同时对一笔记录发起 update 操作的时候,mysql 会确保整个操作会排队执行,内部是互斥锁实现的,从而可以确保在并发修改数据的时候,数据的正确性,执行 update 的时候,会返回被更新的行数,这里我们就利用 mysql 这个特性来实现读写锁的功能
①:创建读写锁表 t_read_write_lock
create table t_read_write_lock(
resource_id varchar(50) primary key not null comment '互斥资源id',
w_count int not null default 0 comment '目前执行中的W(借款人还款)操作数量' ,
r_count int not null default 0 comment '目前执行中的R(投资人债权转让)操作数量',
version bigint not null default 0 comment '版本号,每次执行update的时候+1'
);
这里主要关注 3 个字段:
-
resource_id
:互斥资源 id,比如上面的借款记录 id -
w_count
:当前执行 W(借款人还款)操作的数量 -
r_count
:当前执行 R (投资人债权转让)操作的数量
下面来看 W 操作和 R 操作的实现。
②:借款人还款操作:W
1、通过resource_id去t_read_write_lock查询,如果不存在,则插入一条记录,这里由于resource_id是主键,所以对于同一个resource_id只会有一个插入成功,这里用 $lock_record表示t_read_write_lock记录
2、判断lock_record.w_count==0 && lock_record.r_count==0,如果为true继续向下,否则返回false,业务终止
3、获取锁,过程如下
{
3.1、开启事务
3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)
3.3、提交事务;
}
4、如果3.2的count==1,继续向下执行,否则终止业务
5、执行业务操作
6、释放锁,过程如下
{
6.1、开启事务
6.2、update t_read_write_lock set w_count=0 where w_count = 1
6.3、提交事务
}
整个过程有个问题,不知道大家发现没有,如果执行到 5 之后,系统挂了,会出现什么情况?
业务执行完毕了,但是 w 锁却没有释放,这种后果就是本次操作一直持有锁,后续 W 和 R 操作就没法执行了。
我们来看看,业务执行过程中,系统挂掉,锁未释放如何处理和优化
?方案如下:
- 创建锁日志表:业务执行需要获取锁,除了需要修改
t_read_write_lock
表中的加锁数据外,还要额外增加一张表t_lock_log
去记录当前互斥资源id获取锁 - 业务执行结果 - 释放锁
的过程(并记录上时间),这样即使系统挂了导致了锁占用,我们也知道业务是在那一步挂掉了。 - 同步记录锁日志表数据:然后需要修改 借款人还款操作过程:W 的业务流程,
获取锁 - 业务执行结果 - 释放锁
的每一步过程都要在同一个事务中,同步更新t_lock_log
中的进度,保证和t_read_write_lock
的修改操作同时成功,或者失败 - 旁路定时任务清理锁占用:最后再增加一个定时任务去根据加锁时间是否超出阈值,来扫描
t_lock_log
中已过期未释放的锁,如果锁超过阈值未释放,则修改t_read_write_lock
和t_lock_log
中的状态。这样即使在任务在执行过程中崩溃了,旁路的定时任务也会定时扫描清理过期的锁,这样就避免了系统挂掉导致的锁一直占有!
1、 创建锁日志表:需要添加一下上锁日志表,每次上锁成功,则记录一条日志,表结构如下
create table t_lock_log(
id bigint primary key auto_increment comment '主键,自动增长'
resource_id varchar(50) primary key not null comment '互斥资源id',
lock_type smallint default 0 comment '锁类型,0:W锁,1:R锁',
status smallint default 0 comment '状态,0:获取锁成功,1:业务执行完毕,2:锁被释放',
create_time bigint default 0 comment '记录创建时间',
version bigint not null default 0 comment '版本号,每次执行update的时候+1'
);
2、同步记录锁日志表数据:接下看 借款人还款操作过程:W 改进如下
1、通过resource_id去t_read_write_lock查询,如果不存在,则插入一条记录,这里由于resource_id是主键,所以对于同一个resource_id只会有一个插入成功,这里用 $lock_record表示t_read_write_lock记录
2、判断lock_record.w_count==0 && lock_record.r_count==0,如果为true继续向下,否则返回false,业务终止
3、获取锁,过程如下
{
3.1、开启事务
3.2、int count = (update t_read_write_lock set w_count=1 where r_count = 0)
3.3、如果count==1,则插入一条上锁日志,锁类型是0,状态是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},0,0,'当前时间');
3.4、提交事务;
}
4、如果3.2的count==1,继续向下执行,否则终止业务
5、执行业务操作,业务操作过程如下
{
5.1、业务库开启事务
5.2、执行业务
5.3、更新锁日志记录的状态为1,条件中必须带上status=0:int updateLogCount = (update t_lock_log set status=1 where id=#{日志记录id} and status = 0)
5.4、if(updateLogCount==1){
5.5、提交事务
}else{
5.6、回滚事务【走到这里说明更新锁日志记录失败了,说明t_lock_log的status被其他地方改掉了,被防止死锁的job修改了】
}
}
6、释放锁,过程如下
{
6.1、开启事务
6.2、释放锁:update t_read_write_lock set w_count=0 where w_count = 1 and resource_id = #{resource_id}
6.3、更新锁日志状态为2:update t_lock_log set status=2 where id = #{日志记录id}
6.4、提交事务
}
3、旁路定时任务清理锁占用:此时我们需要一个 job,通过这个 job 来释放长时间还未释放的锁,比如过了 10 分钟,锁还未被释放的,job 的逻辑如下
1、获取10分钟之前锁未释放的锁日志列表:select * from t_lock_log where status in (0,1) and create_time+10分钟<=当前时
间的;
2、轮询获取的日志列表,释放锁,操作如下
{
2.1、开启事务
2.2、if(t_lock_log.lock_type==0){
//lock_type为0表示是W锁,下面准备释放W锁
//先将日志状态更新为2,注意条件中带上version作为条件,这里使用到了乐观锁,可以确保并发修改时只有一个count的值为1
int count = (update t_lock_log set status=2 where id = #{日志记录id} and version = #{日志记录.version})
if(count==1){
//将w_count置为0
update t_read_write_lock set w_count=0 where w_count = 1 and resource_id = #{resource_id}
}
}else{
//准备释放R锁
//先将日志状态置为2
int count = (update t_lock_log set status=2 where id = #{日志记录id} and version = #{日志记录.version})
if(count==1){
//将r_count置为r_count-1,注意条件中带上r_count - 1>=0
update t_read_write_lock set r_count=r_count-1 where r_count - 1>=0 and resource_id = #{resource_id}
}
}
2.3、提交事务
}
③:投资人债权转让操作:R
1、通过resource_id去t_read_write_lock查询,如果不存在,则插入一条记录,这里由于resource_id是主键,所以对于同一个resource_id只会有一个插入成功,这里用 $lock_record表示t_read_write_lock记录
2、判断lock_record.w_count ==0,如果为true继续向下,否则返回false,业务终止
3、获取锁,过程如下
{
3.1、开启事务
3.2、int count = (update t_read_write_lock set r_count=r_count+1 where w_count = 0)
3.3、如果count==1,则插入一条上锁日志,锁类型是1【表示R锁】,状态是0:insert t_lock_log (resource_id,lock_type,status,create_time) values (#{resource_id},1,0,'当前时间');
3.4、提交事务;
}
4、如果3.2的count==1,继续向下执行,否则终止业务
5、执行业务操作,业务操作过程如下
{
5.1、业务库开启事务
5.2、执行业务
5.3、更新锁日志记录的状态为1,条件中必须带上status=0:int updateLogCount = (update t_lock_log set status=1 where id=#{日志记录id} and status = 0)
5.4、if(updateLogCount==1){
5.5、提交事务
}else{
5.6、回滚事务【走到这里说明更新锁日志记录失败了,说明t_lock_log的status被其他地方改掉了,被防止死锁的job修改了】
}
}
6、释放锁,过程如下
{
6.1、开启事务
6.2、释放锁:update t_read_write_lock set r_count=r_count-1 where r_count - 1 >= 0 and resource_id = #{resource_id}
6.3、更新锁日志状态为2:update t_lock_log set status=2 where id = #{日志记录id}
6.4、提交事务
}
④:总结
使用 mysql 来实现读写锁,如何防止死锁,重点就是 2 张表,锁表和日志表,2 个表配合一个 job,就把问题解决了。
大家可以将上面代码流程转换为程序,结合 spring 的 aop 可以实现一个通用的 db 读写锁