ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP8.5怎么配置分布式锁超时释放

PHP8.5怎么配置分布式锁超时释放 前言分布式锁的两个经典事故方向刚好相反。第一个是锁没释放某个进程拿到锁以后在业务逻辑里遇到未捕获的异常或发生了致命错误finally没跑到或者进程直接被杀。锁一直挂在 Redis 里其他所有实例全部阻塞整个任务队列停摆。第二个是锁提前过期TTL 设了 10 秒但业务跑了 30 秒。锁在 10 秒时自动消失另一个实例拿到了同一把锁于是两个进程同时操作同一份数据——重复扣款、重复发券、数据错乱。更阴险的是第一个进程跑完后还会去「释放」锁而它释放的其实是别人持有的锁。这两类问题的解法不是调大或调小 TTL而是把「超时释放」拆成三个互相独立的机制分别配置TTL 兜底、主动释放、续期看门狗。三者缺一都会留下一个具体的故障场景。关于版本要先说明白分布式锁不是 PHP 语言特性也不是 PHP 8.5 引入的能力。真正提供锁的是 Redis、MySQL 这类外部系统PHP 8.5 只是运行环境。标题里的 8.5 是环境版本本文按此环境编写。PHP 8.5 新增的管道操作符|、array_first()/array_last()、clone with等语法能让配套脚本写得更短但它们与锁机制本身无关本文也不会把它们的作用夸大。一、把「超时释放」拆成三个机制机制解决什么实现方式失效场景TTL 兜底持有者进程崩溃后锁永久占用SET key val NX PX ttlTTL 小于业务耗时 → 锁提前失效主动释放正常路径下立刻让出锁finally中执行比较后删除进程被kill -9什么都不执行续期看门狗业务耗时超过 TTL 时不丢锁定期把 TTL 重置PHP 里没有独立调度线程无法真正后台续期三个机制的分工很清楚TTL 是保险主动释放是常态续期是给长任务用的补丁。任何人只做了其中一个都会在某个特定场景下翻车。释放锁时必须带上「持有者校验」否则就会出现这样的事故A 持有的锁超时自动释放B 拿到了锁此时 A 执行完毕调用DEL把 B 的锁删掉了C 紧接着也拿到锁——同一时刻 B 和 C 都在临界区里。二、获取与释放三个必须做对的细节?php declare(strict_types1); /** * 基于 Redis 的分布式锁需要 ext-redisphpredis * 三个要点原子获取、唯一 token、比较后删除 */ final class RedisLock { private string $key; private string $token; private int $ttlMs; public function __construct( private \Redis $redis, string $name, int $ttlMs 10000, ) { $this-key lock: . $name; $this-token bin2hex(random_bytes(16)); // 唯一标识本次持有 $this-ttlMs $ttlMs; } /** 原子获取SET key token NX PX ttl 是一条命令不存在竞态 */ public function acquire(): bool { $ret $this-redis-set($this-key, $this-token, [nx, px $this-ttlMs]); return $ret ! false; } /** 续期只续「自己的」锁不续别人的 */ public function renew(): bool { $script LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) end return 0 LUA; return (bool) $this-redis-eval($script, [$this-key, $this-token, $this-ttlMs], 1); } /** 释放比较 token 后再删Lua 保证这两步原子 */ public function release(): bool { $script LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0 LUA; return (bool) $this-redis-eval($script, [$this-key, $this-token], 1); } /** 给外部读取用的剩余 TTL毫秒-1 表示无过期-2 表示键不存在 */ public function ttl(): int { return (int) $this-redis-pttl($this-key); } }三个细节分别对应三类事故SET ... NX PX必须是一条命令。先SETNX再EXPIRE是错的如果进程在这两条命令之间崩溃锁就永久存在TTL 兜底机制直接失效。token 必须唯一且不能复用。用固定的1或进程 ID 做 value在「释放时校验持有者」这一步就没有意义了。释放必须用 Lua 做「比较 删除」。GET之后再DEL是两次往返中间可能正好发生锁过期与重新获取于是删掉别人的锁。Redis 执行 Lua 脚本是原子的这两步不会被插队。TTL 该设多少没有万能值按「业务耗时的上限再加一段缓冲」来定并且要监控实际耗时分布。一个可操作的做法是先按 P99 耗时的 2 到 3 倍设置同时把锁等待超时也显式设出来避免请求无限期排队。// 获取锁的等待策略带超时的重试而不是死等 $deadline microtime(true) 3.0; // 最多等 3 秒 $lock new RedisLock($redis, order: . $orderId, 10000); $got false; while (microtime(true) $deadline) { if ($lock-acquire()) { $got true; break; } usleep(random_int(50_000, 150_000)); // 加随机抖动避免惊群 } if (!$got) { throw new RuntimeException(获取锁超时请稍后重试); }重试间隔加随机抖动是必要的不加抖动几十个进程会在同一毫秒一起重试把 Redis 的瞬时压力放大。三、PHP 里续期看门狗的真实困境Java 生态里常用一个后台线程定期给锁续期锁快到期时自动延长很多文章把这一套直接搬到 PHP这是最容易出问题的部分。PHP 的执行模型是「请求/任务生命周期内单线程同步执行」。当你的任务阻塞在一段耗时的数据库查询或外部 HTTP 调用上时同一进程里没有任何代码在运行也就不可能自动续期。所以直接照搬「后台守护线程」的思路在 PHP 里是行不通的。可行的做法有三条选一条就行做法实现适合检查点续期在业务循环里每处理 N 条就renew()一次循环型批处理任务最常用拉长 TTL把 TTL 设得显著大于任务最长耗时耗时稳定、可预测的任务独立守护进程pcntl_fork()出子进程专职续期CLI 环境、需要长时间持有第三条在 CLI 下是可行的pcntl扩展仅在 CLI 且非 Windows 环境可用但进程间的生命周期管理会变复杂父进程要负责回收子进程异常退出时子进程可能变成孤儿继续续期。除非确实需要否则推荐第一条。// 检查点续期把 renew 放进循环而不是依赖后台线程 foreach ($items as $i $item) { if ($i % 100 0 !$lock-renew()) { // 续期失败 锁已经不属于自己了必须立刻停下不能再写数据 throw new RuntimeException(锁已失效任务中止以避免重复处理); } $this-process($item); }续期失败必须立刻中止而不是重试。续期失败意味着锁已经过期甚至已被别人持有此时你手中的「锁」是一张废纸继续写入就是并发事故。四、即使锁过期也要保护数据fencing token前面所有机制都建立在「时间」上而分布式系统里的时间是不可靠的GC 停顿、虚拟机挂起、网络延迟都可能让一个进程在「自己以为还持有锁」的状态下继续工作。解决办法是引入一个单调递增的令牌fencing token围栏令牌。获取锁时让锁服务返回一个自增序号写入数据时把这个序号带上存储层拒绝序号比当前值小的写入。?php declare(strict_types1); // 获取锁时拿到一个单调递增的版本号Redis 的 INCR 可以做到 $fencingToken (int) $redis-incr(lock:fence:order: . $orderId); // 真正的临界区写入带上 token让数据库做最后一道校验 $stmt $pdo-prepare( UPDATE orders SET status :status, fence :fence WHERE id :id AND (fence IS NULL OR fence :fence) ); $stmt-execute([ :status paid, :fence $fencingToken, :id $orderId, ]); if ($stmt-rowCount() 0) { // 说明已经有更新的持有者写过这条记录了本次写入被安全丢弃 throw new RuntimeException(检测到更晚的持有者写入被拒绝); }有了 fencing token即使两个进程因为时钟问题同时以为自己持有锁数据库这一层也能保证只有序号更大的那次写入生效。这是唯一能真正抵御「锁过期 时钟漂移」的手段代价是要在存储层多一个字段和一个条件。五、不用 Redis 也能做锁不是所有项目都有 RedisMySQL 也能实现分布式锁三种方式的取舍如下方案语句阻塞行为说明命名锁SELECT GET_LOCK(name, 10)可等待指定秒数连接断开自动释放最省心唯一索引插入INSERT INTO lock_table ...失败即冲突需要自己清理过期行行锁SELECT ... FOR UPDATE事务结束才释放锁的生命周期等于事务适合短临界区GET_LOCK()的特点是锁与数据库连接绑定连接断开包括进程崩溃时锁自动释放这天然解决了「TTL 兜底」的问题代价是它不跨数据库实例而且在连接池复用的前提下要小心拿到的是哪条连接。-- 命名锁10 秒拿不到就返回 0 SELECT GET_LOCK(order:10086, 10) AS got; -- 用完必须释放否则会一直占着这条连接 SELECT RELEASE_LOCK(order:10086);唯一索引插入法的表结构大致是这样的靠expire_at字段和定期清理来避免死锁CREATE TABLE distributed_lock ( lock_name VARCHAR(128) NOT NULL, token CHAR(32) NOT NULL, expire_at INT UNSIGNED NOT NULL, PRIMARY KEY (lock_name), KEY idx_expire (expire_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;抢锁用「先删除过期行再插入」DELETE FROM distributed_lock WHERE lock_name :name AND expire_at :now; INSERT INTO distributed_lock (lock_name, token, expire_at) VALUES (:name, :token, :expire_at);第二条语句靠主键冲突来判失败。释放时同样要带 token 条件避免删掉别人的锁DELETE FROM distributed_lock WHERE lock_name :name AND token :token;常见坑点1. 先 SETNX 再 EXPIRE❌if ($redis-setnx($key, $tok)) { $redis-expire($key, 10); }✅$redis-set($key, $tok, [nx, px 10000]);两条命令之间存在窗口期程序在这中间挂掉就会留下一个永不过期的锁把整条业务线堵死。2. 释放锁时不做持有者校验❌$redis-del($key);✅ 用 Lua 比较 token 后再删。这是「删掉别人的锁」的直接成因表现为「锁莫名消失两个进程同时在跑」而且日志里看不出任何异常。3. TTL 设置小于业务耗时❌ 业务平均 8 秒TTL 设为 5 秒指望「反正一般够用」。 ✅ 按耗时上限而不只是平均值设计 TTL并在循环里做检查点续期。平均值够用没有意义只要有一个请求落在长尾上就是一次并发事故。4. 续期失败后继续执行❌if (!$lock-renew()) { $logger-warning(续期失败); }然后继续处理。 ✅ 立刻抛异常中止任务。续期失败意味着锁已经不属于你了继续写数据就是在无锁状态下操作共享资源。5. 在 PHP 里指望「自动后台续期」❌ 照搬其他语言的看门狗线程写法或者指望Fiber能在阻塞时切走。 ✅ 用检查点续期、拉长 TTL或者pcntl_fork()出独立进程。FiberPHP 8.1 引入是协作式调度当前代码不主动让出执行权调度器就无法介入。它无法解决「阻塞在 IO 上时无人续期」的问题。6. 重试没有退避和抖动❌while (true) { if ($lock-acquire()) break; }✅ 带超时的重试 随机退避。死循环抢锁会把 Redis 打满而且请求会一直挂着最终把 FPM 的进程池耗尽——问题从「拿不到锁」升级成「整个站点不可用」。7. 忽略时钟漂移的影响❌ 认为「TTL 是 10 秒那这 10 秒内一定是安全的」。 ✅ 在存储层加 fencing token 兜底。TTL 的计时发生在锁服务侧而业务进程对「已经过了多久」的判断依赖本地时钟。GC 停顿几秒、虚拟机被挂起都可能让你的进程以为还在锁内。8. 把锁的粒度做得太粗❌ 用一把全局锁lock:order保护所有订单操作。 ✅ 按业务主键分片lock:order:{orderId}。一把大锁会把本来可以并行的操作串行化吞吐直接掉到单实例的水平而正确性并没有比按主键分片更高。总结场景该怎么做关键参数或机制正常执行完毕finally里释放比较 token 后删除Lua进程崩溃TTL 自动过期兜底SET ... NX PXTTL 大于耗时上限业务耗时超过 TTL循环内续期renew()失败必须中止任务时间不可预测独立守护进程pcntl_fork()仅 CLI时钟不可靠fencing token 兜底存储层校验单调递增序号没有 RedisMySQL 方案GET_LOCK()或唯一索引插入并发抢占有限等待 随机抖动避免惊群与进程池耗尽配置分布式锁的超时释放正确的做法是同时配齐三件事TTL 兜底防崩溃、主动释放走finally、续期在循环里做检查点并且释放时校验持有者身份。如果临界区涉及不可逆的资金或库存变动再加一层 fencing token让存储层成为最终裁判。最后重申标题里的版本问题分布式锁不是 PHP 8.5 的能力而是 Redis 或数据库的能力PHP 版本只影响写法和可用的语法糖。把它当成语言特性去找文档只会在错误的路上打转。
返回列表