SpringBoot + 分布式锁 + 定时任务:避免集群环境下任务重复执行的 3 种方案对比

相信很多小伙伴都遇到过这样的场景:系统部署在多台服务器上,每个服务器都配置了相同的定时任务,到了执行时间,所有服务器上的任务一起触发,不仅浪费系统资源,还可能导致数据不一致等问题。这可怎么办呢?今天我们来聊聊一个在分布式系统中非常常见的问题:如何避免集群环境下定时任务的重复执行?

问题的根源:集群环境下的定时任务困境

在单体应用时代,定时任务的执行很简单,就是到了指定时间点就执行。但在微服务或集群环境下,情况就变得复杂了。

想象一下,你有一个清理过期数据的定时任务,设定每天凌晨2点执行。如果你的系统部署在5台服务器上,那么就会有5个清理任务同时运行,不仅浪费资源,还可能因为并发操作导致数据混乱。

这就是分布式锁要解决的核心问题:如何在多个节点中协调,确保同一时间只有一个节点执行特定的任务。

解决方案概览

目前业界主流的分布式锁实现方案主要有三种:

  1. 基于Redis的分布式锁
  2. 基于数据库的分布式锁
  3. 基于Zookeeper的分布式锁

每种方案都有其独特的优势和适用场景,下面我们来详细对比一下。

方案一:基于Redis的分布式锁

实现原理

Redis的分布式锁实现主要依靠其原子性操作。我们使用SET命令的NX和EX选项来实现:

// 尝试获取锁
String result = redisTemplate.opsForValue().setIfAbsent(
    lockKey,     // 锁的键
    requestId,   // 锁的值,用于标识锁的持有者
    expireTime,  // 过期时间
    TimeUnit.MILLISECONDS
);

这里的NX选项表示只有当键不存在时才设置,EX选项设置键的过期时间,这样可以避免死锁问题。

代码实现

@Component
public class RedisDistributedLock {
    
    @Autowired
    private StringRedisTemplate redisTemplate;

    // Lua脚本用于原子性解锁
    private static final String UNLOCK_LUA_SCRIPT =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) " +
            "else " +
            "return 0 " +
            "end";

    /**
     * 尝试获取分布式锁
     */
    public boolean tryLock(String lockKey, String requestId, int expireTime) {
        try {
            String result = redisTemplate.opsForValue().setIfAbsent(
                    lockKey, requestId, expireTime, TimeUnit.MILLISECONDS);
            return Boolean.TRUE.equals(result);
        } catch (Exception e) {
            log.error("获取Redis分布式锁异常", e);
            return false;
        }
    }

    /**
     * 释放分布式锁
     */
    public boolean releaseLock(String lockKey, String requestId) {
        try {
            // 使用Lua脚本保证解锁操作的原子性
            DefaultRedisScript<Long> script = 
                new DefaultRedisScript<>(UNLOCK_LUA_SCRIPT, Long.class);
            Long result = redisTemplate.execute(script, 
                Collections.singletonList(lockKey), requestId);
            return RELEASE_SUCCESS.equals(result);
        } catch (Exception e) {
            log.error("释放Redis分布式锁异常", e);
            return false;
        }
    }
}

优缺点分析

优点:

  • 性能高:Redis是内存数据库,读写速度快
  • 实现简单:利用SET命令的NX和EX选项即可实现
  • 支持锁自动过期:避免死锁问题
  • 社区生态丰富:有Redission等成熟的客户端库

缺点:

  • 需要额外依赖:需要部署和维护Redis集群
  • 单点故障风险:虽然可以通过Redis Cluster缓解,但仍存在风险
  • 可能出现锁误删:如果业务处理时间超过锁过期时间,可能导致锁被其他节点获取

方案二:基于数据库的分布式锁

实现原理

数据库分布式锁利用数据库的唯一约束或行锁机制。我们创建一张锁表,利用主键唯一性来实现互斥。

代码实现

@Component
public class DatabaseDistributedLock {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 初始化锁表
     */
    public void initLockTable() {
        String sql = """
            CREATE TABLE IF NOT EXISTS distributed_lock (
                lock_key VARCHAR(255) PRIMARY KEY,
                lock_value VARCHAR(255) NOT NULL,
                expire_time TIMESTAMP NOT NULL
            )
            """;
        jdbcTemplate.execute(sql);
    }

    /**
     * 尝试获取分布式锁
     */
    @Transactional
    public boolean tryLock(String lockKey, String requestId, int expireTime) {
        try {
            // 删除已过期的锁
            deleteExpiredLocks();

            // 尝试插入新的锁记录
            String insertSql = """
                INSERT INTO distributed_lock (lock_key, lock_value, expire_time)
                VALUES (?, ?, ?)
                ON DUPLICATE KEY UPDATE
                    lock_value = IF(expire_time < NOW(), ?, lock_value),
                    expire_time = IF(expire_time < NOW(), ?, expire_time)
                """;

            Timestamp expireTimeTs = Timestamp.valueOf(
                LocalDateTime.now().plusSeconds(expireTime));
            int affectedRows = jdbcTemplate.update(insertSql, 
                lockKey, requestId, expireTimeTs, requestId, expireTimeTs);

            // 检查是否真的获得了锁
            return checkIfLockAcquired(lockKey, requestId);
        } catch (Exception e) {
            log.error("获取数据库分布式锁异常", e);
            return false;
        }
    }

    /**
     * 释放分布式锁
     */
    @Transactional
    public boolean releaseLock(String lockKey, String requestId) {
        try {
            String sql = "DELETE FROM distributed_lock WHERE lock_key = ? AND lock_value = ?";
            int affectedRows = jdbcTemplate.update(sql, lockKey, requestId);
            return affectedRows > 0;
        } catch (Exception e) {
            log.error("释放数据库分布式锁异常", e);
            return false;
        }
    }
}

优缺点分析

优点:

  • 无需额外中间件:利用现有数据库
  • 数据一致性有保障:数据库本身的事务特性
  • 成本低:无需额外部署中间件

缺点:

  • 性能相对较低:数据库读写性能低于内存数据库
  • 数据库压力大:频繁的锁操作会影响数据库性能
  • 锁释放依赖事务:在某些情况下可能导致锁无法及时释放

方案三:基于Zookeeper的分布式锁

实现原理

Zookeeper的分布式锁利用其临时顺序节点的特性。每个客户端在指定的父节点下创建临时顺序子节点,然后获取父节点下的所有子节点,判断自己创建的节点是否是最小的,如果是则获得锁,否则监听前一个节点的变化。

代码实现

@Component
public class ZookeeperDistributedLock {

    @Value("${zookeeper.address:localhost:2181}")
    private String zkAddress;

    private CuratorFramework client;

    @PostConstruct
    public void init() {
        client = CuratorFrameworkFactory.newClient(
                zkAddress,
                new ExponentialBackoffRetry(1000, 3)
        );
        client.start();
    }

    /**
     * 尝试获取分布式锁
     */
    public InterProcessMutex acquireLock(String lockPath, int timeout, TimeUnit timeUnit) {
        try {
            InterProcessMutex lock = new InterProcessMutex(client, lockPath);
            boolean acquired = lock.acquire(timeout, timeUnit);
            
            if (acquired) {
                log.debug("成功获取Zookeeper分布式锁: {}", lockPath);
                return lock;
            } else {
                log.debug("获取Zookeeper分布式锁失败: {}", lockPath);
                return null;
            }
        } catch (Exception e) {
            log.error("获取Zookeeper分布式锁异常", e);
            return null;
        }
    }

    /**
     * 释放分布式锁
     */
    public void releaseLock(InterProcessMutex lock) {
        try {
            if (lock != null && lock.isAcquiredInThisProcess()) {
                lock.release();
                log.debug("成功释放Zookeeper分布式锁");
            }
        } catch (Exception e) {
            log.error("释放Zookeeper分布式锁异常", e);
        }
    }
}

优缺点分析

优点:

  • 高可靠性:Zookeeper本身具有高可用性和一致性
  • 强一致性:保证数据的一致性
  • 天然支持监听机制:可以实现更复杂的锁策略

缺点:

  • 需要额外依赖:需要部署和维护Zookeeper集群
  • 实现相对复杂:需要处理连接管理、会话失效等问题
  • 性能不如Redis:网络开销相对较大

实际应用示例

让我们看看如何在定时任务中使用这些分布式锁:

@Service
public class ScheduledTaskService {

    @Autowired
    private DistributedLockManager distributedLockManager;

    /**
     * 使用Redis分布式锁的定时任务
     */
    @Scheduled(fixedRate = 30000) // 每30秒执行一次
    public void redisLockedTask() {
        String lockKey = "scheduled_task_redis_locked";
        int expireTime = 25000; // 锁过期时间25秒

        boolean executed = distributedLockManager.executeWithRedisLock(lockKey, expireTime, () -> {
            log.info("Redis分布式锁保护的定时任务开始执行...");
            
            // 模拟业务处理
            try {
                Thread.sleep(10000); // 模拟耗时操作
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            
            log.info("Redis分布式锁保护的定时任务执行完成");
        });

        if (!executed) {
            log.debug("Redis分布式锁任务未执行(其他节点正在执行)");
        }
    }
}

三种方案对比总结

特性Redis数据库Zookeeper
性能
实现复杂度简单复杂
可靠性
额外依赖
运维成本

如何选择合适的方案?

选择哪种分布式锁方案,主要取决于你的具体业务场景:

  1. 如果追求高性能:选择Redis方案,适合对性能要求较高的场景
  2. 如果已有数据库基础设施:选择数据库方案,无需额外部署中间件
  3. 如果对可靠性要求极高:选择Zookeeper方案,适合关键业务场景

在实际项目中,我个人更倾向于Redis方案,因为它在性能和实现复杂度之间取得了很好的平衡,而且社区生态丰富,有Redission等成熟的客户端库可供使用。

最佳实践建议

  1. 合理设置锁的过期时间:避免业务处理时间超过锁过期时间导致的问题
  2. 使用UUID作为锁的持有者标识:确保锁的归属关系明确
  3. 实现可重入机制:避免同一节点重复获取锁的问题
  4. 做好异常处理:确保在异常情况下也能正确释放锁
  5. 监控和告警:对锁的获取和释放情况进行监控

总结

分布式锁是解决集群环境下资源竞争的重要手段,选择合适的方案对系统稳定性至关重要。希望通过这篇文章,你能对三种分布式锁方案有更深入的理解,并能在实际项目中做出合适的选择。

如果你有任何问题或想法,欢迎在评论区交流讨论!


标题:SpringBoot + 分布式锁 + 定时任务:避免集群环境下任务重复执行的 3 种方案对比
作者:jiangyi
地址:http://jiangyi.space/articles/2026/01/24/1769238862076.html

    0 评论
avatar