分布式锁

分布式锁详解

一、分布式锁概述

1. 背景与定义

分布式锁是在分布式场景中使用的同步机制。分布式系统指由多台机器组成的应用系统,这些机器可能位于不同物理位置或网络环境,通过Socket进行通信。分布式锁的核心功能是确保在分布式环境下,同一时刻只有一个执行体(如线程或进程)能访问临界资源,从而保证数据一致性。

与传统的多线程锁相比,分布式锁面临两大核心挑战:

  • 进程独立性:单个进程退出不会影响其他进程,锁的持有者可能意外宕机,需要设计超时释放机制。
  • 网络不可靠性:加锁和解锁操作依赖网络通信,存在请求丢失、延迟或重复的风险。

2. 分布式锁的核心特性

一个完善的分布式锁需要满足以下条件:

特性 说明
互斥性 任意时刻,只有一个客户端能持有锁
容错性 锁服务自身应具备高可用性,避免单点故障
锁超时 当持锁客户端异常退出时,锁应能自动释放,防止死锁
可重入性 同一客户端可多次获取同一把锁(可选)
公平性 按请求顺序获取锁(可选)

二、分布式锁的基础:多线程同步机制

在深入分布式锁之前,我们先回顾多线程编程中的同步机制,理解这些基础概念有助于把握分布式锁的设计思想。

1. 互斥锁与自旋锁

互斥锁(Mutex) 是最基础的同步原语,用于保护临界资源,仅允许一个线程访问。当多个线程竞争时,未获取锁的线程会进入阻塞状态,操作系统将其从运行态切换到阻塞态,加入等待队列。锁释放时,系统从队列中选择一个线程唤醒,转为就绪态。整个过程涉及用户态与内核态的切换,开销较大,适合临界区较长的场景。

自旋锁(Spinlock) 则采用忙等待策略。竞争失败的线程不会进入阻塞态,而是持续占用CPU轮询锁状态。这种方式的优点是避免了线程切换的开销,但缺点是如果临界区较长,会造成CPU空转浪费。自旋锁适用于临界区极短的场景,如简单的指针操作。

两者的本质区别

  • 互斥锁:让出CPU,线程休眠,适合长临界区
  • 自旋锁:不释放CPU,忙等待,适合短临界区

2. 原子变量

原子变量通过CPU级指令(如lock前缀)保证操作的原子性,实现原理依赖总线锁定或缓存锁定机制,确保多核环境下单一核心能独占访问内存区域。原子操作适用于简单变量的同步(如计数器),无法替代复杂场景的锁机制。

3. 读写锁

读写锁适用于读多写少的场景:

  • 读锁(共享锁):允许多个线程同时持有,并发读取数据
  • 写锁(排他锁):独占资源,写操作时阻塞所有读操作

实际开发中建议优先使用互斥锁,在确认存在性能瓶颈后再考虑替换为读写锁。

4. 条件变量

条件变量用于线程间的条件同步,必须配合互斥锁使用。核心作用是阻塞线程直到用户定义的条件满足。典型应用是生产者-消费者模型:

// 消费者线程
pthread_mutex_lock(&mutex);
while (queue.empty()) {
    pthread_cond_wait(&cond, &mutex);  // 等待条件满足
}
item = queue.pop();
pthread_mutex_unlock(&mutex);

// 生产者线程
pthread_mutex_lock(&mutex);
queue.push(item);
pthread_cond_signal(&cond);  // 唤醒等待线程
pthread_mutex_unlock(&mutex);

虚假唤醒:条件变量的唤醒可能是不确定的——即使没有收到信号,pthread_cond_wait也可能返回。Linux手册明确提示,pthread_cond_signal的唤醒行为具有不确定性。因此,必须使用while循环检查条件,而不是if

// 正确:while循环防止虚假唤醒
while (queue.empty()) {
    pthread_cond_wait(&cond, &mutex);
}

// 错误:if可能因虚假唤醒导致错误
if (queue.empty()) {
    pthread_cond_wait(&cond, &mutex);
}

允许虚假唤醒的好处是简化了系统实现,同时强制开发者编写更健壮的代码。

5. 信号量

信号量通过PV操作实现同步,适用于线程或进程间协调:

  • P操作(sem_wait):减少信号量,若信号量为0则阻塞
  • V操作(sem_post):增加信号量,唤醒阻塞的线程

信号量初始值为1时可模拟互斥锁,初始值为0时可用于线程同步(如等待事件完成)。nginx的多进程锁正是通过信号量实现的。

6. 进程间通信(IPC)

进程间通信主要方式包括:

  • 管道:限于父子进程或同族进程通信
  • 消息队列:FIFO机制,消息有边界
  • 共享内存:最快的IPC方式,nginx多子进程配置同步的基础
  • 信号量:通过semget的第二个参数区分线程/进程使用
  • 信号:异步通知机制,如SIGTERMSIGKILL
  • Socket:分布式系统首选的通信方式,支持跨网络通信

三、分布式锁的实现挑战

与单机多线程锁相比,分布式锁面临三个核心难题:

1. 互斥性的实现

多线程锁依赖进程共享的内存空间(堆、静态全局区等),锁标记直接存储在内存中。而分布式锁需要所有节点均可访问的资源,通常存储在数据库或分布式配置中心(如Redis、ZooKeeper、etcd)。加锁操作变为网络请求,在目标字段标记当前进程的唯一标识(如进程ID或UUID)。

2. 锁超时与异常处理

在分布式环境中,进程可能随时崩溃,或网络请求可能丢失。如果持锁进程异常退出,锁将永远不会释放,导致死锁。因此分布式锁必须设计超时自动释放机制:

  • Redis:通过SET key value NX EX seconds设置自动过期
  • MySQL:需独立启动超进程定期扫描并清理过期锁
  • ZooKeeper:通过临时节点(session过期自动删除)

3. 原子性操作

加锁和解锁操作通常涉及多个步骤(如先读取再判断、先验证再删除),这些步骤必须保证原子性。Redis通过Lua脚本实现原子性,MySQL通过事务和行锁保证。

四、MySQL实现分布式锁

1. 表结构设计

MySQL作为关系型数据库,需要通过表记录存储分布式锁:

CREATE TABLE `distributed_lock` (
    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
    `lock_type` VARCHAR(64) NOT NULL COMMENT '锁类型/名称',
    `owner_id` VARCHAR(128) NOT NULL COMMENT '锁持有者标识(进程ID+线程ID)',
    `create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加锁时间',
    `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_lock_type` (`lock_type`)  -- 唯一约束保证互斥
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

互斥语义实现

  • 通过lock_type字段的唯一约束,确保同一时刻只有一个进程能成功插入记录
  • INSERT成功(影响行数为1)表示加锁成功
  • Duplicate entry错误(错误码1062)表示锁已被占用

2. 加锁与解锁操作

加锁

INSERT INTO distributed_lock (lock_type, owner_id) 
VALUES ('order_lock', 'process_123_456');

解锁(必须验证持有者):

DELETE FROM distributed_lock 
WHERE lock_type = 'order_lock' AND owner_id = 'process_123_456';

锁竞争处理:未获取锁的进程需通过轮询(自旋)方式重复尝试加锁。MySQL不支持公平锁,获取顺序由数据库调度决定。

3. 锁超时解决方案

MySQL没有自动过期机制,需启动独立的超进程定期扫描并清理过期锁:

-- 超进程定时执行(例如每10秒)
DELETE FROM distributed_lock 
WHERE lock_type = 'order_lock' 
  AND update_time < DATE_SUB(NOW(), INTERVAL 50 SECOND);

索引优化:若update_time是索引列,条件应改写为update_time < DATE_SUB(NOW(), INTERVAL 50 SECOND),避免函数运算导致索引失效。

超进程自身也需高可用,可采用多实例配合分布式锁确保只有一个超进程在运行。

4. MySQL方案的优缺点

优点 缺点
数据持久化,进程重启后锁状态可恢复 锁竞争引发频繁磁盘IO,性能瓶颈
技术栈统一,无需引入额外组件 需额外开发超进程处理锁超时
事务支持,可组合其他业务操作 计算与存储耦合,无法实现高可用架构

五、Redis实现分布式锁

1. Redis基础

Redis是内存数据库,数据常驻内存,读写性能远超磁盘型数据库。通过redis-server命令启动,默认监听6379端口,使用redis-cli连接。

2. 分布式锁基本实现

Redis通过SETNX(SET if Not eXists)命令实现分布式锁:

# 加锁(键不存在时设置成功)
SETNX lock_key "process_123_456"

# 设置超时时间(需与SETNX合并,见下文)
EXPIRE lock_key 30

问题SETNXEXPIRE是两条独立命令,若加锁后进程崩溃,锁永不过期。Redis 2.6.12版本后支持原子操作:

SET lock_key "process_123_456" NX EX 30

返回OK表示加锁成功,返回nil表示锁已被占用。

3. 解锁的原子性问题

解锁需要先验证持有者,再删除锁:

# 错误的实现(非原子)
GET lock_key
# 如果值为"process_123_456",则执行
DEL lock_key

问题在于:GETDEL之间,锁可能超时被其他进程获取,导致误删他人锁。解决方案是使用Lua脚本保证原子性:

-- unlock.lua
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

执行:

redis-cli --eval unlock.lua lock_key , "process_123_456"

4. 锁超时

Redis通过EX(秒)或PX(毫秒)参数自动处理超时,无需额外进程:

SET lock_key "process_123_456" NX EX 30

30秒后Redis自动删除该键,其他进程可获取锁。但存在一个经典问题:锁续期——若任务执行时间超过30秒,锁被自动释放,其他进程获取锁后可能造成数据冲突。

解决方案:启动看门狗线程,定期(如每10秒)检查锁是否仍被持有,若任务未完成则执行EXPIRE延长锁时间。

5. 锁的广播通知

轮询方式浪费网络资源,可通过Redis的发布/订阅模式实现通知:

  • 获取锁失败时,订阅特定频道(如lock:order_lock
  • 锁释放时,向该频道广播消息
  • 所有监听者收到消息后,重新尝试获取锁

注意:此方案仍为非公平锁,因为所有节点同时尝试竞争,没有排队机制。

6. 高可用性问题:RedLock

单节点Redis存在单点故障风险。RedLock算法(由Redis作者提出)通过多个独立节点实现高可用:

部署:奇数个独立的Redis节点(通常5个),相互不复制。

加锁流程

  1. 获取当前时间戳(毫秒)
  2. 依次向所有节点发送加锁请求(SET key value NX PX timeout
  3. 计算成功获取锁的节点数,以及总耗时
  4. 若成功节点数 ≥ N/2+1(如5个节点需至少3个),且总耗时 < 锁有效期,则加锁成功
  5. 加锁成功时,锁的有效期减去总耗时
  6. 若加锁失败,向所有节点发送释放锁请求

解锁流程:向所有节点发送解锁Lua脚本,无需考虑返回值。

RedLock在多数节点存活时可用,单节点故障不影响锁服务。

7. Redis方案的优缺点

优点 缺点
高性能,内存操作,适合高频场景 数据存储在内存,进程重启锁标记丢失
原子命令和Lua脚本保证操作原子性 锁续期需要额外看门狗机制
原生支持锁超时 RedLock算法复杂,需部署多节点
丰富的数据结构,支持多种场景 非严格强一致性(Redis主从复制可能丢数据)

六、MySQL与Redis方案对比

维度 MySQL方案 Redis方案
性能 磁盘IO,TPS较低 内存操作,TPS极高
超时机制 需额外超进程 原生支持EX/PX
原子性 依赖事务 支持Lua脚本
可用性 单点故障风险 支持集群/RedLock
持久化 数据持久化 内存数据库,可配置持久化
适用场景 锁操作不频繁,对数据一致性要求极高 高并发场景,可接受轻微数据丢失风险

七、总结

分布式锁是分布式系统实现互斥访问的关键技术。其核心设计要点包括:

  1. 互斥性:通过唯一标识和存储介质的原子操作实现
  2. 超时机制:防止持锁进程异常退出导致死锁
  3. 原子性:加锁和解锁操作必须不可分割
  4. 高可用:存储层需支持主从切换或多数派协议

在实践中,Redis是分布式锁的主流实现方案,MySQL可作为备选或与业务操作结合使用。

posted @ 2026-03-25 00:46  xggx  阅读(29)  评论(0)    收藏  举报