Redis 分布式锁

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: Redis 分布式锁

分布式锁

基本原理

  • 说分布式锁之前我们先来说一下 synchronized,synchronized 利用 JVM 内部的锁监视器来控制线程,由此可以在 JVM 内部可以实现线程间的互斥
  • 但是,当有多个 JVM 的时候,就会有多个锁监视器,就会有多个线程获取到锁,这样就无法实现多 JVM 进程之前的互斥
  • 要解决这个问题,就要让多个 JVM 使用同一个锁监视器,这个锁监视器一定是在 JVM 内部,多 JVM 进程都可以看到的这么一个锁监视器。因此,这时无论是 JVM 内部的,还是多 JVM 的线程都应该来这个锁监视器来获取锁,这样就会只有一个线程获取锁,就能够实现多进程之间的互斥
  • 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
  • 分布式锁的特性

    • 多进程可见: 多个 JVM 进程看到同一个锁监视器
    • 互斥:只有一个进程能拿到线程锁
    • 高可用
    • 高性能(高并发)
    • 安全性
    • ...

分布式锁的实现

  • 常见的有三种
MySQL Redis Zookeeper
互斥 利用MySQL本身的互斥锁机制 利用 setnx 这样的互斥命令 利用节点的唯一性和有序性实现互斥
高可用
高性能 一般 一般
安全性 断开连接,自动释放锁 利用锁超时时间,到期释放 临时节点,断开连接自动释放

基于 Redis 的分布式锁

  • 获取锁

    • 互斥:确保只能有一个线程获取锁(setnx 命令)
    • 添加超时时间(expire)
    • 原子性:获取锁和添加超时时间同时进行(set key value ex 10 nx
    • 非阻塞:尝试一次,成功返回 true,失败返回 false
  • 释放锁

    • 手动释放(del 命令)
    • 超时释放:获取锁时添加一个超时时间

简单实现 Redis 分布式锁

  • ILook.java
package com.hmdp.utils;

/**
 * Redis 分布式锁接口
 */
public interface ILock {

    /**
     * 尝试获取锁
     *
     * @param timeoutSec 锁持有的超时时间,获取后自动释放
     * @return true代表获取锁成功;false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();
}
  • SimpleRedisLock.java
package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        long threadId = Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        // 避免拆箱空指针风险
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 释放锁
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

分布式锁误删问题

  • 我们来看下面一种极端情况
  • 线程1获取到锁后去执行自己的业务,但由于某些原因业务被阻塞, 在线程1业务阻塞期间锁被超时释放,这时线程2来获取到锁去执行自己的业务,在线程2执行业务期间,线程1被唤醒也继续执行业务,线程1执行完业务后会去释放锁(相当于把线程2得到的锁给释放掉了),这时线程3来获取锁,由于线程1将锁释放掉了,线程3可以得到锁,得到锁后也去执行自己的业务,此时,线程2和线程3的业务就在并发执行,这就可能会引发线程安全
  • 发生这种情况归根结底是线程1把别人的锁(线程2)给释放掉了,如果线程1在释放锁之前能够判断一下是否是自己的锁,那么问题就能够得到解决

  • 因此,我们的业务流程也应该发生变化

改进 Redis 分布式锁

  • 在获取锁时存入线程标识(可以用UUID表示)
  • 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致

    • 如果一致则释放锁
    • 如果不一致则不释放锁

为什么不用线程id?

  • 线程id就是一串递增的数字,在 JVM 内部,每创建一个线程数字就会递增
  • 如果是在集群的模式下,每个 JVM 内部都会维护这样一个递增的数字,这样就很有可能出现线程 id 冲突的情况
  • 因此我们可以使用 UUID+线程id 确保不同以及相同线程标识一定不同
package com.hmdp.utils;

import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        // 避免拆箱空指针风险
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 判断标识是否一致
        if (threadId.equals(id)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

分布式锁的原子性问题

  • 我们再来看下面一种情况
  • 线程1获取到锁后去执行自己的业务,执行完成后判断锁标识一致通过,当要释放锁的时候被阻塞(eg:JVM垃圾回收);这时线程2获取到锁去执行业务,在这个期间,线程1被唤醒,线程1业务执行完直接去释放锁,因为前面已经判断过标识,线程1这里直接将线程2的锁给释放掉了;线程2执行业务期间线程3又来获取锁,线程3得到锁后去执行业务,此时线程2和线程3的业务就并发执行了,这就可能会引发线程安全
  • 出现这个问题的主要原因就是判断锁标识和释放锁是两个动作,这两个动作之间出了问题,要想避免这个问题的发生,我们必须确保判断锁标识的动作和释放锁的动作组成一个原子性的操作

Lua 脚本解决多条命令原子性问题

  • Lua 脚本:在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性
  • Redis 的调用函数
# 执行redis 命令
redis.call('命令名称', 'key', '其他参数', ...)

# eg: 执行 set name ruochen
redis.call('set', 'name', 'ruochen')

# eg: 先执行 set name ruochen, 再执行 get name
redis.call('set', 'name', 'ruochen')
local name = redis.call('get', 'name')
return name
  • Redis 调用脚本命令

# 调用脚本 (0:key类型参数数量)
EVAL "return redis.call('set', 'name', 'ruochen')" 0
# key 类型参数会放到KEYS数组,其他参数会放到ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数(数组角标从1开始)
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name ruochen
  • Lua 脚本编写释放锁流程(unlock.lua
-- 获取锁中线程标识,比较线程标识与锁中的标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0
  • Java 调用 Lua 脚本
package com.hmdp.utils;

import cn.hutool.core.lang.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.Collections;
import java.util.concurrent.TimeUnit;

public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        // 避免拆箱空指针风险
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 调用Lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
}

总结

  • 基于Redis的分布式锁实现思路:

    • 利用 set nx ex 获取锁,并设置过期时间,保存线程标识
    • 释放锁时先判断线程标识是否与自己一致,一致则删除锁,且使用 Lua 脚本保证原子性
  • 特性

    • 利用 set ng 满足互斥性
    • 利用 set ex 保证故障时锁依然能释放,避免死锁,提高安全性
    • 利用 Redis 集群保证高可用和高并发特性
目前为止已经是一个相对完善的分布式锁了,但是它仍然有进步的空间
相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
1月前
|
NoSQL Java Redis
太惨痛: Redis 分布式锁 5个大坑,又大又深, 如何才能 避开 ?
Redis分布式锁在高并发场景下是重要的技术手段,但其实现过程中常遇到五大深坑:**原子性问题**、**连接耗尽问题**、**锁过期问题**、**锁失效问题**以及**锁分段问题**。这些问题不仅影响系统的稳定性和性能,还可能导致数据不一致。尼恩在实际项目中总结了这些坑,并提供了详细的解决方案,包括使用Lua脚本保证原子性、设置合理的锁过期时间和使用看门狗机制、以及通过锁分段提升性能。这些经验和技巧对面试和实际开发都有很大帮助,值得深入学习和实践。
太惨痛: Redis 分布式锁 5个大坑,又大又深, 如何才能 避开 ?
|
13天前
|
NoSQL Redis
Redis分布式锁如何实现 ?
Redis分布式锁通过SETNX指令实现,确保仅在键不存在时设置值。此机制用于控制多个线程对共享资源的访问,避免并发冲突。然而,实际应用中需解决死锁、锁超时、归一化、可重入及阻塞等问题,以确保系统的稳定性和可靠性。解决方案包括设置锁超时、引入Watch Dog机制、使用ThreadLocal绑定加解锁操作、实现计数器支持可重入锁以及采用自旋锁思想处理阻塞请求。
48 16
|
1月前
|
缓存 NoSQL Java
大数据-50 Redis 分布式锁 乐观锁 Watch SETNX Lua Redisson分布式锁 Java实现分布式锁
大数据-50 Redis 分布式锁 乐观锁 Watch SETNX Lua Redisson分布式锁 Java实现分布式锁
61 3
大数据-50 Redis 分布式锁 乐观锁 Watch SETNX Lua Redisson分布式锁 Java实现分布式锁
|
1月前
|
NoSQL Redis 数据库
计数器 分布式锁 redis实现
【10月更文挑战第5天】
49 1
|
1月前
|
NoSQL 算法 关系型数据库
Redis分布式锁
【10月更文挑战第1天】分布式锁用于在多进程环境中保护共享资源,防止并发冲突。通常借助外部系统如Redis或Zookeeper实现。通过`SETNX`命令加锁,并设置过期时间防止死锁。为避免误删他人锁,加锁时附带唯一标识,解锁前验证。面对锁提前过期的问题,可使用守护线程自动续期。在Redis集群中,需考虑主从同步延迟导致的锁丢失问题,Redlock算法可提高锁的可靠性。
80 4
|
1月前
|
缓存 NoSQL Ubuntu
大数据-39 Redis 高并发分布式缓存 Ubuntu源码编译安装 云服务器 启动并测试 redis-server redis-cli
大数据-39 Redis 高并发分布式缓存 Ubuntu源码编译安装 云服务器 启动并测试 redis-server redis-cli
56 3
|
1月前
|
缓存 NoSQL 算法
面试题:Redis如何实现分布式锁!
面试题:Redis如何实现分布式锁!
|
NoSQL Redis 数据库
用redis实现分布式锁时容易踩的5个坑
云栖号资讯:【点击查看更多行业资讯】在这里您可以找到不同行业的第一手的上云资讯,还在等什么,快来! 近有不少小伙伴投入短视频赛道,也出现不少第三方数据商,为大家提供抖音爬虫数据。 小伙伴们有没有好奇过,这些数据是如何获取的,普通技术小白能否也拥有自己的抖音爬虫呢? 本文会全面解密抖音爬虫的幕后原理,不需要任何编程知识,还请耐心阅读。
用redis实现分布式锁时容易踩的5个坑
|
NoSQL Java 关系型数据库
浅谈Redis实现分布式锁
浅谈Redis实现分布式锁
|
存储 canal 缓存