各位观众,大家好!今天咱们聊聊并发编程里让人头疼,但又不得不面对的问题:分布式锁。这玩意儿就像一群熊孩子抢玩具,不加约束,那场面绝对惨不忍睹。所以,我们需要个“家长”出来维持秩序,这个“家长”就是分布式锁。
一、并发的烦恼:不加锁的后果
咱们先来模拟一个简单的场景:多个用户同时抢购一件商品,库存只有1个。
<?php
// 模拟库存
$inventory = 1;
function purchase() {
global $inventory;
echo "用户 " . uniqid() . " 尝试购买...n";
if ($inventory > 0) {
// 模拟耗时操作,比如数据库更新
sleep(rand(1, 3));
$inventory--;
echo "购买成功!剩余库存: " . $inventory . "n";
} else {
echo "库存不足!n";
}
}
// 模拟多个用户并发购买
$threads = [];
for ($i = 0; $i < 5; $i++) {
$threads[] = new Thread(function() {
purchase();
});
}
foreach ($threads as $thread) {
$thread->start();
}
foreach ($threads as $thread) {
$thread->join();
}
echo "程序结束,最终库存: " . $inventory . "n";
注意: 上述代码使用了 Thread
类模拟多线程,你需要安装并启用 pthreads
扩展才能运行。如果你不想安装 pthreads
,可以尝试使用 pcntl_fork
创建子进程来模拟并发,但这会涉及到进程间通信的复杂性,超出我们今天讨论的范围。
如果你运行这段代码,你会发现,即使库存只有1个,仍然可能会出现多个用户“购买成功”的情况,导致库存变为负数,数据不一致!这就是并发带来的问题,多个进程/线程同时操作共享资源,导致数据竞争。
二、锁的诞生:解决数据竞争
为了解决这个问题,我们需要引入锁的概念。锁的作用就是让同一时刻只有一个进程/线程能够访问共享资源。
1. 本地锁(PHP):看似美好,实则鸡肋
PHP本身提供了一些锁机制,比如 flock()
函数。
<?php
$lockFile = '/tmp/my_lock.lock';
function purchase() {
global $inventory, $lockFile;
$fp = fopen($lockFile, 'w+');
if (flock($fp, LOCK_EX)) { // 加独占锁
echo "用户 " . uniqid() . " 尝试购买...n";
if ($inventory > 0) {
sleep(rand(1, 3));
$inventory--;
echo "购买成功!剩余库存: " . $inventory . "n";
} else {
echo "库存不足!n";
}
flock($fp, LOCK_UN); // 释放锁
} else {
echo "获取锁失败,稍后重试。n";
}
fclose($fp);
}
// 模拟多个用户并发购买 (同样需要 pthreads 扩展或 pcntl_fork)
// ... (代码与之前类似,略去)
flock()
函数可以实现本地文件锁,但是它只适用于单机环境。在分布式环境中,每个服务器都有自己的文件系统,flock()
无法保证多个服务器上的进程互斥访问共享资源。
所以,本地锁在分布式场景下,约等于没用。
2. 分布式锁:真正的王者
分布式锁需要一个共享存储系统来维护锁的状态,常用的有 Redis 和 ZooKeeper。
三、Redis 分布式锁:简单高效的选择
Redis 是一个高性能的键值存储系统,天然适合用来实现分布式锁。
1. 基本原理:SETNX + EXPIRE
Redis 分布式锁的核心命令是 SETNX
(SET if Not eXists) 和 EXPIRE
。
SETNX key value
: 如果 key 不存在,则设置 key 的值为 value,返回 1;如果 key 已经存在,则不做任何操作,返回 0。EXPIRE key seconds
: 设置 key 的过期时间,防止死锁。
代码示例:
<?php
require 'vendor/autoload.php'; // 引入 Redis 客户端库,比如 predis/predis
use PredisClient;
class RedisLock {
private $redis;
private $lockKey;
private $lockTimeout; // 锁的超时时间,单位秒
public function __construct(Client $redis, string $lockKey, int $lockTimeout = 10) {
$this->redis = $redis;
$this->lockKey = $lockKey;
$this->lockTimeout = $lockTimeout;
}
public function acquireLock(): bool {
$lockValue = uniqid(); // 使用唯一值作为锁的值
$acquired = $this->redis->setnx($this->lockKey, $lockValue);
if ($acquired) {
$this->redis->expire($this->lockKey, $this->lockTimeout);
return true;
} else {
return false;
}
}
public function releaseLock(): bool {
// 释放锁之前,需要验证锁的持有者是否是自己
$lockValue = $this->redis->get($this->lockKey);
if ($lockValue && $lockValue === $this->getLockValue()) {
return $this->redis->del($this->lockKey) > 0;
}
return false;
}
private function getLockValue(): string {
//生成lock的value值,这里使用当前客户端的唯一标识,防止误删
return $this->redis->client('ID');
}
}
// 使用示例
$redis = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
]);
$lock = new RedisLock($redis, 'product_lock', 5);
if ($lock->acquireLock()) {
try {
// 执行需要加锁的业务逻辑
echo "获取锁成功,执行业务逻辑...n";
sleep(rand(1, 3));
echo "业务逻辑执行完毕。n";
} finally {
if ($lock->releaseLock()) {
echo "释放锁成功。n";
} else {
echo "释放锁失败。n";
}
}
} else {
echo "获取锁失败,稍后重试。n";
}
解释:
acquireLock()
: 尝试获取锁。使用SETNX
设置锁,如果成功(key 不存在),则设置过期时间。如果获取失败(key 已经存在),则返回 false。releaseLock()
: 释放锁。先检查锁的持有者是否是当前进程/线程,如果是,则删除锁。$lockValue = uniqid()
: 生成一个唯一的锁值,用于防止误删锁。 如果没有锁值,A进程获取锁后,业务还没执行完,锁过期了,B进程获取到了锁,A进程执行完业务后,把B进程的锁给释放了,这样就造成了并发问题。$redis->client('ID')
: 用于获取当前redis客户端的唯一标识,用于防止误删锁。finally
: 确保在任何情况下都能释放锁,避免死锁。
2. 缺陷:单点故障 + 时间不同步
上面这个简单的实现存在一些问题:
- 单点故障: 如果 Redis 服务器挂了,锁就失效了,可能导致多个进程同时进入临界区。
- 时间不同步: 如果 Redis 服务器的时间和业务服务器的时间不同步,可能导致锁提前过期,或者锁永远不会过期。
3. Redlock:更可靠的方案
Redlock 算法是 Redis 官方提出的分布式锁算法,旨在解决单点故障的问题。它的核心思想是:使用多个独立的 Redis 实例,只有当大多数 Redis 实例都成功加锁时,才认为获取锁成功。
Redlock 算法步骤:
- 客户端尝试从 N 个独立的 Redis 实例获取锁。
- 客户端使用相同的 key 和 value,并设置相同的过期时间。
- 客户端必须在一定的超时时间内(例如,总锁时间的 5-10%)尝试获取所有 Redis 实例的锁。
- 只有当客户端成功获取了超过半数(N/2 + 1)的 Redis 实例的锁,才认为获取锁成功。
- 如果获取锁成功,客户端需要设置一个锁的有效时间,这个时间应该小于最初设置的过期时间,以避免锁的提前释放。
- 如果获取锁失败,客户端需要释放所有 Redis 实例上的锁。
PHP Redlock 实现 (需要 redis/redlock 扩展):
<?php
require 'vendor/autoload.php'; // 引入 Redlock 客户端库,比如 redis/redlock
use RedlockRedlock;
use PredisClient;
$servers = [
['host' => '127.0.0.1', 'port' => 6379],
['host' => '127.0.0.1', 'port' => 6380],
['host' => '127.0.0.1', 'port' => 6381],
];
$redlock = new Redlock($servers);
$resource = 'product_lock';
$ttl = 5000; // 锁的过期时间,单位毫秒
try {
$lock = $redlock->lock($resource, $ttl);
if ($lock) {
echo "Redlock 获取锁成功,执行业务逻辑...n";
sleep(rand(1, 3));
echo "业务逻辑执行完毕。n";
$redlock->unlock($lock);
echo "Redlock 释放锁成功。n";
} else {
echo "Redlock 获取锁失败,稍后重试。n";
}
} catch (Exception $e) {
echo "Redlock 发生异常: " . $e->getMessage() . "n";
}
Redlock 的优点:
- 更高的可用性:即使部分 Redis 实例挂了,只要超过半数的实例可用,锁仍然可以正常工作。
- 更好的容错性:Redlock 算法可以容忍部分 Redis 实例的时间不同步。
Redlock 的缺点:
- 更复杂:需要部署和维护多个 Redis 实例。
- 性能略有下降:需要与多个 Redis 实例进行通信。
- 仍然存在争议:Redlock 算法的正确性在学术界存在争议,一些专家认为它仍然存在一些潜在的问题。
四、ZooKeeper 分布式锁:稳定可靠的选择
ZooKeeper 是一个分布式协调服务,提供数据一致性保证,非常适合用来实现分布式锁。
1. 基本原理:临时顺序节点
ZooKeeper 分布式锁的核心思想是利用 ZooKeeper 的临时顺序节点特性。
- 临时节点: 当客户端与 ZooKeeper 服务器断开连接时,该节点会被自动删除。
- 顺序节点: ZooKeeper 会为每个新创建的节点自动分配一个单调递增的序号。
实现步骤:
- 客户端尝试在 ZooKeeper 上创建一个临时顺序节点,节点名称可以为
/locks/product_lock_
。 - 客户端获取所有子节点,并判断自己创建的节点是否是序号最小的节点。
- 如果是序号最小的节点,则认为获取锁成功。
- 如果不是序号最小的节点,则监听比自己序号小的那个节点的删除事件。当该节点被删除时,客户端重新尝试获取锁。
- 释放锁:删除自己创建的临时顺序节点。
代码示例 (需要 kazoo 扩展,或者使用 curator-php 库):
<?php
require 'vendor/autoload.php'; // 引入 ZooKeeper 客户端库,比如 ptachoire/kazoo
use KazooClient;
class ZookeeperLock {
private $zk;
private $lockPath = '/locks';
private $lockName = 'product_lock_';
private $myZnode;
public function __construct(string $hosts) {
$this->zk = new Client($hosts);
$this->zk->start();
// 确保锁的根节点存在
if (!$this->zk->exists($this->lockPath)) {
$this->zk->create($this->lockPath, '', [], Kazoo::EPHEMERAL);
}
}
public function acquireLock(): bool {
// 创建临时顺序节点
$this->myZnode = $this->zk->create($this->lockPath . '/' . $this->lockName, '', [], Kazoo::EPHEMERAL | Kazoo::SEQUENCE);
$myId = $this->getNodeId($this->myZnode);
while (true) {
// 获取所有子节点
$children = $this->zk->getChildren($this->lockPath);
sort($children);
// 找到序号最小的节点
$firstNode = reset($children);
$firstId = $this->getNodeId($this->lockPath . '/' . $firstNode);
if ($myId === $firstId) {
// 我是序号最小的节点,获取锁成功
return true;
} else {
// 监听比自己序号小的那个节点的删除事件
$myIndex = array_search(basename($this->myZnode), $children);
$preNode = $children[$myIndex - 1];
$preNodePath = $this->lockPath . '/' . $preNode;
// 检查前一个节点是否存在
if ($this->zk->exists($preNodePath)) {
$this->zk->get($preNodePath, function($data, $stat, $event) {
// 当监听的节点被删除时,重新尝试获取锁
if ($event['type'] === Kazoo::DELETED_EVENT) {
// 这里需要使用循环,避免出现竞态条件
while (true) {
if ($this->acquireLock()) {
break;
}
usleep(10000); // 短暂休眠,避免 CPU 占用过高
}
}
});
} else {
// 前一个节点不存在,可能已经被删除,重新尝试获取锁
continue;
}
// 等待事件发生
usleep(10000); // 短暂休眠,避免 CPU 占用过高
return false; // 必须返回 false,否则会陷入死循环
}
}
}
public function releaseLock(): void {
if ($this->myZnode) {
$this->zk->delete($this->myZnode);
$this->myZnode = null;
}
}
private function getNodeId(string $path): int {
return (int)substr(basename($path), strlen($this->lockName));
}
public function __destruct() {
$this->zk->close();
}
}
// 使用示例
$zkLock = new ZookeeperLock('127.0.0.1:2181');
if ($zkLock->acquireLock()) {
try {
echo "ZooKeeper 获取锁成功,执行业务逻辑...n";
sleep(rand(1, 3));
echo "业务逻辑执行完毕。n";
} finally {
$zkLock->releaseLock();
echo "ZooKeeper 释放锁成功。n";
}
} else {
echo "ZooKeeper 获取锁失败,稍后重试。n";
}
解释:
acquireLock()
: 创建临时顺序节点,获取所有子节点,判断自己是否是序号最小的节点,如果不是,则监听比自己序号小的那个节点的删除事件。releaseLock()
: 删除自己创建的临时顺序节点。getNodeId()
: 从节点名称中提取序号。Kazoo::EPHEMERAL | Kazoo::SEQUENCE
: 创建临时顺序节点。$this->zk->get($preNodePath, function($data, $stat, $event)
: 监听前一个节点的删除事件。
2. 优点:
- 高可用性: ZooKeeper 集群本身具有高可用性,可以容忍部分节点故障。
- 强一致性: ZooKeeper 保证数据一致性,避免出现脑裂等问题。
- 避免死锁: 临时节点的特性可以防止死锁,即使客户端崩溃,锁也会被自动释放。
3. 缺点:
- 性能略低: 相对于 Redis 来说,ZooKeeper 的性能略低,因为每次操作都需要进行数据同步。
- 更复杂: 需要部署和维护 ZooKeeper 集群。
五、选择哪种方案?
特性 | Redis (SETNX + EXPIRE) | Redis (Redlock) | ZooKeeper |
---|---|---|---|
复杂度 | 简单 | 较复杂 | 较复杂 |
性能 | 高 | 较高 | 较低 |
可靠性 | 低 | 较高 | 高 |
适用场景 | 对性能要求高,容错性要求不高的场景 | 对可靠性有一定要求,但对性能要求较高的场景 | 对可靠性要求极高的场景 |
是否避免死锁 | 通过过期时间避免死锁,但可能提前释放 | 通过过期时间避免死锁,但可能提前释放 | 临时节点自动释放,避免死锁 |
总结:
- 如果对性能要求非常高,且可以容忍一定的错误,可以使用 Redis (SETNX + EXPIRE)。
- 如果需要更高的可靠性,可以使用 Redlock。
- 如果对可靠性要求极高,且可以容忍一定的性能损失,可以使用 ZooKeeper。
六、一些额外的建议
- 设置合理的过期时间: 过期时间应该足够长,以保证业务逻辑能够执行完毕,但也不应该过长,以避免死锁。
- 使用重试机制: 如果获取锁失败,应该使用重试机制,而不是直接放弃。
- 监控锁的状态: 应该监控锁的状态,以便及时发现问题。
- 避免长时间持有锁: 应该尽量缩短持有锁的时间,以提高并发性能。
- 考虑使用现成的库: 有很多现成的分布式锁库可以使用,可以避免重复造轮子。
好了,今天的分享就到这里。希望大家能够理解分布式锁的原理和使用方法,并在实际项目中灵活应用。记住,选择合适的分布式锁方案,就像给孩子选玩具一样,适合的才是最好的!