Mysql可以做分布式锁吗?Mysql分布式锁的应用

1. 引言

       在分布式系统中,分布式锁是一个常见的需求,用于在多个节点之间协调对共享资源的访问。通常情况下,使用 Redis 等内存数据库实现分布式锁是更流行的选择,因为它们通常提供更快的响应时间和更高的吞吐量。但 MySQL 也可以实现分布式锁,并且在某些业务场景下,使用 MySQL 实现分布式锁是合理的选择。

       

2. mysql为什么可以做分布式锁?

MySQL能够作为分布式锁的实现基础,主要依赖于其ACID特性(特别是原子性和持久性)和唯一键约束。以下从多维度分析

       

3. mysql分布式锁实现方式有哪些?

       
在 MySQL 中,常见的实现分布式锁的方法有以下几种:
 

①: 基于唯一约束

唯一约束锁的适用场景及原因如下:

  • 简单互斥控制​​
    • ​​原因​​:唯一约束利用数据库的唯一索引特性,天然保证同一时间只有一个插入操作成功,实现互斥非常简单直接。
    • 案例​​:分布式定时任务只需要在任务执行前插入一条代表该任务名的记录,插入成功则获得执行权。
  • ​​单资源操作​​
    • 原因​​:这种锁只能锁定单个资源(即插入的特定字符串),无法处理多个资源的关联锁定。
    • ​​案例​​:订单号生成器只需要确保每个订单号唯一,属于单资源操作。
  • ​​集群任务调度​​
    • ​​原因​​:集群中多个节点同时尝试插入相同的任务标识,只有最先插入成功的节点获得执行权,且插入操作在数据库层面是原子的。
    • ​​案例​​:每天凌晨3点的对账任务,集群中只有一个节点执行。

大致流程如下:

  • 创建一个锁表,例如 uni_key_lock,包含字段 lock_name(唯一索引)和 expire_time 等。
  • 获取锁时,尝试插入一条记录(锁名作为唯一键)。如果插入成功,表示获取锁成功。
  • 释放锁时,删除该记录。
  • 为了避免死锁,可以设置一个过期时间,并使用定时任务清理过期的锁。

实现过程如下:

  1. 先创建一个表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;
    
  2. 开发业务类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;
            }
        }
    }
    
    
  3. 测试方法:使用并行流开启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");
                }
            });
        }
    
  4. 测试结果:实现了分布式锁,线程占锁期间,其他线程不执行业务,仅打印没有获取到锁

    在这里插入图片描述

 

②: 基于悲观锁(select … for update)

悲观锁(select … for update)的适用场景及原因如下:

  • 强一致要求​​
    • ​​原因​​:SELECT … FOR UPDATE 会在事务中锁定数据,直到事务结束才释放,期间其他事务无法修改这些数据,保证读取的数据始终是最新且一致的。
    • 案例​​:转账操作中需要同时锁定转出和转入账户,避免出现中间状态被其他事务读取或修改。
  • ​​复杂事务链​​
    • 原因​​:悲观锁支持在同一个事务中对多个资源顺序加锁,确保整个操作链的原子性。
    • ​​案例​​:电商下单涉及库存扣减、优惠券使用、订单创建等多个操作,需要在一个事务中锁定所有相关资源。
  • ​​可接受的中低并发​​
    • ​​原因​​:由于悲观锁会阻塞其他等待锁的事务,当并发量高时,大量线程阻塞会导致系统吞吐量急剧下降。
    • ​​案例​​:内部管理系统,用户量不大,但对数据一致性要求高,适合悲观锁。

大致流程如下:

  • 使用事务,通过 SELECT … FOR UPDATE 对锁表中的一行进行锁定。
  • 成功获取锁后,执行业务逻辑,然后提交事务以释放锁。
  • 其他事务在尝试获取同一把锁时会被阻塞,直到第一个事务提交。

实现过程如下:

  1. 悲观锁的实现可以借助上面带有唯一所以的表uni_key_lock,在此表基础上使用 select ... for update 来实现,注意这个sql尽量走索引,否则会锁表,我们案例中使用的 lock_name 正好是唯一索引,可以用于测试,因为要锁一行数据,所以手动先给这个表里加一行数据
    在这里插入图片描述

  2. 开发业务类ForUpdateLockHolder,基于悲观锁 实现加锁和解锁

    • 加锁:需要手动开启事务,当并发请求时,所有并发业务都会先执行SELECT * FROM uni_key_lock WHERE lock_name = ? FOR UPDATE,但由于select ... for update悲观锁的存在,只会有一个业务线程获取锁成功。其他业务线程执行时,都会被阻塞,直到第一个线程提交他的事务。第一个线程在获取到锁之后,再执行自己的业务逻辑,发生异常就回滚,执行完毕就解锁。
      • 注意:我这里加完锁线程sleep1秒,用于模拟业务操作耗时。可以展示扩展问题的现象
    • 解锁:手动提交事务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);
            }
        }
    }
    
  3. 测试方法:使用并行流开启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);
                });
            });
        }
    
  4. 测试结果:实现了分布式锁,同时只有一个事务执行了业务操作,其他线程在等待解锁之后,也会依次执行自己的业务

    在这里插入图片描述

从结果来看,这种方式的悲观锁,同时确实只有一个事务执行了业务操作,其他线程在等待解锁之后,也会依次执行自己的业务,是满足分布式锁的要求的。

接下来扩展一下思路,假如要求其他线程获取不到锁后,不执行自己的业务呢?就类似第①种方式(唯一索引的分布式)的执行结果,那这种要怎么实现呢?

  1. 扩展需求:要求其他未抢到锁的事务,放弃执行自己的业务,仅打印日志:没有获取到锁。重写业务类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);
            }
        }
    }
    
  2. 扩展需求测试方法

        @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);
                });
            });
        }
    
  3. 扩展需求测试结果:实现了分布式锁,同时只有一个事务执行了业务操作,其他线程未获取到锁,不再执行自己的业务,仅打印日志

    在这里插入图片描述

 

③: 基于乐观锁

乐观锁的适用场景及原因如下:

  • 超高并发需求​​
    • ​​原因​​:乐观锁没有加锁操作,通过版本号检测冲突,在冲突率较低的情况下,性能远高于悲观锁。
    • 案例​​:短视频点赞,点赞操作频繁且冲突率低(一个视频同时被多人点赞),即使冲突也可以通过重试解决。
  • ​​最终一致可接受​​
    • 原因​​:乐观锁可能存在短暂的数据不一致(冲突检测到重试成功前),但最终会达到一致。
    • ​​案例​​:购物车合并,即使出现冲突,通过重试后系统状态仍然一致,不需要严格的实时一致。
  • ​​可重试业务​​
    • ​​原因​​:乐观锁在更新失败后(版本不匹配)需要重试,因此业务逻辑必须可以安全地多次执行(幂等)。
    • ​​案例​​:用户积分变动,积分更新操作是幂等的,重试不影响最终结果。

大致流程如下:

  • 在表中增加版本号字段,更新时检查版本号。但这种方法不适用于锁,因为锁需要独占,而乐观锁通常用于避免冲突的更新,不适合互斥锁的场景。

实现过程如下:

  1. 创建一张用于乐观锁 加锁和解锁的表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='乐观锁实现分布式锁';
    

    然后手动插入一条数据用于乐观锁 加锁

    在这里插入图片描述

  2. 开发业务类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;
            }
        }
    
    }
    
  3. 测试方法: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. 测试结果:实现了分布式锁,线程占锁期间,其他线程不执行业务,仅打印没有获取到锁

    在这里插入图片描述

       

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:互斥的资源id
  • W:借款人还款操作
  • R:投资人债权转让操作

对 X 资源,可以执行 2 种操作:W 操作、R 操作,2 种操作需要满足下面条件

  1. 执行操作的机器分布式在不同的节点中,也就是分布式

  2. W(借款人还款) 操作是独享的,也就是说同一时刻只允许有一个操作者对 X 执行 W 操作

  3. R (投资人债权转让)操作是共享的,也就是说同时可以有多个执行者对 X 资源执行 R 操作

  4. 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 个字段:

  1. resource_id:互斥资源 id,比如上面的借款记录 id

  2. w_count:当前执行 W(借款人还款)操作的数量

  3. 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.2int 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_lockt_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.2int 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=0int updateLogCount = (update t_lock_log set status=1 where id=#{日志记录id} and status = 0)
        5.4if(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.2if(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.2int 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=0int updateLogCount = (update t_lock_log set status=1 where id=#{日志记录id} and status = 0)
        5.4if(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 读写锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值