分布式定时任务实战:Redis锁解决@Scheduled并发难题

想象一下这样的场景:凌晨三点,你的电商平台准时启动日结算任务,却在同一时刻被部署在三个不同Pod上的相同服务重复执行了三次。结果?库存数据错乱,财务对账崩溃,运维团队半夜被紧急呼叫。这就是分布式环境下定时任务失控的典型噩梦。

1. 为什么单机锁在分布式场景中失效

单机环境下,我们用synchronized或ReentrantLock就能轻松解决定时任务并发问题。但当你把服务部署到多台机器上,这些本地锁瞬间变成"纸老虎"。每个JVM都有自己的锁管理器,它们根本不知道其他JVM的存在。

典型问题场景:

  • 微服务多实例部署时,每个实例的@Scheduled都会独立触发
  • Kubernetes滚动更新期间,新旧版本可能同时运行定时任务
  • 自动扩缩容导致临时出现多个服务实例
// 这种锁在分布式环境下完全无效
private final Object lock = new Object();

@Scheduled(cron = "0 0 3 * * ?")
public void dailySettlement() {
    synchronized(lock) {
        // 仍然会被多个实例同时执行
    }
}

2. Redis分布式锁核心实现方案

2.1 基于SETNX的原子操作

Redis的SETNX(SET if Not eXists)命令是实现分布式锁的基石。但单纯使用SETNX会存在锁无法释放的风险,我们需要给锁设置过期时间:

# 正确的加锁命令
SET lock:order_task <unique_value> NX PX 30000

参数解析:

  • NX:仅当key不存在时才设置
  • PX 30000:30秒后自动过期
  • <unique_value>:每个客户端唯一的标识值(推荐UUID)

2.2 Redisson客户端最佳实践

手动实现Redis锁需要考虑太多边界情况,不如直接使用成熟的Redisson客户端:

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

@Service
public class OrderTaskService {
    @Autowired
    private RedissonClient redissonClient;
    
    @Scheduled(cron = "0 0/5 * * * ?")
    public void processOrders() {
        RLock lock = redissonClient.getLock("order:process:lock");
        try {
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                // 真正的业务逻辑
            }
        } finally {
            lock.unlock();
        }
    }
}

3. 分布式锁的五大陷阱与规避策略

3.1 死锁:当锁忘记释放时

常见原因:

  • 任务执行时间超过锁过期时间
  • 程序崩溃未执行finally块
  • 网络分区导致解锁命令未到达Redis

解决方案:

// 推荐使用带超时的尝试获取锁
boolean acquired = lock.tryLock(5, 30, TimeUnit.SECONDS);

3.2 脑裂:集群故障时的锁失效

Redis集群发生网络分区时可能出现多个客户端同时持有锁的情况。应对方案:

  1. 使用Redlock算法(需要至少5个Redis主节点)
  2. 增加校验机制,如操作前检查数据版本号
  3. 关键操作实现幂等性

3.3 锁粒度:太粗降低并发度

错误示范:

// 整个方法加锁,并发性能差
RLock lock = redissonClient.getLock("global:lock");

优化方案:

// 按订单ID分段加锁
String lockKey = "order:" + orderId + ":lock";
RLock lock = redissonClient.getLock(lockKey);

3.4 锁续期:长任务的特殊处理

对于执行时间不确定的长任务,需要实现锁续期机制:

// Redisson已经内置看门狗机制
lock.lock(30, TimeUnit.SECONDS); // 看门狗会自动续期

// 手动续期示例
if (lock.isHeldByCurrentThread()) {
    lock.expire(30, TimeUnit.SECONDS);
}

3.5 锁等待:避免系统雪崩

大量线程同时争抢锁会导致Redis压力激增。应对策略:

  1. 采用随机退避算法
  2. 实现二级缓存(本地锁+分布式锁)
  3. 限制最大等待时间
// 带超时的锁等待
if (!lock.tryLock(100, 10000, TimeUnit.MILLISECONDS)) {
    // 记录监控指标并快速失败
    metrics.counter("lock.timeout").increment();
    return;
}

4. 定时任务锁的高级应用场景

4.1 集群任务调度:确保唯一执行

使用Redis锁实现集群环境下定时任务的全局唯一执行:

@Scheduled(cron = "0 0 2 * * ?")
public void generateDailyReport() {
    RLock lock = redissonClient.getLock("report:generate:lock");
    if (!lock.tryLock()) {
        log.info("其他节点正在生成报表");
        return;
    }
    
    try {
        // 实际报表生成逻辑
    } finally {
        lock.unlock();
    }
}

4.2 任务执行顺序控制

通过锁的获取顺序实现任务执行顺序控制:

public void executeInOrder() {
    // 先获取前置任务锁
    RLock firstLock = redissonClient.getLock("task:first");
    RLock secondLock = redissonClient.getLock("task:second");
    
    boolean first = firstLock.tryLock();
    if (!first) return;
    
    try {
        // 执行第一个任务
        doFirstTask();
        
        // 完成后尝试获取第二个锁
        if (secondLock.tryLock()) {
            try {
                doSecondTask();
            } finally {
                secondLock.unlock();
            }
        }
    } finally {
        firstLock.unlock();
    }
}

4.3 动态定时任务管理

结合Redis实现动态调整的定时任务:

@Scheduled(fixedDelayString = "${task.interval}")
public void dynamicTask() {
    RLock lock = redissonClient.getLock("dynamic:task:lock");
    try {
        if (lock.tryLock()) {
            // 从Redis读取最新配置
            String config = redissonClient.getBucket("task:config").get();
            processWithConfig(config);
        }
    } finally {
        lock.unlock();
    }
}

5. 监控与性能优化实战

5.1 锁竞争监控指标

关键监控指标示例:

指标名称类型说明
lock.acquire.time计时器获取锁耗时
lock.wait.count计数器等待锁的线程数
lock.hold.time计时器锁持有时间
lock.timeout.rate比率锁获取超时比例

5.2 Redisson配置调优

推荐的生产环境配置:

# application-redis.yml
singleServerConfig:
  idleConnectionTimeout: 10000
  connectTimeout: 5000
  timeout: 3000
  retryAttempts: 3
  retryInterval: 1000
  subscriptionsPerConnection: 5
  connectionMinimumIdleSize: 10
  connectionPoolSize: 64
  dnsMonitoringInterval: 5000
threads: 16
nettyThreads: 32

5.3 替代方案对比

当Redis不适用时的备选方案:

  1. Zookeeper:

    • 优点:强一致性,完善的watch机制
    • 缺点:写入性能较低
  2. 数据库分布式锁:

    -- 基于唯一索引的实现
    INSERT INTO distributed_lock(lock_name, owner, expire_at) 
    VALUES ('report_lock', 'node1', NOW() + INTERVAL 30 SECOND);
    
  3. ETCD:

    • 优点:高可用,强一致性
    • 缺点:部署复杂度较高

在实际项目中,我们团队曾遇到过一个典型案例:对账系统每天凌晨需要处理上百万条交易记录。最初使用数据库行锁导致任务经常超时失败,后来改用Redis分段锁(按商户ID分片)后,任务执行时间从4小时缩短到40分钟。关键点在于:

  • 锁粒度细化到每个商户
  • 设置合理的锁超时时间(5分钟)
  • 实现任务中断后的断点续做功能
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐