引言

在前面一文《花2万块买来的BUG!你的系统是不是也埋着相同的BUG?》中因为发短信接口未使用分布式锁导致短信接口在高并发情况下可以被超发,类似的情况在JAVA开发中是非常常见的,最简单解决方案就是给接口添加分布式锁,在上文中也提到了常见实现分布式锁的三种方式,基于数据库、基于Redis、基于Zookeeper,上文中使用了基于Redis的SETNX来实现的,如果项目中没有引用Redis或者Zookeeper也可以基于数据库来实现一个简单的分布式锁了,基于数据库实现分布式锁主要有三种方式,基于数据库唯一索引、基于数据库悲观锁和基于数据库乐观锁,接下来将详细介绍这三种方式实现的具体步骤。

二、基于唯一索引

2.1、实现思路

我们知道数据库表中的唯一索引可以确保一张表中相同数据只能插入一次,基于这条规则我们可以创建一张表,然后给锁名字段创建一个唯一索引,当并发插入时如果插入成功就获取到锁,插入失败就未获取到锁,释放锁就是把数据这条数据删除。

创建union_key_lock表:

CREATE TABLE `union_key_lock` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `lock_name` varchar(255) NOT NULL DEFAULT '',
  `expire_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '锁过期时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_key_name` (`lock_name`) USING BTREE
) ENGINE=InnoDB  COMMENT='唯一键实现分布式锁'

在union_key_lock表中将锁名字段lock_name添加唯一索引,expire_at为锁过期时间,可以在Mysql中或者项目中添加定时任务删除expire_at<now()的数据,防止代码出现异常未及时释放锁导致死锁。

2.2、代码实现

基于数据库唯一索引代码实现起来是非常简单的,有两个方法,第一个方法是lock(),接收一个锁名参数和锁超时时间参数,第二个方法是unLock()释放锁方法:

@Resource
private JdbcTemplate jdbcTemplate;

@Override
public Boolean lock(String lockName, Integer second) {
    try {
        String sql = String.format("insert into union_key_lock (lock_name, expire_at) value ('%s','%s')", lockName, DateUtil.formatLocalDateTime(LocalDateTime.now().plusSeconds(second)));
        jdbcTemplate.execute(sql);
        return true;
    } catch (Exception e) {
        return false;
    }
}

@Override
public void unLock(String lockName) {
    String sql = String.format("delete from union_key_lock where lock_name='%s';", lockName);
    jdbcTemplate.execute(sql);
}

2.3 、测试代码

我们编写多线程代码压测是否成功实现分布式锁:

@Resource
private UnionKeyLockImpl unionKeyLock;

@Test
void testunionKeyLock() {
    String lockName = "赵侠客";
    IntStream.range(1, 5).parallel().forEach(x -> {
        try {
            if (unionKeyLock.lock(lockName, 5)) {
                log.info("get lock success");
            } else {
                log.warn("get lock error");
            }
        } finally {
            unionKeyLock.unLock(lockName);
        }
    });
}

2.4、小结

基于数据库的分布式锁优点包括实现简单、事务支持、无需额外组件和持久化特性。缺点则包括性能较低、锁粒度受限、死锁风险、资源开销较大以及锁释放问题。

优点:

  • 实现简单:基于数据库唯一索引。
  • 事务支持:与业务操作同事务,保一致性。
  • 无需额外组件:适用于已有数据库系统。
  • 持久化:锁信息数据库存储,系统崩溃后仍存在。

缺点:

  • 性能较低:相比内存级锁,SQL操作性能低。
  • 锁粒度受限:以表或行为单位,易竞争。
  • 死锁风险:需严格事务管理。
  • 资源开销:频繁获取锁增数据库负载。
  • 锁释放问题:异常未释放需额外机制处理。

三、基于悲观锁

3.1 、实现思路

基于数据库悲观锁实现分布式锁依赖于数据库的行级锁机制,通过 SELECT … FOR UPDATE 等操作显式地锁定数据库中的某一行,来达到获取分布式锁的目的。在这种方式下,其他事务在尝试修改这行数据时会被阻塞,直到锁被释放。

创建一张锁表,记录需要锁定的资源:

CREATE TABLE `select_for_update_lock` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `lock_name` varchar(255) NOT NULL DEFAULT '',
  `lock_status` int(255) NOT NULL DEFAULT '0' COMMENT '0--正常 1--被锁',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_key_name` (`lock_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8 COMMENT='悲观锁'

当获取锁时使用FOR UPDATE阻塞其它查询,任务执行完成后COMMIT提交事务后自动释放锁,在调用锁之前要将锁名信息添加到表中。

BEGIN;
SELECT * FROM select_for_update_lock WHERE lock_name = 'my_lock' AND lock_status = 0 FOR UPDATE;
...执行任务
COMMIT;

其他事务在尝试执行 SELECT … FOR UPDATE 时会被阻塞,直到COMMIT后锁被释放。

3.2、代码实现

基于数据库悲观锁使用分布式锁代码也是非常简单的,只有一个方法:

@Resource
private JdbcTemplate jdbcTemplate;
@Resource
private PlatformTransactionManager platformTransactionManager;
public void lock(String lockName, Runnable runnable)  {
    // 定义事务
    DefaultTransactionDefinition def = new DefaultTransactionDefinition();
    def.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
    def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
    // 开启事务
    TransactionStatus status = platformTransactionManager.getTransaction(def);
    try {
        // 尝试获取锁
        jdbcTemplate.queryForObject("SELECT lock_name FROM select_for_update_lock WHERE lock_name = ? FOR UPDATE", String.class, lockName);
        runnable.run();;
    } catch (Exception e) {
        // 出现异常时回滚事务
        platformTransactionManager.rollback(status);
        throw e;
    }finally {
        // 提交事务,释放锁
        platformTransactionManager.commit(status);
    }
}

在代码lock()方法中使用PlatformTransactionManager手动开启使用,在finally中手动提交事务

3.3、测试代码

@Resource
    private SelectForUpdateLockImpl selectForUpdateLock;
    
    @Test
    void testSelectForUpdateLock() {
        String lockName = "赵侠客";
        IntStream.range(1, 10).parallel().forEach(x -> {
            try {
                selectForUpdateLock.lock(lockName, () -> {
                    log.info("get {} lock success", lockName);
                });
            } catch (Exception e) {
                log.error("get {} lock error", lockName);
            }
        });
    }

3.4、小结

基于数据库悲观锁的分布式锁有以下优缺点:
优点:

  • 实现简单:利用数据库行级锁机制,无需引入其他分布式锁组件。
  • 事务支持:悲观锁与数据库事务结合紧密,能保证业务逻辑的原子性。
  • 一致性强:依赖数据库锁机制,保证了高并发下数据的一致性。

缺点:

  • 性能瓶颈:数据库行锁在高并发时可能成为性能瓶颈,导致数据库连接阻塞。
  • 可用性受限:数据库故障或网络问题会影响锁的释放,降低系统可用性。
  • 死锁风险:多事务复杂操作下可能产生死锁,需要精心设计锁策略。
  • 锁粒度粗:行级锁可能导致锁竞争激烈,影响性能。
  • 资源开销大:长期占用数据库资源,可能导致锁等待和连接池资源耗尽。

四、基于乐观锁

4.1 、实现思路

基于数据库的乐观锁实现分布式锁通常利用唯一索引或版本号机制来确保在高并发场景下的锁定操作。乐观锁适合在冲突较少的场景中使用,依赖于更新时的数据状态一致性判断。以下是一个基于数据库乐观锁的分布式锁实现示例。
创建一张optimistic_lock表:

CREATE TABLE `optimistic_lock` (
  `id` bigint(20) 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(255) NOT NULL DEFAULT '0' COMMENT '0--正常 1--被锁',
   PRIMARY KEY (`id`),
   UNIQUE KEY `uidx_lock_name` (`lock_name`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET=utf8 COMMENT='乐观锁实现分布式锁'

在锁名字段上增加唯一索引,其实现思路是通过数据库的更新数据是否成功能判断是否获取到锁,所以我们要提前将锁名任务添加到表中,expire_at为锁过期时间,防止未及时释放导致死锁,这里可以通过定时任务删除过期的锁。

4.2 、代码实现

基于数据库乐观锁实现分布锁主要有两个方法:

@Resource
private JdbcTemplate jdbcTemplate;
public boolean lock(String lockName) {
    try {
        String sql = String.format("update optimistic_lock set lock_status=1, expire_at = NOW() + INTERVAL 1 MINUTE where lock_name ='%s' and lock_status = 0 ;", lockName);
        return jdbcTemplate.update(sql) == 1;
    } catch (Exception e) {
        return false;
    }
}

public void unLock(String lockName) {
    String sql = String.format("update optimistic_lock set lock_status=0 ,expire_at=now() where lock_name='%s' ;", lockName);
    jdbcTemplate.update(sql);
}

4.3 、测试代码

@Resource
    private OptimisticLock optimisticLock;

    @Test
    void testOptimisticLock() {
        String lockName = "赵侠客";
        IntStream.range(1, 10).parallel().forEach(x -> {
            try {
                if (optimisticLock.lock(lockName)) {
                    log.info("get lock success");
                } else {
                    log.warn("get lock error");
                }
            } finally {
                optimisticLock.unLock(lockName);
            }
        });
    }

4.4、小结

基于数据库乐观锁的分布式锁具有以下优缺点:

优点:

  • 实现简单:易于理解和实现,可以直接利用现有数据库,无需额外分布式中间件。
  • 数据库天然一致性:利用数据库的事务和一致性机制,保证并发场景下的数据一致性。
  • 适用于小规模系统:对于低并发系统,乐观锁可以有效满足需求,避免引入复杂中间件。

缺点:

  • 性能瓶颈:数据库不适合处理高并发锁操作,频繁的读写操作会给数据库带来压力。
  • 冲突处理复杂:乐观锁在冲突时需要重试,可能导致操作延迟。
  • 锁粒度问题:基于记录的锁粒度较粗,可能导致资源争用。
  • 不适合高并发场景:高并发下冲突率增加,重试操作影响性能和响应时间。
  • 数据库单点问题:依赖单个数据库节点可能导致单点故障。
  • 锁过期处理复杂:数据库锁缺乏自动过期机制,可能导致操作阻塞。

总结

基于数据库唯一索引、悲观锁、乐观锁实现分布式锁的适用场景可以总结如下:

基于数据库唯一索引的分布式锁:

  • 适用场景:低并发、简单锁定操作、短时间锁持有、无需自动超时机制。
  • 典型场景:任务调度、确保资源独占访问。

基于数据库悲观锁的分布式锁:

  • 适用场景:高冲突、长业务操作、资源一致性要求高。
  • 典型场景:金融交易、订单状态更新。

基于数据库乐观锁的分布式锁:

  • 适用场景:低冲突、高并发、短时间锁持有、允许重试获取锁。
  • 典型场景:订单扣库存、数据表版本更新、用户抽奖等。