分布式锁
分布式锁详解
一、分布式锁概述
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的第二个参数区分线程/进程使用 - 信号:异步通知机制,如
SIGTERM、SIGKILL - 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
问题:SETNX和EXPIRE是两条独立命令,若加锁后进程崩溃,锁永不过期。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
问题在于:GET和DEL之间,锁可能超时被其他进程获取,导致误删他人锁。解决方案是使用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个),相互不复制。
加锁流程:
- 获取当前时间戳(毫秒)
- 依次向所有节点发送加锁请求(
SET key value NX PX timeout) - 计算成功获取锁的节点数,以及总耗时
- 若成功节点数 ≥ N/2+1(如5个节点需至少3个),且总耗时 < 锁有效期,则加锁成功
- 加锁成功时,锁的有效期减去总耗时
- 若加锁失败,向所有节点发送释放锁请求
解锁流程:向所有节点发送解锁Lua脚本,无需考虑返回值。
RedLock在多数节点存活时可用,单节点故障不影响锁服务。
7. Redis方案的优缺点
| 优点 | 缺点 |
|---|---|
| 高性能,内存操作,适合高频场景 | 数据存储在内存,进程重启锁标记丢失 |
| 原子命令和Lua脚本保证操作原子性 | 锁续期需要额外看门狗机制 |
| 原生支持锁超时 | RedLock算法复杂,需部署多节点 |
| 丰富的数据结构,支持多种场景 | 非严格强一致性(Redis主从复制可能丢数据) |
六、MySQL与Redis方案对比
| 维度 | MySQL方案 | Redis方案 |
|---|---|---|
| 性能 | 磁盘IO,TPS较低 | 内存操作,TPS极高 |
| 超时机制 | 需额外超进程 | 原生支持EX/PX |
| 原子性 | 依赖事务 | 支持Lua脚本 |
| 可用性 | 单点故障风险 | 支持集群/RedLock |
| 持久化 | 数据持久化 | 内存数据库,可配置持久化 |
| 适用场景 | 锁操作不频繁,对数据一致性要求极高 | 高并发场景,可接受轻微数据丢失风险 |
七、总结
分布式锁是分布式系统实现互斥访问的关键技术。其核心设计要点包括:
- 互斥性:通过唯一标识和存储介质的原子操作实现
- 超时机制:防止持锁进程异常退出导致死锁
- 原子性:加锁和解锁操作必须不可分割
- 高可用:存储层需支持主从切换或多数派协议
在实践中,Redis是分布式锁的主流实现方案,MySQL可作为备选或与业务操作结合使用。

浙公网安备 33010602011771号