一网打尽分布式锁

随着企业业务复杂度提升,微服务架构逐渐成为系统解耦、提升迭代效率的核心方案。在单体应用拆分为多个独立部署微服务后,原本通过本地锁实现资源互斥访问机制失效。例如,跨服务事务操作可能因为服务间调用延迟或并发冲突导致数据不一致,典型场景包括库存超卖、订单重复支付等。此时,分布式锁应运而生,成为协调多节点并发访问共享资源的关键技术。

常见分布式锁实现方案可以分为三大类,主要包括基于数据库(比如MySQL)、基于缓存(比如Redis)和基于分布式协调服务组件(比如ZooKeeper、etcd)。

01 分布式锁衡量维度

特点 描述
互斥性(必选) 同一时刻仅有一个线程持有锁,防止并发访问共享资源
安全性(必选) 只有持有当前锁线程才能删除锁,防止误删
锁超时【死锁】(必选) 设置过期时间、租约机制实现锁失效,防止应用崩溃锁无法释放
阻塞锁(可选) 当前资源已加锁,其他线程获取锁失败是阻塞等待,还是立即返回
可重入(可选) 当前锁持有者是否能够重复获取同一锁,避免递归调用或重复操作导致死锁
公平性(可选) 是否提供公平锁、非公平锁选择,公平锁确保锁饥饿问题,非公平锁提升系统吞吐量
高可用与容错性(可选) 使用集群模式避免单点故障,支持部分节点宕机仍能正常运行
高性能与低延迟(可选) 获取/释放锁的操作需高效,减少业务影响。

02 基于数据库实现分布式锁

基于数据库实现分布式锁也有两种方式,一种是基于数据库增删记录,另一种是基于数据库排他锁。

2.1 基于数据库增删

MySQL数据库实现分布式锁,最简单方式就是直接创建一张分布式锁表,插入数据实现加锁,删除数据实现释放锁。附加过期时间判断锁是否失效,使用锁持有者判断当前加锁应用。此外,单实例数据库TPS一般为几千,不能提供高并发量支持。

sql 复制代码
create table distributed_lock (
    lock_key    varchar(255) primary key, -- 锁唯一标识
    lock_owner  varchar(255),             -- 锁持有者
    expire_time timestamp                 -- 锁过期时间
);

-- 获取锁
insert into distributed_lock (lock_key, lock_owner, expire_time) values (?, ?, ?);

-- 释放锁
delete from distributed_lock where lock_key=? and lock_owner=?

2.2 基于数据库排他锁

MySQL提供for update语句添加查询排它锁,其他线程需要获取该行数据就需要等待锁释放,或者如果持有锁线程应用崩溃也会自动释放锁。排他锁存在一些缺陷,首先会直接占用数据库连接,可能导致数据库连接爆满问题。另外,单实例数据库TPS一般为几千,不能提供高并发量支持。

java 复制代码
create table distributed_lock (
    lock_key    varchar(255) primary key, -- 锁唯一标识
    lock_owner  varchar(255),             -- 锁持有者
    expire_time timestamp                 -- 锁过期时间
);
java 复制代码
// 加锁
def lock:
    // 开启事务
    connection.setAutoCommit(false)
    // 获取锁
    result = select * from distributed_lock where lock_key=? and lock_owner=? for update
    // 结果非空
    if (result ≠ null):
        return true
    // 插入锁数据
    insert into distributed_lock (lock_key, lock_owner, expire_time) values (?, ?, ?)
    return true
​
// 解锁
def unlock:
    // 提交事务
    connection.commit()

03 基于分布式协调组件实现分布式锁

由于Zookeeper临时顺序节点(EPHEMERAL_SEQUENTIAL)和监听机制,能够实现资源互斥访问与状态监听,从而提供分布式锁可靠支持。ZK所有节点都具备顺序发号器,能够实现节点序号递增,保证节点顺序性。顺序节点递增有序性能够确保加锁公平性。节点提供监听机制,能够高效唤醒队列获取锁,避免服务器宕机导致死锁问题。

ZK实现分布式锁主要包括加锁和释放锁阶段。加锁阶段应用服务仅需创建ZK临时有序节点,并且监听父节点变化,判断当前节点是否最小序号即可完成加锁。释放锁仅需删除当前节点,即可完成锁释放。

ZK实现分布式锁也存在两个方面缺点。缺点一,Master两阶段提交创建、销毁临时节点性能没有那么高。缺点二,如果存在网络抖动或者长时间Full GC断开会话,删除临时节点导致并发执行。

04 基于缓存实现分布式锁

Redis具备单线程内存高性能操作、多数据类型、复杂指令操作、Lua脚本原子操作、集群与主从高可用部署能力,使得成为缓存最佳首选,也是成为分布式锁最优选择方案。

4.1 基于setnx+expire实现

Redis命令 说明
setnx key val 键不存在设置值
expire key seconds 设置键多少秒后过期
Redis命令setnx不支持携带过期时间,所以需要配合expire命令设置过期时间才能避免死锁问题。但是,两条命令分步执行属于非原子操作,服务器没有执行expire命令发生宕机,就会导致死锁问题。
java 复制代码
if(jedis.setnx(locKey, lockValue) == 1) { // 加锁
    jedis.expire(locKey, lockSeconds); // 设置过期时间
    try {
        // 业务请求
    }catch(Exception e){
        // 异常处理
    }
    finally {
        jedis.del(locKey); // 释放锁
    }
}

4.2 基于setnx+过期值实现

为了解决setnx + expire非原子操作,加锁值使用过期值(过期值=系统时间+过期时间 )代替,加锁成功条件就是加锁值小于当前时间戳。 方案巧妙移除过期时间操作,但是计算键值过期与使用getset命令替换旧值也非原子操作,会导致多客户端并发抢锁成功问题,违反分布式锁**「*** 互斥性***」**原则。另外,如果采用主从架构实现读写分离,主从同步延迟读取旧数据,也会导致多客户端并发加锁问题,可见分布式锁不能采用读写分离方案。

Redis命令 说明
setnx key val 键不存在设置值
getset key val 设置指定键值,并返回键旧值
java 复制代码
public boolean tryLock(String lockKey, String lockValue, Long timeout) {
    // 系统时间 + 过期时间
    long expireMills = System.currentTimeMillis() + timeoutMills; 
    String expireValue = String.valueOf(expireMills);

    // 如果当前锁不存在, 返回加锁成功
    if (jedis.setnx(lockKey, expireValue) == 1) {
        return true;
    } 
    // 如果锁已经存在, 获取锁过期时间
    String currentExpireValue = jedis.get(lockKey);
    // 过期=缓存过期时间<=系统当前时间
    if (currentExpireValue != null && Long.parseLong(currentExpireValue) < System.currentTimeMillis()) {
        // 锁已过期, 获取上一个锁过期时间, 并设置现在锁过期时间
        String oldExpireValue = jedis.getSet(lockKey, expireValue);
            
        // 考虑多线程并发情况,只有一个线程设置值和当前值相同, 它才可以加锁
        if (oldExpireValue != null && oldExpireValue.equals(currentExpireValue)) {
            return true;
        }
    }

    // 其他情况, 均返回加锁失败
    return false;
}

4.3 基于Lua脚本实现

Redis Lua脚本支持多命令操作原子性,解决sexnx + expire无法正常设置超时时间问题。方案满足分布式锁 「 互斥性」、「锁超时」 特性,但是也无法清晰评估锁需要设置超时时间。 如果超时时间设置过短,分布式锁提前过期会导致多线程并行执行,也会导致多线程非安全释放锁。另一方面,如果超时时间设置过长,此时服务宕机导致其他线程过久等待锁释放。

java 复制代码
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
    return redis.call('expire',KEYS[1],ARGV[2])
else
    return 0
end;
java 复制代码
public boolean tryLock(String lockKey, String lockValue, Long timeout) {
    String lock_script = "...";
    List<String> keys = Lists.newArrayList(lockKey);
    List<String> args = Lists.newArrayList(lockValue, timeout.toString());
    Object res = jedis.eval(lock_script, keys, args);
    // 判断是否成功
    return result.equals(1L);
}

public boolean releaseLock(String lockKey) {
    jedis.del(lockKey);
}

4.4 基于set扩展指令实现

Redis基础命令set提供扩展指令实现超时时间、是否存在原子性操作,简化setnx与expire命令带来的复杂问题,同样也存在设置超时时间时长带来的额外问题。

Redis命令 说明
set key val [ex seconds] [px milliseconds] [NX XX]
del key 删除键
java 复制代码
SetParams externParams = SetParams.setParams().ex(timeout).nx();
String result = jedis.set(locKey, lockValue, externParams);
if(StringUtils.equals(result, "1")) { // 加锁成功
    try {
        // 业务请求
    } catch(Exception e){
        // 异常处理
    } finally {
        jedis.del(locKey); // 释放锁
    }
}

4.5 基于set扩展指令+身份校验实现

为了解决超时时间设置过短导致多线程非安全问题,引入身份识别加强所释放安全性。核心技术方案就是,加锁期间生成随机数标识线程身份,Lua脚本释放锁携带身份校验通过是否一致再进行删除。

参数 参数值
KEYS[1] 分布式锁
ARGV[1] 身份唯一标识=客户端ID(UUID) + 线程ID
bash 复制代码
if redis.call("get",KEYS[1]) == ARGV[1] then
	return redis.call('del',KEYS[1])
else
	return 0
end;
java 复制代码
public boolean tryLock(String lockKey, String lockValue, Long timeout) {
    String lock_script = "...";
    List<String> keys = Lists.newArrayList(lockKey);
    List<String> args = Lists.newArrayList(lockValue, timeout.toString());
    Object res = jedis.eval(lock_script, keys, args);
    // 判断是否成功
    return result.equals(1L);
}

public boolean releaseLock(String lockKey, String lockValue) {
    String lock_script = "...";
    List<String> keys = Lists.newArrayList(lockKey);
    List<String> args = Lists.newArrayList(lockValue);
    jedis.eval(lock_script, keys, args);
    return true;
}

4.6 基于看门狗实现

为了解决超时时间引起「锁过期释放,业务没执行完」问题,使用守护线程 (看门狗) 定期续期持有锁超时时长。也就是定期检查锁是否还存在,如果存在续期锁过期时长,防止锁过期提前释放。看门狗属于单机加锁方案,如果加锁成功未来得及同步到从节点,此时主节点突然宕机就会导致其他线程加锁成功,违背分布式锁 「互斥性」 原则。

4.6.1 RedissonLock使用案例

java 复制代码
// 1. 构造Redisson实现分布式锁配置
Config conf = new Config();
conf.useSingleServer().setAddress("redis://dns:5379").setPassword("123").setDatabase(0);

// 2. 构造RedissonClient
RedissonClient redissonClient = Redisson.create(conf);
// 3. 获取锁对象实例
RLock lock = redissonClient.getLock(lockKey);
try {
    // 4. 尝试获取锁
    //    waitTimeout: 尝试获取锁最大等待时间, 超过获取锁失败
    //    leaseTime: 锁持有时间, 超过锁自动失效
    boolean res = lock.tryLock(waitTimeout, leaseTime, TimeUnit.SECONDS);
    if (res) {
        // 处理业务
    }
} catch (Exception e) {
    throw new RuntimeException("aquire lock fail");
} finally{
    // 释放锁
    rLock.unlock();
}

4.6.2 加锁

Redisson底层核心采用哈希表(Hash)存储分布式锁内容,哈希表字段设置为持有锁身份标识,字段值代表锁已达到重入次数。加锁或者锁重入期间,都会主动为分布式锁进行续期。加锁失败会返回等待抢锁时间,避免陷入无限抢锁环节。 如果加锁成功,还需使用Redis订阅发布功能,订阅频道redisson_lock__channel:{lockKey}消息,目的是收到其他锁释放消息进行抢锁操作。

Redis命令 说明
exists key 检查键是否存在
hset key field value 赋值哈希表字段值,键不存在返回1,否则返回0
pexpire key milliseconds 设置键过期时间(毫秒)
hincrby key field increment 增量操作哈希表字段值
pttl key 获取指定键剩余生存时间(毫秒)
subscribe channel 订阅频道消息
java 复制代码
分布式锁={身份标识: 重入次数}
参数 参数值
KEYS[1] 分布式锁
ARGV[1] 过期时间
ARGV[2] 身份标识=redisson客户端ID(UUID) + 线程ID
bash 复制代码
-- 若锁不存在:新增锁设置锁重入计数为1, 设置锁过期时间
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

-- 若锁存在且唯一标识也匹配:当前请求为锁重入请求, 锁重入计数+1, 再次设置锁过期时间
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

-- 若锁存在但唯一标识不匹配: 锁是被其他线程占用, 当前线程无权解他人锁, 直接返回锁剩余过期时间
return redis.call('pttl', KEYS[1]);

4.6.3 释放锁

Redisson释放锁会验证身份信息,保证持有锁线程操作。于此同时,还会发布解锁消息(0),其他订阅线程就会收到消息抢锁,保证线程及时进行抢锁操作。

参数 参数值
KEYS[1] 分布式锁键
KEYS[2] 解锁消息PubSub频道,redisson_lock__channel:{lockKey}
ARGV[1] 解锁订阅消息功能编码,0=解锁消息
ARGV[2] 锁过期时间,默认值30秒
ARGV[3] 唯一标识=redisson客户端ID(UUID) + 线程ID
java 复制代码
-- 若锁不存在: 直接广播解锁消息, 返回1
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1; 
end;
 
-- 若锁存在但唯一标识不匹配: 锁被其他线程占用, 当前线程不允许解锁其他线程持有锁
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;
end; 
 
-- 若锁存在且唯一标识匹配: 锁重入计数减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then 
    -- 锁重入数减大于0: 当前线程持有锁还有重入, 不能进行锁删除操作, 直接续期操作
    redis.call('pexpire', KEYS[1], ARGV[2]); 
    return 0; 
else 
    -- 锁重入计数为0: 锁已释放, 直接删除掉锁, 并广播解锁消息, 唤醒阻塞线程争抢锁
    redis.call('del', KEYS[1]); 
    redis.call('publish', KEYS[2], ARGV[1]); 
    return 1;
end;

return nil;

4.6.4 RedissonLock源码

java 复制代码
public class RedissonLock extends RedissonBaseLock {
    @Override
    public RFuture<Boolean> tryLockAsync(long waitTime, long leaseTime, TimeUnit unit, long currentThreadId) {
        CompletableFuture<Boolean> result = new CompletableFuture<>();
        AtomicLong time = new AtomicLong(unit.toMillis(waitTime));
        long currentTime = System.currentTimeMillis();
        // Lua脚本加锁
        RFuture<Long> ttlFuture = tryAcquireAsync0(waitTime, leaseTime, unit, currentThreadId);
        ttlFuture.whenComplete((ttl, e) -> {
            if (e != null) {
                result.completeExceptionally(e);
                return;
            }
            // lock acquired
            if (ttl == null) {
                if (!result.complete(true)) {
                    unlockAsync(currentThreadId);
                }
                return;
            }
            long el = System.currentTimeMillis() - currentTime;
            time.addAndGet(-el);
            
            if (time.get() <= 0) {
                trySuccessFalse(currentThreadId, result);
                return;
            }
            
            long current = System.currentTimeMillis();
            AtomicReference<Timeout> futureRef = new AtomicReference<>();
            // 订阅频道
            CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(currentThreadId);
            pubSub.timeout(subscribeFuture, time.get());
            subscribeFuture.whenComplete((r, ex) -> {
                if (ex != null) {
                    result.completeExceptionally(ex);
                    return;
                }
                if (futureRef.get() != null) {
                    futureRef.get().cancel();
                }
                long elapsed = System.currentTimeMillis() - current;
                time.addAndGet(-elapsed);
                
                tryLockAsync(time, waitTime, leaseTime, unit, r, result, currentThreadId);
            });
            if (!subscribeFuture.isDone()) {
                Timeout scheduledFuture = getServiceManager().newTimeout(timeout -> {
                    if (!subscribeFuture.isDone()) {
                        subscribeFuture.cancel(false);
                        trySuccessFalse(currentThreadId, result);
                    }
                }, time.get(), TimeUnit.MILLISECONDS);
                futureRef.set(scheduledFuture);
            }
        });
        return new CompletableFutureWrapper<>(result);
    }
}

4.7 红锁

红锁底层使用RedissonLock看门狗续期操作,主要核心是增加多节点保证锁可用性。但是,引入多节点操作也会带来一些问题,首先多节点操作会增加加锁耗时和资源消耗。其次,因为网络原因部分锁释放失败,可能导致长时间无法使用。最后,红锁要求半数节点加锁成功,而多锁要求全部节点,如果其中一个Redisson Node不可用就会导致不可用。

序号 步骤
1 顺序向N个RedissonNode请求获取锁,如果超过获取锁时间解锁返回失败,否则跳过请求下一个获取
2 如果获取锁数量达到半数(N/2 + 1),那么达到获取锁要求,不再请求下一个获取
3 设置锁使用时间,返回成功

4.7.1 RedLock使用案例

java 复制代码
Config conf1 = new Config();
conf.useSingleServer().setAddress("redis://dns:5378").setPassword("123").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(conf);
 
Config conf2 = new Config();
conf2.useSingleServer().setAddress("redis://dns:5379").setPassword("123").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(conf2);
 
Config conf3 = new Config();
conf3.useSingleServer().setAddress("redis://dns:5380").setPassword("123").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(conf3);
 
// 获取多个RLock对象
RLock lock1 = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);
 
// 根据多个RLock对象构建RedissonRedLock
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);

try {
    // 4. 尝试获取锁
    //    waitTimeout: 尝试获取锁最大等待时间, 超过获取锁失败
    //    leaseTime: 锁持有时间, 超过锁自动失效
    boolean res = redLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS);
    if (res) {
        // 处理业务
    }
} catch (Exception e) {
    throw new RuntimeException("aquire lock fail");
}finally{
    // 释放锁
    redLock.unlock();
}

4.7.2 RedissonRedLock源码

java 复制代码
public class RedissonMultiLock implements RLock {
    final List<RLock> locks = new ArrayList<>();
    public RedissonMultiLock(RLock... locks) {
        if (locks.length == 0) {
            throw new IllegalArgumentException("Lock objects are not defined");
        }
        this.locks.addAll(Arrays.asList(locks));
    }
    /**
     * 加锁
     * @param waitTime  尝试获取锁最大等待时间, 超过获取锁失败
     * @param leaseTime 锁持有时间, 超过锁自动失效
     */
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
        long newLeaseTime = -1;
        if (leaseTime != -1) {
            newLeaseTime = unit.toMillis(waitTime) * 2;
        }
        long time = System.currentTimeMillis();
        // 锁剩余使用时间
        long remainTime = -1;
        if (waitTime != -1) {
            remainTime = unit.toMillis(waitTime);
        }
        // 加锁等待时间
        long lockWaitTime = calcLockWaitTime(remainTime);
        // 1. 允许加锁失败节点个数限制 = N-(N/2+1)
        int failedLocksLimit = failedLocksLimit();
        // 2. 遍历所有节点通过EVAL命令执行Lua加锁
        List<RLock> acquiredLocks = new ArrayList<>(locks.size());
        for (ListIterator<RLock> iterator = locks.listIterator(); iterator.hasNext();) {
            RLock lock = iterator.next();
            boolean lockAcquired;
            // 3. 尝试加锁
            try {
                // 没有时间限制, 直接尝试加锁
                if (waitTime == -1 && leaseTime == -1) {
                    lockAcquired = lock.tryLock();
                } else {
                    // 设置等待加锁时间
                    long awaitTime = Math.min(lockWaitTime, remainTime);
                    lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS);
                }
            } catch (RedisResponseTimeoutException e) {
                // 超过加锁最大等待时间, 解锁所有节点
                unlockInner(Arrays.asList(lock));
                lockAcquired = false;
            } catch (Exception e) {
                // 抛出异常表示获取锁失败
                lockAcquired = false;
            }
            if (lockAcquired) {
                // 4. 获取锁则添加到已获取锁集合
                acquiredLocks.add(lock);
            } else {
                // 5. 是否已经达到需要加锁数量= N-(N/2+1), 达到就暂停其他节点加锁
                if (locks.size() - acquiredLocks.size() == failedLocksLimit()) {
                    break;
                }
                // 不允许失败
                if (failedLocksLimit == 0) {
                    unlockInner(acquiredLocks);
                    if (waitTime == -1 && leaseTime == -1) {
                        return false;
                    }
                    failedLocksLimit = failedLocksLimit();
                    acquiredLocks.clear();
                    // reset iterator
                    while (iterator.hasPrevious()) {
                        iterator.previous();
                    }
                } else {
                    failedLocksLimit--;
                }
            }
            // 6. 计算加锁耗时是否超过限制
            if (remainTime != -1) {
                remainTime -= System.currentTimeMillis() - time;
                time = System.currentTimeMillis();
                if (remainTime <= 0) {
                    unlockInner(acquiredLocks);
                    return false;
                }
            }
        }
        // 7. 统一设置锁持有时间
        if (leaseTime != -1) {
            acquiredLocks.stream()
            .map(l -> (RedissonBaseLock) l)
            .map(l -> l.expireAsync(unit.toMillis(leaseTime), TimeUnit.MILLISECONDS))
                .forEach(f -> f.toCompletableFuture().join());
        }
        // 8. 如果逻辑正常执行完则认为最终申请锁成功, 返回true
        return true;
    }
}

05 思考与总结

实际需要结合业务场景进行选型,高并发场景首选Redis,强一致性场景适用Zookeeper,数据库方案仅作为备选方案。另外,Redis维度建议采用看门狗分布式锁+非读写分离集群/主从架构,结合数据库事务处理达到业务数据一致性。

特性 Redis Zookeeper 数据库
互斥性 原子性(Lua脚本) 临时节点唯一性 唯一索引/行锁
可重入性 Redisson锁计数器 客户端会话关联 需额外逻辑
自动释放 超时机制 会话断开自动删除 超时事件或定时任务
性能 很高
实现复杂度 低(原生命令支持) 中(需维护会话) 高(需事务管理)
相关推荐
小马爱打代码9 分钟前
微服务外联Feign调用:第三方API调用的负载均衡与容灾实战
微服务·架构·负载均衡
9527华安4 小时前
FPGA实现40G网卡NIC,基于PCIE4C+40G/50G Ethernet subsystem架构,提供工程源码和技术支持
fpga开发·架构·网卡·ethernet·nic·40g·pcie4c
guojl9 小时前
深度解决大文件上传难题
架构
DemonAvenger9 小时前
Go语言中的TCP编程:基础实现与最佳实践
网络协议·架构·go
xinxiangwangzhi_10 小时前
pytorch底层原理学习--PyTorch 架构梳理
人工智能·pytorch·架构
真实的菜12 小时前
Kafka生态整合深度解析:构建现代化数据架构的核心枢纽
架构·kafka·linq
guojl12 小时前
营销客群规则引擎
架构
Natsume171013 小时前
嵌入式开发:GPIO、UART、SPI、I2C 驱动开发详解与实战案例
c语言·驱动开发·stm32·嵌入式硬件·mcu·架构·github
DemonAvenger13 小时前
深入理解Go的网络I/O模型:优势、实践与踩坑经验
网络协议·架构·go