本文目录导读:

- 数据库层面:乐观锁(推荐)
- 使用 Redis 原子操作替代锁
- 数据库事务隔离级别调整
- 表设计层面:降级锁粒度
- 使用消息队列解耦
- 读写分离
- 行锁 vs 表锁的选择
- 代码设计:避免长事务
- 使用 PHP 扩展级别的原子操作
- 查询设计:利用条件更新避免竞态
- 最佳实践选择
在 PHP 中避免加锁(或减少锁竞争),通常是为了提升性能和避免死锁,以下是几种常见的策略,按场景分类:
数据库层面:乐观锁(推荐)
乐观锁的核心是:不加锁,而是在更新时检查数据是否被修改过。
实现方式(使用版本号或时间戳):
// 查询时带上版本号
$user = $db->query("SELECT * FROM users WHERE id = 1");
$version = $user['version'];
// 更新时带上 WHERE 版本号条件
$result = $db->exec(
"UPDATE users SET balance = 100, version = version + 1
WHERE id = 1 AND version = $version"
);
// 检查影响行数,如果为 0 说明数据已被修改
if ($result === 0) {
// 冲突处理:重试或返回错误
throw new Exception('数据已被他人修改');
}
优点:
- 不会持有数据库锁,读取永远不被阻塞。
- 适合读多写少的场景。
使用 Redis 原子操作替代锁
很多场景下,可以用 Redis 的原子指令 来替代显式的锁。
场景:计数器、库存扣减
// 错误方式:先读后写(需要锁)
$count = $redis->get('stock');
$redis->set('stock', $count - 1);
// 正确方式:原子递减(不需要锁)
$redis->decr('stock'); // 或 decrby('stock', 1)
// 检查是否超卖
if ($redis->get('stock') < 0) {
// 回滚
$redis->incr('stock');
throw new Exception('库存不足');
}
场景:分布式锁替代(使用 Redis SETNX)
// 获取分布式锁
$lock = $redis->set('lock:key', 'lock_value', ['NX', 'EX' => 10]);
if ($lock) {
try {
// 业务逻辑
} finally {
$redis->del('lock:key');
}
}
数据库事务隔离级别调整
如果你必须使用数据库锁,可以通过调整隔离级别来减少锁的粒度:
| 隔离级别 | 锁的粒度 | 适用场景 |
|---|---|---|
| READ UNCOMMITTED | 无锁(脏读) | 数据准确性要求不高(如日志) |
| READ COMMITTED | 行级锁 | 大多数业务场景 |
| REPEATABLE READ | 共享锁/间隙锁(MySQL 默认) | 金融、订单等 |
| SERIALIZABLE | 表级锁 | 极少使用,性能极差 |
// 在 MySQL 中设置隔离级别
$pdo->exec("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED");
表设计层面:降级锁粒度
使用冗余字段减少锁冲突
博客的点赞数,不要每次点赞都更新主表的 likes 字段,而是:
- 写入点赞记录表(无锁或行级锁)。
- 定期(如每隔 5 分钟)聚合统计并更新主表。
-- 点赞记录表(每次点赞都 INSERT,没有 UPDATE) INSERT INTO post_likes (post_id, user_id) VALUES (?, ?); -- 定时任务聚合 UPDATE posts p SET likes = (SELECT COUNT(*) FROM post_likes WHERE post_id = p.id) WHERE p.id = ?;
使用消息队列解耦
如果某个操作是高频写入且需要串行化,可以将操作放入队列,串行消费:
// 将写入操作投递到队列
$queue->push([
'type' => 'increment_balance',
'user_id' => 1,
'amount' => 10
]);
// 消费者串行处理(天然无锁)
while ($task = $queue->pop()) {
// 单线程处理,不需要加锁
$db->exec("UPDATE users SET balance = balance + ? WHERE id = ?",
[$task['amount'], $task['user_id']]);
}
读写分离
将读操作和写操作分发到不同的数据库实例:
- 主库:负责写(可以加锁,但锁竞争少)。
- 从库:负责读(完全无锁)。
// 使用 PHP 的数据库读写分离扩展(如 ParagonIE 的 PDO)
$readPdo = new PDO('mysql:host=slave.example.com', ...);
$writePdo = new PDO('mysql:host=master.example.com', ...);
行锁 vs 表锁的选择
在 MySQL 中,确保 使用索引 来触发行锁,避免全表扫描导致表锁:
-- 使用索引,走行锁 UPDATE users SET balance = 100 WHERE id = 1; -- id 有索引 -- 没有索引,可能升级为表锁 UPDATE users SET balance = 100 WHERE email = 'xxx'; -- email 无索引
代码设计:避免长事务
持锁时间越长,锁冲突概率越大,尽量减少事务中的操作:
// 错误:事务中做了大量远程调用
$pdo->beginTransaction();
$pdo->exec("UPDATE ...");
callExternalApi(); // 耗时操作,一直持锁
$pdo->exec("UPDATE ...");
$pdo->commit();
// 正确:仅将必要操作放在事务内
$data = callExternalApi();
$pdo->beginTransaction();
$pdo->exec("UPDATE ... WHERE ...");
$pdo->commit();
使用 PHP 扩展级别的原子操作
如果只是单进程(如 CLI 脚本单进程消费队列),可以用 PHP 扩展:
// Swoole 的原子计数(单进程内原子操作) $atomic = new Swoole\Atomic(100); $atomic->sub(1); echo $atomic->get(); // 99
查询设计:利用条件更新避免竞态
// 场景:扣减用户余额,且不允许负数
$result = $pdo->exec(
"UPDATE users
SET balance = balance - 100
WHERE id = ? AND balance >= 100" // 条件防止超额扣减,无需锁
);
最佳实践选择
| 场景 | 推荐方案 |
|---|---|
| 扣库存/计数 | Redis 原子操作 |
| 更新用户资料 | 乐观锁(版本号) |
| 订单并发扣款 | 条件 UPDATE + 事务 |
| 跨服务共享数据 | Redis 分布式锁 |
| 频繁读不频繁写 | 读写分离 |
| 高并发写入热点 | 消息队列串行化 |
核心思想:能不用锁就不用锁;必须用锁时,让锁的持有时间尽可能短、锁的粒度尽可能小。