# scaffold 项目之分布式锁

# 简介

分布式锁 是一种用于协调分布式系统中的多个节点或服务实例对共享资源的访问的机制。它的主要目标是确保在分布式环境下,同一时间只有一个节点或进程能够操作共享资源,从而避免资源竞争和数据不一致性问题。

也就是在 集群环境下 保证某些资源或者数据一致性,本项目对于分布式锁组件使用的是基于 Redis 实现的:

基于 Redis 的分布式锁

  • 原理:利用 Redis 的单线程特性和原子性操作来实现锁。常用的方法是使用 Redis 的 SETNXSET if Not eXists )命令,该命令在键不存在时设置其值,并返回 1;否则返回 0。
  • 优点
    • 实现简单。
    • Redis 性能高,能够快速响应锁请求。
  • 缺点
    • 依赖 Redis 的可用性,如果 Redis 异常,锁服务就会失效。
    • 需要处理锁的失效问题(如锁的自动过期机制可能导致死锁或数据不一致)。

# 开始使用

本项目封装了 scaffold-spring-boot-starter-protection 技术组件,使用 Redis 实现分布式锁的功能,它有两种使用方式分别如下:

  • 编程式锁:基于 Redisson 框架提供的各种分布式锁
  • 声明式锁:基于 Lock4j 框架的 @Lock4j 注解 (其实它也可以用编程式锁)

# 编程式锁

直接引用之前文章 scaffold项目之redis缓存 整合了 redis 相关功能

<dependency>
    <groupId>com.tz.boot</groupId>
    <artifactId>scaffold-spring-boot-starter-redis</artifactId>
</dependency>

无需额外的配置,因为 scaffold-spring-boot-starter-redis 组件配置好了

# 简单使用

例子:

public class LockService {
    public void execute() {
        RedissonClient redisson = RedissonManager.getRedisson();
        RLock lock = redisson.getLock("myLock");
        boolean isLocked;
        try {
            isLocked = lock.tryLock(100, 10, TimeUnit.SECONDS);
            if (isLocked) {
                // 业务逻辑
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (isLocked) {
                lock.unlock(); // 解锁
            }
        }
    }
}

# 实战应用

在支付模块 scaffold-module-pay 中,需要确保每个支付任务通知有且只有一个在执行,在单机模式下可以用 synchronized ,如果在集群环境下就需要用到分布式锁了,具体使用:

  1. 定义锁的 key 值,或规范

    /**
     * <p> Project: scaffold - RedisKeyConstants </p>
     *
     * 支付 Redis Key 枚举类
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    public interface RedisKeyConstants {
        /**
         * 通知任务的分布式锁
         * <p>
         * KEY 格式:pay_notify:lock:% d // 参数来自 DefaultLockKeyBuilder 类
         * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构
         * 过期时间:不固定
         */
        String PAY_NOTIFY_LOCK = "pay_notify:lock:%d";
        /**
         * 支付序号的缓存
         * <p>
         * KEY 格式:pay_no:{prefix}
         * VALUE 数据格式:编号自增
         */
        String PAY_NO = "pay_no:";
    }
  2. 创建 PayNotifyLockRedisDAO 使用 RedissonClient 来操作加锁

    /**
     * <p> Project: scaffold - PayNotifyLockRedisDAO </p>
     *
     * 支付通知的锁 Redis DAO
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @Repository
    public class PayNotifyLockRedisDAO {
        @Resource
        private RedissonClient redissonClient;
        public void lock(Long id, Long timeoutMillis, Runnable runnable) {
            String lockKey = formatKey(id);
            RLock lock = redissonClient.getLock(lockKey);
            try {
                lock.lock(timeoutMillis, TimeUnit.MILLISECONDS);
                // 执行逻辑
                runnable.run();
            } finally {
                lock.unlock();
            }
        }
        private static String formatKey(Long id) {
            return String.format(PAY_NOTIFY_LOCK, id);
        }
    }

    解释:

    lock.lock (timeoutMillis, TimeUnit.MILLISECONDS);: 添加 Redis 分布式锁,时长通过参数控制

    runnable.run ();: 执行逻辑

    lock.unlock ();: 释放 Redis 分布式锁

  3. PayNotifyServiceImpl 中,执行通知的时候通过 PayNotifyLockRedisDAO 获取锁

    /**
         * 同步执行单个支付通知
         *
         * @param task 通知任务
         */
        public void executeNotify(PayNotifyTaskDO task) {
            // 分布式锁,避免并发问题
            notifyLockCoreRedisDAO.lock(task.getId(), NOTIFY_TIMEOUT_MILLIS, () -> {
                // 校验,当前任务是否已经被通知过
                // 虽然已经通过分布式加锁,但是可能同时满足通知的条件,然后都去获得锁。此时,第一个执行完后,第二个还是能拿到锁,然后会再执行一次。
                // 因此,此处我们通过第 notifyTimes 通知次数是否匹配来判断
                PayNotifyTaskDO dbTask = notifyTaskMapper.selectById(task.getId());
                if (ObjectUtil.notEqual(task.getNotifyTimes(), dbTask.getNotifyTimes())) {
                    log.warn("[executeNotifySync][task({}) 任务被忽略,原因是它的通知不是第 ({}) 次,可能是因为并发执行了]",
                            JsonUtils.toJsonString(task), dbTask.getNotifyTimes());
                    return;
                }
                // 执行通知
                getSelf().executeNotify0(dbTask);
            });
        }

# 声明式锁

Lock4j 是一个基于 AOP 的声明式锁框架,通过注解的方式简化了分布式锁的使用。开发者只需要在需要加锁的方法上添加注解,框架会自动处理锁的获取和释放。

  1. 引入依赖:

    <dependency>
         <groupId>com.baomidou</groupId>
         <artifactId>lock4j-redis-template-spring-boot-starter</artifactId>
    </dependency>
  2. 配置:

    lock4j:
      redis:
        host: 127.0.0.1
        port: 6379
        password: null
        database: 0
        timeout: 10000
  3. 使用

    直接在方法上添加注解,非常方便

    public class UserService {
        @Lock(name = "userLock", expire = 10000)
        public void updateUser(User user) {
            // 业务逻辑
        }
    }

# 附录:

# Redisson 编程式锁和 Lock4j 声明式锁的比较

特性Redisson 编程式锁Lock4j 声明式锁
使用方式编程式,需要手动获取和释放锁声明式,通过注解自动处理锁
灵活性高,可以灵活控制锁的获取和释放低,依赖框架自动处理
易用性低,需要编写更多代码高,只需添加注解
可读性低,代码中需要显式处理锁高,注解清晰明了
维护性低,锁的逻辑分散在代码中高,锁的逻辑集中管理

# 选择建议

  • Redisson 编程式锁:适用于需要高度灵活性和控制的场景,如复杂的业务逻辑中需要手动管理锁的获取和释放。
  • Lock4j 声明式锁:适用于需要简化锁的使用,提高代码可读性和维护性的场景,如在业务逻辑中只需要简单地加锁和解锁。