PHP 怎么避免加锁

wen PHP项目 2

本文目录导读:

PHP 怎么避免加锁

  1. 数据库层面:乐观锁(推荐)
  2. 使用 Redis 原子操作替代锁
  3. 数据库事务隔离级别调整
  4. 表设计层面:降级锁粒度
  5. 使用消息队列解耦
  6. 读写分离
  7. 行锁 vs 表锁的选择
  8. 代码设计:避免长事务
  9. 使用 PHP 扩展级别的原子操作
  10. 查询设计:利用条件更新避免竞态
  11. 最佳实践选择

在 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 字段,而是:

  1. 写入点赞记录表(无锁或行级锁)。
  2. 定期(如每隔 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 分布式锁
频繁读不频繁写 读写分离
高并发写入热点 消息队列串行化

核心思想:能不用锁就不用锁;必须用锁时,让锁的持有时间尽可能短、锁的粒度尽可能小。

抱歉,评论功能暂时关闭!