PHP `Distributed Locking` (`Redis Lock`/`ZooKeeper`):解决并发资源竞争

各位观众,大家好!今天咱们聊聊并发编程里让人头疼,但又不得不面对的问题:分布式锁。这玩意儿就像一群熊孩子抢玩具,不加约束,那场面绝对惨不忍睹。所以,我们需要个“家长”出来维持秩序,这个“家长”就是分布式锁。

一、并发的烦恼:不加锁的后果

咱们先来模拟一个简单的场景:多个用户同时抢购一件商品,库存只有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";
}

解释:

  1. acquireLock(): 尝试获取锁。使用 SETNX 设置锁,如果成功(key 不存在),则设置过期时间。如果获取失败(key 已经存在),则返回 false。
  2. releaseLock(): 释放锁。先检查锁的持有者是否是当前进程/线程,如果是,则删除锁。
  3. $lockValue = uniqid(): 生成一个唯一的锁值,用于防止误删锁。 如果没有锁值,A进程获取锁后,业务还没执行完,锁过期了,B进程获取到了锁,A进程执行完业务后,把B进程的锁给释放了,这样就造成了并发问题。
  4. $redis->client('ID'): 用于获取当前redis客户端的唯一标识,用于防止误删锁。
  5. finally: 确保在任何情况下都能释放锁,避免死锁。

2. 缺陷:单点故障 + 时间不同步

上面这个简单的实现存在一些问题:

  • 单点故障: 如果 Redis 服务器挂了,锁就失效了,可能导致多个进程同时进入临界区。
  • 时间不同步: 如果 Redis 服务器的时间和业务服务器的时间不同步,可能导致锁提前过期,或者锁永远不会过期。

3. Redlock:更可靠的方案

Redlock 算法是 Redis 官方提出的分布式锁算法,旨在解决单点故障的问题。它的核心思想是:使用多个独立的 Redis 实例,只有当大多数 Redis 实例都成功加锁时,才认为获取锁成功。

Redlock 算法步骤:

  1. 客户端尝试从 N 个独立的 Redis 实例获取锁。
  2. 客户端使用相同的 key 和 value,并设置相同的过期时间。
  3. 客户端必须在一定的超时时间内(例如,总锁时间的 5-10%)尝试获取所有 Redis 实例的锁。
  4. 只有当客户端成功获取了超过半数(N/2 + 1)的 Redis 实例的锁,才认为获取锁成功。
  5. 如果获取锁成功,客户端需要设置一个锁的有效时间,这个时间应该小于最初设置的过期时间,以避免锁的提前释放。
  6. 如果获取锁失败,客户端需要释放所有 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 会为每个新创建的节点自动分配一个单调递增的序号。

实现步骤:

  1. 客户端尝试在 ZooKeeper 上创建一个临时顺序节点,节点名称可以为 /locks/product_lock_
  2. 客户端获取所有子节点,并判断自己创建的节点是否是序号最小的节点。
  3. 如果是序号最小的节点,则认为获取锁成功。
  4. 如果不是序号最小的节点,则监听比自己序号小的那个节点的删除事件。当该节点被删除时,客户端重新尝试获取锁。
  5. 释放锁:删除自己创建的临时顺序节点。

代码示例 (需要 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";
}

解释:

  1. acquireLock(): 创建临时顺序节点,获取所有子节点,判断自己是否是序号最小的节点,如果不是,则监听比自己序号小的那个节点的删除事件。
  2. releaseLock(): 删除自己创建的临时顺序节点。
  3. getNodeId(): 从节点名称中提取序号。
  4. Kazoo::EPHEMERAL | Kazoo::SEQUENCE: 创建临时顺序节点。
  5. $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。

六、一些额外的建议

  • 设置合理的过期时间: 过期时间应该足够长,以保证业务逻辑能够执行完毕,但也不应该过长,以避免死锁。
  • 使用重试机制: 如果获取锁失败,应该使用重试机制,而不是直接放弃。
  • 监控锁的状态: 应该监控锁的状态,以便及时发现问题。
  • 避免长时间持有锁: 应该尽量缩短持有锁的时间,以提高并发性能。
  • 考虑使用现成的库: 有很多现成的分布式锁库可以使用,可以避免重复造轮子。

好了,今天的分享就到这里。希望大家能够理解分布式锁的原理和使用方法,并在实际项目中灵活应用。记住,选择合适的分布式锁方案,就像给孩子选玩具一样,适合的才是最好的!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注