Redis:分布式锁应用中获取锁与加锁的原子性问题

郎家岭伯爵 2025年04月02日 55次浏览

前言

在分布式环境下通常需要加分布式锁来解决服务之间的资源竞争问题,通常我们使用 Redis 或者 Zookeeper 来解决分布式锁问题。

本文我们来解决下 Redis 实现分布式锁时的获取锁和加锁的原子性操作问题。

实现

前面我们写过几篇关于 Redis 基本操作的案例,本文不再赘述 Redis 的基础使用。另外也不赘述分布式锁的概念,我们默认大家已充分理解分布式锁。

Redis分布式锁基础使用

Redis 实现分布式锁的逻辑十分简单。这里我们直接贴出代码:

package com.langjialing.helloworld.controlle1;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * @author 郎家岭伯爵
 */
@RestController
@Slf4j
@RequestMapping("/redis")
public class RedisController {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 分布式锁的概念:在分布式环境下,多个服务实例对共享资源进行操作时,为了保证数据的一致性,需要对共享资源进行加锁操作。
     */
    @RequestMapping("/set")
    public String set(String value) {
        try{
            String key = "langjialing";

            // 获取锁资源
            String s = stringRedisTemplate.opsForValue().get(key);
            // 如果锁资源不为空,说明锁被占用
            if (s != null) {
                return "当前锁被占用";
            }
            // 无锁状态,加锁
            stringRedisTemplate.opsForValue().set(key, value, 1, TimeUnit.MINUTES);

            // TODO:加锁后操作资源,根据自己的业务场景进行操作

            return "success";
        } catch (Exception e) {
            log.error("set redis error", e);
            return "fail";
        } finally {
            // 操作完资源释放锁
            stringRedisTemplate.delete("langjialing");
        }
    }
}

获取锁与加锁的原子性问题解决

问题原因

在上面的案例中,我们注意到 GET 锁资源后进行了判断,然后再使用 SET 对资源加锁。如果 GET 时锁资源处于无锁状态,但在执行判断逻辑时其它服务对资源加了锁,这样双方都获取到了锁。 这就是由于获取锁与加锁非原子性导致的锁竞争问题。

原子性问题:GET 和 SET操作不是原子执行的,其他服务可能在两者之间插入操作,导致锁的互斥性被破坏。

了解到以上内容后,解决思路也是比较简单的:保证 GETSET 操作的原子性

问题解决

本案例中我们使用 JedisPool 工具里的 jedis.getResource().set(key, value, SetParams.setParams().nx().ex(60)); 方法来解决。

使用 Jedis 的 NX 参数时,Redis 会检查 key 是否存在:

  • 存在:不执行任何操作,返回 null(表示加锁失败)。
  • 不存在:设置键值并附加过期时间,返回 “OK”(表示加锁成功)。

需要注意的是

  • 使用 JedisPool 时需要手动创建一个配置类。上面案例里的 StringRedisTemplate 不需要创建配置类。因为 StringRedisTemplate 是基于 Spring Data Redis 的工具,Spring 帮我们加载了配置。
维度 StringRedisTemplate Jedis
抽象层级 高级抽象(封装了连接管理和序列化逻辑) 底层客户端(直接操作 Redis 命令)
线程安全 是(可在多线程中共享) 否(每个线程需独立实例)
连接池管理 自动管理(通过 Lettuce 或 Jedis 连接工厂) 需手动配置 JedisPool
与 Spring 集成 深度集成(自动配置、依赖注入) 需手动配置
适用场景 推荐在 Spring 项目中使用 非 Spring 项目,或需要精细控制连接池时

pom.xml引入依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.4.0</version>
</dependency>

application.yaml的Redis配置

spring:
  application:
    name: 郎家岭伯爵
  redis:
    ssl: false
    host: 192.168.201.128
    port: 6379
    database: 0
    timeout: 1000
    password:
    lettuce:
      pool:
        max-active: 100
        max-wait: -1
        min-idle: 0
        max-idle: 20

JedisPool 配置类

package com.langjialing.helloworld.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
 * @author 郎家岭伯爵
 */

@Configuration
public class JedisConfig {
    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Value("${spring.redis.password}")
    private String password;

    @Value("${spring.redis.database}")
    private int database;

    @Value("${spring.redis.lettuce.pool.max-active}")
    private int maxActive;

    @Value("${spring.redis.lettuce.pool.max-idle}")
    private int maxIdle;

    @Value("${spring.redis.lettuce.pool.min-idle}")
    private int minIdle;

    @Value("${spring.redis.lettuce.pool.max-wait}")
    private long maxWaitMillis;

    @Value("${spring.redis.timeout}")
    private int timeout;

    @Bean
    public JedisPool jedisPool() {
        // 1. 配置连接池参数
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        // 最大连接数
        poolConfig.setMaxTotal(maxActive);
        // 最大空闲连接数
        poolConfig.setMaxIdle(maxIdle);
        // 最小空闲连接数
        poolConfig.setMinIdle(minIdle);
        // 获取连接时的最大等待时间(毫秒)
        poolConfig.setMaxWaitMillis(maxWaitMillis);
        // 借用连接时进行有效性测试
        poolConfig.setTestOnBorrow(true);
        // 归还连接时进行有效性测试
        poolConfig.setTestOnReturn(true);

        // 2. 创建 JedisPool
        if (password == null || password.isEmpty()) {
            return new JedisPool(poolConfig, host, port, timeout, null, database);
        } else {
            return new JedisPool(poolConfig, host, port, timeout, password, database);
        }
    }
}

JedisPool实现原子性操作锁资源

package com.langjialing.helloworld.controlle1;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.SetParams;

import javax.annotation.Resource;
import java.util.UUID;

/**
 * @author 郎家岭伯爵
 */
@RestController
@Slf4j
@RequestMapping("/redis")
public class RedisController {

    @Resource
    private JedisPool jedisPool;

    @RequestMapping("/set1")
    public String set1(String value) {
        Jedis jedis = jedisPool.getResource();
        String uid = UUID.randomUUID().toString();
        String key = "langjialing";
        try {
            // 实现GET和SET的原子性。如果key存在会返回null,如果key不存在会返回OK
            String set = jedis.set(key, value + "_" + uid, SetParams.setParams().nx().ex(60));
            if (set == null){
                return "当前锁被占用";
            } else if ("OK".equals(set)) {
                return "success";
            } else {
                return null;
            }
        } catch (Exception e) {
            log.error("set redis error", e);
            return "fail";
        } finally {
            // 验证uid,确保删除的是自己的锁
            String s = jedis.get(key);
            String[] s1 = s.split("_");
            if (uid.equals(s1[1])) {
                // 操作完资源释放锁
                jedis.del(key);
            }
            // 释放资源
            jedis.close();
        }
    }
}

通过以上方案即可解决 Redis 在分布式锁中 GETSET 的原子性问题。不过这只是一种方案,针对更复杂的 Redis 操作时,以上方案就无法满足要求了(例如上面的方案中在 finally 代码块中就未实现 GETDEL 操作的原子性。不过本案例的场景也没有必要实现 GETDEL 的原子性操作)。

原子性操作 Redis 的终极方案必然是 Lua 脚本。

总结

本文总结下 Redis 实现分布式锁时获取锁与加锁操作的原子性问题。