Redis--一文搞懂Redis分布式锁

分布式锁

当多个线程要访问一个共享资源(数据库数据或Redis中的数据或共享文件)时,为了达到多个线程同步访问,此时需要使用分布式锁。

让这些线程在访问共享资源之前先获取一个令牌token,持有令牌的线程才可以访问共享资源。这个令牌就是分布式锁。这个分布式锁是一种“互斥资源”,只有一个。

只要有线程抢到锁,其他线程只能等待,直到锁被释放或等待超时。

场景引入

电商普通对商品sk:0008进行秒杀销售。假设商品数量amount=1000,每个账户只能抢购一台,就是每个请求只能减少一台库存。

普通代码

@RestController
public class SecSkillController {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @RequestMapping("/secSkill")
    public String secSkill() {
        // 1. 判断库存是否充足
        String stockKey = "stock:0008";
        String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
        int stock = Integer.parseInt(stockStr);
        if (stock <= 0) {
            return "秒杀失败,库存不足";
        }else{
            // 2. 扣减库存
            stringRedisTemplate.opsForValue().decrement(stockKey);
            return "秒杀成功,剩余库存:" + (stock - 1);
        }
    }
}

上述代码问题:秒杀是高并发场景,而且生产环境下一定是部署在一个集群中。如果用户量非常大,会存在多用户同时读取Redis缓存中的sk:0008,那么读取到的value很可能相同,都大于0,都能购买,此时会出现“超卖”问题。

setnx 实现方式解决

setnx 是只有在指定的key不存在时才能执行成功,分布式系统中,哪个节点抢到了setnx,谁就抢到了锁,谁就能操作共享资源。其他节点只能等待锁的释放。然后重启使用setnx命令抢注该key。

具体代码如下:

    // 锁的key
    public static final String Redis_lock = "Redis_lock";

    @RequestMapping("/secSkill2")
    public String secSkill2() {
        String res= "抢购失败!";
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, "lock");
        try {
            if (lock) {
                // 1. 判断库存是否充足
                String stockKey = "stock:0008";
                String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
                int stock = Integer.parseInt(stockStr);
                if (stock <= 0) {
                    res = "秒杀失败,库存不足";
                }else{
                    // 2. 扣减库存
                    stringRedisTemplate.opsForValue().decrement(stockKey);
                    res = "秒杀成功,剩余库存:" + (stock - 1);
                }
            }else{
                res = "没抢到锁哦!";
            }
        }finally {
            // 释放锁
            stringRedisTemplate.delete(Redis_lock);
        }
        return res;
    }

存在的问题:如果该节点在执行完“添加锁”的语句后宕机,其finally中的代码没有执行。那其他节点将永远无法获得锁。

为锁添加过期时间来解决

为key添加过期时间有两种:

  1. 通过expire 命令为key指定过期时间(setnx和expire命令分别执行,不具备原子性,仍会出现问题)
  2. 在setnx命令中直接给出key的过期时间(直接在setnx中完成两步操作,具有原子性,应采用这种方式)
    @RequestMapping("/secSkill2")
    public String secSkill2() {
        String res= "抢购失败!";
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, "lock", 5, TimeUnit.SECONDS);
        try {
            if (lock) {
                // 1. 判断库存是否充足
                String stockKey = "stock:0008";
                String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
                int stock = Integer.parseInt(stockStr);
                if (stock <= 0) {
                    res = "秒杀失败,库存不足";
                }else{
                    // 2. 扣减库存
                    stringRedisTemplate.opsForValue().decrement(stockKey);
                    res = "秒杀成功,剩余库存:" + (stock - 1);
                }
            }else{
                res = "没抢到锁哦!";
            }
        }finally {
            // 释放锁
            stringRedisTemplate.delete(Redis_lock);
        }
        return res;
    }

存在问题:代码中设置的过期时间是5秒,如果请求A 的处理的时间超过了5秒钟还没处理完,由于这个锁过期了,另一个请求B 通过setnx申请到锁,此时请求A刚好处理完,开始执行删除锁操作,就会把B申请的锁删掉。

此时其他请求申请锁,就会与B同时访问共享资源,很可能会引发数据不一致问题。

为锁添加标识

上述那种锁被误删的情况,主要是因为所有请求添加锁的value相同,应该是“谁加的锁,谁来删”。

解决办法:为每个请求生成一个uuid,使用这个uuid作为锁的value,只有请求的uuid一致时才可以删除。

实现代码如下:

    @RequestMapping("/secSkill3")
    public String secSkill3() {
        String uuid = java.util.UUID.randomUUID().toString();
        String res= "抢购失败!";
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, uuid, 5, TimeUnit.SECONDS);
        try {
            if (lock) {
                // 1. 判断库存是否充足
                String stockKey = "stock:0008";
                String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
                int stock = Integer.parseInt(stockStr);
                if (stock <= 0) {
                    res = "秒杀失败,库存不足";
                }else{
                    // 2. 扣减库存
                    stringRedisTemplate.opsForValue().decrement(stockKey);
                    res = "秒杀成功,剩余库存:" + (stock - 1);
                }
            }else{
                res = "没抢到锁哦!";
            }
        }finally {
            if(uuid.equals(stringRedisTemplate.opsForValue().get(Redis_lock))){
                // 释放锁
                stringRedisTemplate.delete(Redis_lock);
            }
        }
        return res;
    }

存在问题:在finally中对于删除锁的客户端身份的判断与删除操作是两个语句,不具有原子性,在并发场景下可能会出现问题。

比如:客户端a在节点A中加锁后,执行业务用了6秒,此时锁已过期,然后执行到finally中的判断,并判断结果为真,然后时间片到,暂停执行。

由于A的锁已过期,客户端b在节点B加锁成功,然后很快执行到了业务逻辑(未超过锁的过期时间),此时b的时间片到了。

此时A中的代码获得cpu,继续执行。那么A就会删掉B加的锁。这是很严重的问题。

添加Lua脚本解决

可以通过Lua脚本实现身份判断和删除锁操作的合并。

对Lua脚本执行。可通过eval命令来完成。

在RedisTemplate中没有eval命令对应的方法,而Jedis中有同名方法。

所以先获取到Jedis客户端,才能调用jedis.eval();

  1. 先导入Jedis依赖
      <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

实现代码如下:

   @Value("${spring.redis.host}")
    private String redisHost;
    @Value("${spring.redis.port}")
    private int redisPort;

    @RequestMapping("/secSkill4")
    public String secSkill4() {
        String uuid = java.util.UUID.randomUUID().toString();
        String res= "抢购失败!";
        Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, uuid, 5, TimeUnit.SECONDS);
        try {
            if (lock) {
                // 1. 判断库存是否充足
                String stockKey = "stock:0008";
                String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
                int stock = Integer.parseInt(stockStr);
                if (stock <= 0) {
                    res = "秒杀失败,库存不足";
                }else{
                    // 2. 扣减库存
                    stringRedisTemplate.opsForValue().decrement(stockKey);
                    res = "秒杀成功,剩余库存:" + (stock - 1);
                }
            }else{
                res = "没抢到锁哦!";
            }
        }finally {
            JedisPool jedisPool = new JedisPool(redisHost, redisPort);
            try(Jedis jedis = jedisPool.getResource()) {
                String scrippt = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                Object eval = jedis.eval(scrippt, Collections.singletonList(Redis_lock), Collections.singletonList(uuid));
                if ("1".equals(eval.toString())){
                    System.out.println("释放锁成功");
                }else{
                    System.out.println("释放锁失败");
                }
            }
        }
        return res;
    }

存在问题:请求a的过期,但是业务未执行完毕,请求b申请到锁,也在执行业务,如果此时这两个请求都修改了库存数据,就又出现数据不一致问题,在高并发场景下,问题会被无线放大。

解决方案:采用“锁续约”方式解决。

在当前业务进程开始执行时,fork一个子进程,用于开启一个定时任务。该定时任务的定时时间小于锁的过期时间,其会定时查看请求加的锁是否已被删除。如果被删除,就会结束子进程;如果未被删除,说明请求的业务还未执行完毕,子进程就将锁的时间重新设置为原过期时间。这种方式称为锁续约,也称为锁续命。

Redisson 可重入锁

可重入锁:同一个线程,可以多次获取同一把锁,不会阻塞、不会死锁,释放锁时也要释放多次,直到全部释放

使用Redisson 的可重入锁可以解决上述问题。

Redisson 使用Lua脚本实现了对可重入锁的添加、冲入、续约、释放。

Redisson 需要用户为锁指定一个key,无需为锁指定过期时间,因为它有默认过期时间30秒,当然也可以手动指定。

Redisson 会为该锁生成一个计数器,记录一个线程重入锁的次数。

那怎么识别是哪个线程加的锁呢?

这个锁存的value的类型是hash

其key 是锁名称;field是客户端唯一 ID + 线程 ID(标记是谁加的锁);value:是加锁次数(重入计数)

遇到加锁时,判断是当前线程,那么计数+1

释放锁时,每释放一次,计数-1,减到0时,才真正删除这个锁。

加锁次数必须和解锁次数一样,才会释放锁。

其依然使用Lua脚本保证其 身份判断和加锁/解锁操作 的原子性。

使用看门狗线程实现锁续约。

加锁成功后,后台开启看门狗线程。

每10秒自动延长锁过期时间,防止业务没执行完而锁被释放。

导入Redisson 依赖

 		<dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.5.0</version>
        </dependency>

修改启动类Application

在Application 中添加一个由单Redis节点构建的Redisson 的Bean。

@SpringBootApplication
public class SpringbootRedisApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootRedisApplication.class, args);
    }

    @Value("${spring.redis.host}")
    private String redisHost;
    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public Redisson redisson(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort).setDatabase(0);
        return (Redisson) Redisson.create(config);
    }

}

修改controller类

    @Autowired
    private Redisson redisson;
    
    @RequestMapping("/secSkill5")
    public String secSkill5() {
        String res= "抢购失败!";
        RLock rlock = redisson.getLock(Redis_lock);
        try {
            boolean lock = rlock.tryLock();
            if (lock) {
                // 1. 判断库存是否充足
                String stockKey = "stock:0008";
                String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
                int stock = Integer.parseInt(stockStr);
                if (stock <= 0) {
                    res = "秒杀失败,库存不足";
                }else{
                    // 2. 扣减库存
                    stringRedisTemplate.opsForValue().decrement(stockKey);
                    res = "秒杀成功,剩余库存:" + (stock - 1);
                }
            }else{
                res = "没抢到锁哦!";
            }
        }catch (Exception e) {
            System.out.println("发生异常了");
        } finally {
            rlock.unlock();
        }
        return res;
    }

存在问题:Redis单机情况下,以上代码没有问题;如果在Redis主从集群中,会存在锁丢失问题。

在Redis主从集群中,节点A为master,B、C为slave。

如果请求a处理请求时加锁,向节点A添加一个key,然后调用应用服务器Sa响应,然后向slave同步key。但是很不幸,同步还没开始,节点A宕机,节点B晋升为master。此时正好请求b请求服务器Sb响应。

由于Sa和Sb都加锁成功,他们同时对共享数据进行处理,这就由出现并发问题。该问题称为主从集群的锁丢失问题。

Redisson 红锁

Redisson 红锁可以防止主从集群锁丢失问题。

Redisson 红锁要求。必须构建除至少三个Redis主从集群。若一个请求要申请锁,必须向所有主从集群中提交key写入,当大多数集群锁写入成功后,该锁才算申请成功。

修改启动类Application

在启动类中添加三个Sentinel 集群构建的Redisson 的Bean。

    @Bean("redisson-1")
    public Redisson redisson1(){
        Config config = new Config();
        config.useSentinelServers().setMasterName("mymaster1").addSentinelAddress("redis:16380","redis:16381","redis:16382");
        return (Redisson) Redisson.create(config);
    }

    @Bean("redisson-2")
    public Redisson redisson2(){
        Config config = new Config();
        config.useSentinelServers().setMasterName("mymaster2").addSentinelAddress("redis:26380","redis:26381","redis:26382");
        return (Redisson) Redisson.create(config);
    }

    @Bean("redisson-3")
    public Redisson redisson3(){
        Config config = new Config();
        config.useSentinelServers().setMasterName("mymaster3").addSentinelAddress("redis:36380","redis:36381","redis:36382");
        return (Redisson) Redisson.create(config);
    }

修改controller类

@Resource(name = "redisson-1")
private Redisson redisson1;
@Resource(name = "redisson-2")
private Redisson redisson2;
@Resource(name = "redisson-3")
private Redisson redisson3;

@RequestMapping("/secSkill6")
public String secSkill6() {
    String res= "抢购失败!";
    RLock rlock1 = redisson1.getLock(Redis_lock+"_1");
    RLock rlock2 = redisson2.getLock(Redis_lock+"_2");
    RLock rlock3 = redisson3.getLock(Redis_lock+"_3");
    RLock rlock = new RedissonRedLock(rlock1,rlock2,rlock3);
    try {
        boolean lock = rlock.tryLock();
        if (lock) {
            // 1. 判断库存是否充足
            String stockKey = "stock:0008";
            String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
            int stock = Integer.parseInt(stockStr);
            if (stock <= 0) {
                res = "秒杀失败,库存不足";
            }else{
                // 2. 扣减库存
                stringRedisTemplate.opsForValue().decrement(stockKey);
                res = "秒杀成功,剩余库存:" + (stock - 1);
            }
        }else{
            res = "没抢到锁哦!";
        }
    }catch (Exception e) {
        System.out.println("发生异常了");
    } finally {
        rlock.unlock();
    }
    return res;
}

问题

无论哪种锁,他们解决变更发问题的思路都是相同的,就是将所有请求通过锁实现串行化,而这在高并发场景下势必会引发性能问题。

分段锁

分段锁的本质就是使访问并行化。将一个资源拆成多个资源,这样就将一把锁的需求变为多把锁,从而实现并行化。

例如:对于秒杀商品sk:0008,库存1000件,现将其拆分为10份,每份100件。

也就是将秒杀商品变为了10件,这样就能用10把锁控制请求并发,每个时刻可以同时处理10个请求。并发提高了10倍。

posted @ 2026-05-13 16:32  NE_STOP  阅读(23)  评论(0)    收藏  举报