分布式锁

随着业务发展、微服务化,一个应用需要部署到多台服务器上然后做负载均衡,由于分布式系统多线程、多进程并且分布在不同的机器上,这将原来的单机部署情况下的并发控制锁策略失效。分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。

分布式锁应该具备哪些条件

  • 互斥性:任意时刻,只有一个客户端能持有锁;
  • 高可用、高性能的获取锁与释放锁:加锁和解锁需要开销尽可能低,同时要保证高可用,避免分布式锁失效。
  • 安全性:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了;
  • 具备可重入特性;
  • 具备锁失效机制、防止死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;
  • 具备非阻塞锁特性,即没有获取到锁直接返回获取锁失败;

对于Redis还具有容错性,即只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。

:锁的可重入是指,当一个线程获得一个对象锁后,再次请求该对象锁时是可以获得该对象的锁的。

package com.wkcto.lock.reentrant;

/**
 *  演示锁的可重入性
 */
public class Test01 {
    public synchronized void sm1(){
        System.out.println("同步方法1");
        //线程执行sm1()方法,默认this作为锁对象,在sm1()方法中调用了sm2()方法,注意当前线程还是持有this锁对象的
        //sm2()同步方法默认的锁对象也是this对象, 要执行sm2()必须先获得this锁对象,当前this对象被当前线程持有,可以 再次获得this对象, 这就是锁的可重入性. 假设锁不可重入的话,可能会造成死锁
        sm2();
    }

    private synchronized void sm2() {
        System.out.println("同步方法2");
        sm3();
    }

    private synchronized void sm3() {
        System.out.println("同步方法3");
    }

    public static void main(String[] args) {
        Test01 obj = new Test01();
        new Thread(new Runnable() {
            @Override
            public void run() {
                obj.sm1();
            }
        }).start();
    }
}

分布式锁的实现方式

目前几乎所有大型网站及应用都是分布式部署,分布式场景中的数据一致性问题一直是一个比较重要的话题,分布式的CAP理论告诉我们任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。
一般情况下,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性,只要这个最终时间是在用户可以接受的范围内即可。
在很多时候,为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行
而分布式锁的具体实现方案有如下三种:

基于数据库实现分布式锁;
基于缓存(Redis等)实现分布式锁;
基于Zookeeper实现分布式锁;

以上尽管有三种方案,但是我们需要根据不同的业务进行选型。

基于数据库的实现方式

基于数据库的实现方式的思想核心为:
在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
一、创建一个表

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id`          INT(11) UNSIGNED NOT NULL AUTO_INCREMENT
  COMMENT '主键',
  `method_name` VARCHAR(64)      NOT NULL
  COMMENT '锁定的方法名',
  `desc`        VARCHAR(255)     NOT NULL
  COMMENT '备注信息',
  `update_time` TIMESTAMP        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
)
  ENGINE = InnoDB
  AUTO_INCREMENT = 3
  DEFAULT CHARSET = utf8
  COMMENT = '锁定中的方法';

二、想要执行某个方法,就使用这个方法名向表中插入数据

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

由于我们对method_name做了唯一性约束,如果有多个请求同时提交插入操作时,数据库能确保只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体中的内容。

三、执行完成后,删除对应的行数据释放锁

delete from method_lock where method_name ='methodName';

这里只是基于数据库实现的一种方法(比较粗的一种)。
但是对于分布式锁应该具备的条件来说,还有一些问题需要解决及优化

  • 因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能。数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。所以,数据库需要双机部署、数据同步、主备切换;
  • 它不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据。所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器线程相同,若相同则直接获取锁。
  • 没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁。也就是说,这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
  • 不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取;
  • 依赖数据库需要一定的资源开销,性能问题需要考虑;

基于缓存(Redis)的实现方式

使用Redis实现分布式锁的理由:

  • Redis具有很高的性能;
  • Redis的命令对此支持较好,实现起来很方便;

Redis命令介绍:
SETNX

// 当且仅当key不存在时,set一个key为val的字符串,返回1;
// 若key存在,则什么都不做,返回0。
SETNX key val;

expire

// 为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
expire key timeout;

delete

// 删除key
delete key;

我们通过Redis实现分布式锁时,主要通过上面的这三个命令。
通过Redis实现分布式的核心思想为:

  1. 获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间自动释放锁,锁的value值为一个随机生成的UUID,通过这个value值,在释放锁的时候进行判断。
  2. 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  3. 释放锁的时候,通过UUID判断是不是当前持有的锁,若是该锁,则执行delete进行锁释放。

方案一:使用Lua脚本(包含SETNX + EXPIRE两条指令)

使用Lua脚本来保证原子性(包含setnx和expire两条指令),lua脚本如下:

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then 
   redis.call('expire',KEYS[1],ARGV[2]) 
else 
   return 0 
end; 

加锁代码如下:

String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" + 
            " redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";    
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values)); 
//判断是否成功 
return result.equals(1L); 

为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

方案二:SET的扩展命令(SET EX PX NX)

除了使用,使用Lua脚本,保证SETNX + EXPIRE两条指令的原子性,我们还可以巧用Redis的SET指令扩展参数!(SET key value[EX seconds][PX milliseconds][NX|XX]),它也是原子性的(类似执行一个事务)!

SET key value[EX seconds][PX milliseconds][NX|XX]
  • NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
  • EX seconds :设定key的过期时间,时间单位是秒。
  • PX milliseconds: 设定key的过期时间,单位为毫秒
  • XX: 仅当key存在时设置值

伪代码demo如下:

if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1{ //加锁 
    try { 
        do something  //业务处理 
    }catch(){ 
  } 
  finally { 
       jedis.del(key_resource_id); //释放锁 
    } 
} 

但是呢,这个方案还是可能存在问题:

问题一:「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
问题二:「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。

方案三:SET EX PX NX + 校验唯一随机值,再删除

既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下,不就OK了嘛。伪代码如下:

if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1{ //加锁 
    try { 
        do something  //业务处理 
    }catch(){ 
  } 
  finally { 
       //判断是不是当前线程加的锁,是才释放 
       if (uni_request_id.equals(jedis.get(key_resource_id))) { 
        jedis.del(lockKey); //释放锁 
        } 
    } 
} 

在这里,「判断是不是当前线程加的锁」和「释放锁」不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
为了更严谨,一般也是用lua脚本代替。lua脚本如下:

if redis.call('get',KEYS[1]) == ARGV[1] then  
   return redis.call('del',KEYS[1])  
else 
   return 0 
end; 

上述实现可以避免释放另一个client创建的锁,如果只有 del 命令的话,那么如果 client1 拿到 lock1 之后因为某些操作阻塞了很长时间,此时 Redis 端 lock1 已经过期了并且已经被重新分配给了 client2,那么 client1 此时再去释放这把锁就会造成 client2 原本获取到的锁被 client1 无故释放了,但现在为每个 client 分配一个 unique 的 string 值可以避免这个问题。至于如何去生成这个 unique string,方法很多随意选择一种就行了。

方案四:Redisson框架

方案三还是可能存在「锁过期释放,业务没执行完」的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

当前开源框架Redisson解决了这个问题。我们一起来看下Redisson底层原理图吧:

在这里插入图片描述
只要线程一加锁成功,就会启动一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断地延长锁key的生存时间。因此,Redisson就是使用Redisson解决了「锁过期释放,业务没执行完」问题。

方案五:多机实现的分布式锁Redlock+Redisson

在这里插入图片描述
如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:

搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。

我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。

在这里插入图片描述
RedLock的实现步骤:如下

1.获取当前时间,以毫秒为单位。

2.按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。

3.客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)

如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。

如果获取锁失败(没有在至少N/2+1个master实例取到锁,又或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

简化下步骤就是:

  • 按顺序向5个master节点请求加锁
  • 根据设置的超时时间来判断,是不是要跳过该master节点。
  • 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
  • 如果获取锁失败,解锁!

性能、崩溃恢复和 fsync

如果我们的节点没有持久化机制,client 从 5 个 master 中的 3 个处获得了锁,然后其中一个重启了,这是注意 整个环境中又出现了 3 个 master 可供另一个 client 申请同一把锁! 违反了互斥性。如果我们开启了 AOF 持久化那么情况会稍微好转一些,因为 Redis 的过期机制是语义层面实现的,所以在 server 挂了的时候时间依旧在流逝,重启之后锁状态不会受到污染。但是考虑断电之后呢,AOF部分命令没来得及刷回磁盘直接丢失了,除非我们配置刷回策略为 fsnyc = always,但这会损伤性能。解决这个问题的方法是,当一个节点重启之后,我们规定在 max TTL 期间它是不可用的,这样它就不会干扰原本已经申请到的锁,等到它 crash 前的那部分锁都过期了,环境不存在历史锁了,那么再把这个节点加进来正常工作。

<think>好的,用户现在问的是如何使用“SET key value NX EX”实现分布式锁。之前他们已经了解了Redis的常见面试题,包括分布式锁的部分,现在可能想深入了解具体实现细节。 首先,我需要回忆一下Redis中SET命令的参数。NX表示只有当键不存在时才设置,EX设置过期时间。这两个参数结合起来可以确保在获取锁的时候避免竞态条件,同时通过过期时间防止死锁。 接下来,用户可能想知道具体的步骤,比如生成唯一值、设置锁、释放锁时的Lua脚本。需要详细说明每一步的作用,比如为什么要用唯一值,这是为了防止其他客户端误删锁。比如客户端A获取锁后,如果处理时间过长导致锁过期,客户端B获取了锁,此时A完成操作后可能会误删B的锁,所以需要验证值是否匹配。 然后,用户可能关心如何保证原子性。在释放锁的时候,使用Lua脚本是关键,因为Lua脚本在Redis中是原子执行的,这样判断和删除操作不会被打断,避免并发问题。 另外,要注意超时时间的设置,不能太长也不能太短,需要根据业务处理时间合理设定,否则可能导致锁过早释放或者资源浪费。自动续期的问题,比如Redisson的看门狗机制,可能也是用户想了解的,但问题中没有提到,可能需要简要提及。 用户可能的误区是认为只要用了NX和EX就足够,但实际上还需要处理唯一值和原子释放的问题。需要强调这些细节的重要性,以避免常见的错误。 最后,总结实现步骤和注意事项,确保用户理解整个流程,并知道如何应对可能的问题,比如锁未释放的情况。如果有余力,可以提到RedLock算法或现有库如Redisson,但根据问题范围,可能不需要展开,但作为最佳实践提一下会更好。</think>以下是使用 Redis 的 `SET key value NX EX` 命令实现分布式锁的核心步骤和原理,以及需要注意的关键细节: --- ### **1. 基本命令解析** ```bash SET key unique_value NX EX expire_time ``` - **`NX`**:仅当 Key 不存在时设置值,保证互斥性(锁竞争)。 - **`EX`**:设置 Key 的过期时间(单位:秒),避免死锁(即使客户端崩溃,锁也会自动释放)。 - **`unique_value`**:必须为唯一标识(如 UUID、客户端 ID + 线程 ID),用于安全释放锁。 --- ### **2. 实现步骤** #### **(1)获取锁** ```bash # 示例:尝试获取锁,设置唯一值和过期时间(如 30 秒) SET lock:order123 8a9d7c6b-xxxx-xxxx-xxxx-xxxxxxxxxxxx NX EX 30 ``` - **成功**:返回 `OK`,表示获取锁成功。 - **失败**:返回 `nil`,需等待或重试(可结合循环实现重试逻辑)。 #### **(2)执行业务逻辑** 获取锁后,执行需要保护的临界区代码(如修改共享资源)。 #### **3. 释放锁** 通过 **Lua 脚本**保证原子性操作(判断值是否匹配再删除): ```lua if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end ``` - **KEYS[1]**: 锁的 Key(如 `lock:order123`)。 - **ARGV[1]**: 唯一标识(必须与设置时一致)。 --- ### **关键注意事项** 1. **唯一值(unique_value)的作用** - 防止误删其他客户端的锁。例如: - 客户端 A 获取锁后因阻塞导致锁过期,客户端 B 获取锁。 - 若 A 直接调用 `DEL`,会误删 B 的锁。 - 通过唯一值校验,确保只有锁的持有者能释放锁。 2. **过期时间(EX)的合理设置** - 时间过长:业务崩溃时资源长时间不可用。 - 时间过短:业务未完成锁已失效,导致并发问题。 - **建议**:根据业务处理时间动态调整,或结合自动续期(如 Redisson 的看门狗机制)。 3. **原子性释放锁** 必须使用 Lua 脚本,避免以下问题: ```bash GET lock:order123 # 客户端 A 检查值 DEL lock:order123 # 此时锁可能已过期并被客户端 B 获取,导致误删 ``` 4. **锁未释放的兜底方案** - 即使设置过期时间,也需尽量在业务完成后主动释放锁。 - 超时时间应覆盖业务最大可能执行时间(需权衡业务场景)。 --- ### **完整流程示例** ```python import redis import uuid client = redis.Redis() lock_key = "lock:order123" unique_id = str(uuid.uuid4()) # 尝试获取锁 acquired = client.set(lock_key, unique_id, nx=True, ex=30) if acquired: try: # 执行业务逻辑... print("Do something...") finally: # 释放锁(Lua 脚本保证原子性) script = """ if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end """ client.eval(script, 1, lock_key, unique_id) else: print("获取锁失败,等待重试...") ``` --- ### **优化与扩展** 1. **重试机制** 可结合指数退避算法(Exponential Backoff)实现重试,避免频繁请求。 2. **高可用场景** - 单节点 Redis 存在单点故障风险,可改用 **RedLock 算法**(多节点部署)增强可靠性。 - 推荐直接使用成熟客户端库(如 Java 的 **Redisson**、Python 的 **redis-py** 扩展)。 3. **锁自动续期** 对于长任务,可启动后台线程定期续期锁(如 Redisson 的 `watchdog` 机制)。 --- 通过以上方法,`SET key value NX EX` 能实现一个简单但可靠的分布式锁,适用于大多数并发控制场景。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值