本文目录导读:

在 PHP 项目中,选择公平锁还是非公平锁,核心取决于你的业务场景以及对性能和公平性的权衡,由于 PHP 通常不是原生支持多线程(CLI 下的 pthreads 或 parallel 扩展除外),这里的“锁”更多指分布式锁(如 Redis、MySQL、etcd 实现)或进程间锁(如文件锁、信号量)。
以下是详细的业务适配分析和选择指南:
核心概念回顾(仅针对分布式/进程间锁)
- 公平锁:先请求锁的线程/进程,先获得锁(按请求顺序排队)。
- 实现方式:通常用 Redis 有序集合(ZSet)或消息队列 + 锁,每个请求加锁时先排队(按时间戳或自增 ID),锁释放后让队首进程获取。
- 优点:避免“饥饿”现象(某个进程永远拿不到锁)。
- 缺点:性能较低,需要额外的排序和队列维护开销。
- 非公平锁:所有请求随机或按抢占方式争夺锁。
- 实现方式:直接 SETNX(Redis)或
flock+ 自旋。 - 优点:性能高,吞吐量大(无需排队)。
- 缺点:可能造成“线程/进程饥饿”,高并发下锁被频繁抢占,某些进程可能长时间等待。
- 实现方式:直接 SETNX(Redis)或
业务适配选择决策树
| 场景特征 | 推荐锁类型 | 原因 |
|---|---|---|
| 严格先来后到 (如:秒杀排队号、订单号生成、排队叫号系统) | 公平锁 | 必须保证请求顺序,否则用户体验灾难(后请求先处理)。 |
| 高吞吐、允许偶尔插队 (如:缓存刷新、日志同步、非关键数据清理) | 非公平锁 | 性能优先,少量进程多等几毫秒无感知。 |
| 锁持有时间很长 (如:生成大报表、数据迁移) | 公平锁 | 防止一个长任务被无限打断,导致其他任务一直失败重试(即饥饿)。 |
| 锁持有时间极短 (如:原子递增计数器、简单写入) | 非公平锁 | 抢占成本低,排队反而浪费时间。 |
| 资源本身具有排他性 (如:唯一订单号、支付防重) | 两者皆可 | 如果业务不要求顺序,非公平更高效;如果要求顺序(如退款处理),选公平。 |
| 重试成本高 (如:每次获取锁失败需要复杂回滚) | 公平锁 | 减少无意义的自旋重试,按顺序稳定获取。 |
| 请求量极大且锁竞争激烈 (>5000 QPS) | 非公平锁 | 公平锁在抢占锁时,队列排序本身会成为瓶颈(需要 O(log N) 操作每次)。 |
PHP 中的具体实现方案
Redis 实现(最常用)
-
非公平锁:
// 标准 SETNX + EXPIRE + 随机值 $lockKey = 'resource_lock'; $lockValue = uniqid('', true); $ttl = 10; // 秒 if ($redis->set($lockKey, $lockValue, ['NX', 'EX' => $ttl])) { // 获得锁,执行业务逻辑 // ... // 释放锁(需要 Lua 脚本保证原子性) $script = 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end'; $redis->eval($script, [$lockKey, $lockValue], 1); } else { // 获取锁失败,sleep 后重试(非公平的自旋) usleep(500 * 1000); // 500ms } -
公平锁(基于 Redis 有序集合 + Lua):
// 思路:每个请求插入 ZSet(score = 毫秒时间戳 + 请求ID), // 只有 score 最小的请求(队首)才能加锁。 // 下面简化代码,实际生产中需要 Lua 保证原子性队列操作。 $queueKey = 'lock_queue'; $lockKey = 'resource_lock'; $myScore = microtime(true) * 10000; // 高精度分数 // 1. 入队并将当前请求加入队列 $redis->zadd($queueKey, $myScore, session_id()); // session_id 仅示例 // 2. 加锁尝试(ACID 检查) $script = ' local queue = KEYS[1] local lock = KEYS[2] local myId = ARGV[1] -- 获取当前队列的最小score元素 local min = redis.call("zrange", queue, 0, 0, "WITHSCORES") if (#min == 0) then return 0 end -- 队列空 if (min[1] == myId) then -- 我是队首,尝试加锁 local result = redis.call("set", lock, myId, "NX", "EX", 10) if (result) then return 1 else return 0 -- 锁被占(理论上不会发生,但考虑并发) end else return 0 -- 我不是队首,不获得锁 end '; $result = $redis->eval($script, [$queueKey, $lockKey, session_id()], 2); if ($result === 1) { // 获得锁,业务处理 // ... // 释放锁时:从队列删除自己 + 删除锁 $redis->zrem($queueKey, session_id()); $redis->del($lockKey); } else { // 没获得锁,等待并重试(轮询队列) // 注意:避免太频繁调用ZRANGE造成性能问题,建议加随机等待 usleep(200 * 1000); }注:上述公平锁实现极简,生产环境中建议直接使用 Redlock 的变体或成熟的库(如
mutex)。
MySQL 实现
- 非公平锁:
SELECT ... FOR UPDATE(行锁) - 公平锁:MySQL 原生不支持排队的行锁,需要自己维护一个等待队列表 + 事务顺序控制(复杂且低效)。
文件锁(仅单进程/单机)
- 非公平:
flock($fp, LOCK_EX)(默认就是非公平,实际效果是随机的) - 公平:PHP 原生
flock不支持公平锁,需要自己用sem_get+ 信号量模拟(但信号量本身也是非公平的)。
性能与公平性的真实权衡
| 维度 | 非公平锁 | 公平锁 |
|---|---|---|
| 平均获取延迟 | 低 (约0.1ms) | 高 (可能 5-50ms) |
| 最大等待时间 | 理论无限(可能永远获取不到) | 有界(按顺序,最坏 = 持有时间 × N) |
| 数据库/Redis 压力 | 低(一次 Set+NX) | 高(需要维护队列 ZADD/ZRANGE 或有序列表) |
| PHP 代码复杂度 | 低 | 高(需要处理队列原子性、过期清理、节点宕机) |
实战建议:混合使用
在大多数生产 PHP 项目中,非公平锁占 90% 以上,但遇到以下情况才切换公平锁:
- 需要“排他且有序”的操作:
- 生成递增发票号(必须按请求顺序分配)。
- 秒杀最终确认队列(防止后抢到的人先付款成功)。
- 长时间任务守护:
数据迁移脚本(比如一次迁移 10 万条),如果非公平,可能每次刚获得锁就被其他脚本抢走(产生“饥饿”),需要公平锁保证至少完成一轮。
- 第三方 API 频率限流补偿:
多个 PHP 进程使用一个桶(Token bucket),如果非公平,可能导致某些进程长期拿不到令牌,需公平排队。
成熟库推荐
- Symfony Lock 组件:支持 Redis/MySQL/File 锁,默认非公平,但可以配置 Blocking锁(类似公平等待)。
- predis/predis:配合 Lua 可实现简单公平锁。
- hyperf/lock:为 Swoole 协程优化的锁,默认非公平,支持超时。
一句话决策
- 非公平锁 → 大多数 PHP 场景(API 限流、缓存防击穿、写日志、定时任务防重复)。
- 公平锁 → 用户感知到“等待顺序”的场景(排队、下单顺序、资源分配严格的金融操作)。
最后建议:默认选择非公平锁,只在明确需要保证“按请求顺序处理”时才使用公平锁,在 PHP 的分布式环境中,实现一个高性能的公平锁远比非公平复杂,且容易引入新问题(队列堆积、死锁)。