在PHP开发的多用户并发场景中,多个请求同时修改同一条数据很容易出现数据覆盖、更新丢失的问题,数据版本控制就是解决这类问题的核心方案,其中乐观锁和悲观锁是最常用的两种实现思路。

一、悲观锁实现原理与PHP示例
悲观锁的核心思想是假设并发冲突发生的概率很高,在操作数据之前先对数据加锁,确保当前操作完成之前,其他事务无法修改该数据。在MySQL数据库中,我们可以通过FOR UPDATE语法实现行级悲观锁,PHP中结合PDO或mysqli扩展即可完成相关操作。
1. 悲观锁实现步骤
- 开启数据库事务
- 查询目标数据时加上
FOR UPDATE锁 - 执行数据更新操作
- 提交事务释放锁
2. PHP代码示例
<?php
// 数据库连接配置
$dbHost = '127.0.0.1';
$dbName = 'test_db';
$dbUser = 'root';
$dbPass = '123456';
$dsn = "mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4";
try {
$pdo = new PDO($dsn, $dbUser, $dbPass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 开启事务
$pdo->beginTransaction();
// 查询数据并加悲观锁,假设用户id为1
$userId = 1;
$querySql = "SELECT id, balance, version FROM user_account WHERE id = ? FOR UPDATE";
$stmt = $pdo->prepare($querySql);
$stmt->execute([$userId]);
$userInfo = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$userInfo) {
throw new Exception('用户不存在');
}
// 模拟业务操作,扣除100元余额
$newBalance = $userInfo['balance'] - 100;
if ($newBalance < 0) {
throw new Exception('余额不足');
}
// 更新余额
$updateSql = "UPDATE user_account SET balance = ? WHERE id = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute([$newBalance, $userId]);
// 提交事务,释放锁
$pdo->commit();
echo "余额更新成功,当前余额:$newBalance";
} catch (Exception $e) {
// 异常时回滚事务
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
echo "操作失败:" . $e->getMessage();
}
?>
需要注意的是,使用悲观锁时必须开启事务,且FOR UPDATE只会锁住查询命中的行,前提是查询条件使用了索引,否则会升级为表锁,影响整体性能。另外,锁的持有时间越长,阻塞其他请求的概率越高,所以要尽量缩短事务的执行时间。
二、乐观锁实现原理与PHP示例
乐观锁的核心思想是假设并发冲突发生的概率很低,不会主动加锁,而是在更新数据时判断数据是否被其他请求修改过,通常通过版本号或者时间戳字段来实现。如果更新时发现版本不匹配,就说明数据已经被修改,需要重试或者提示用户操作失败。
1. 乐观锁实现步骤
- 给数据表增加一个版本号字段
version,或者时间戳字段update_time - 查询数据时同时获取当前的版本号
- 更新数据时,将版本号作为条件,同时版本号加1
- 判断更新语句的影响行数,如果影响行数为0,说明版本不匹配,更新失败
2. PHP代码示例
<?php
// 数据库连接配置同上,省略重复代码
try {
$pdo = new PDO($dsn, $dbUser, $dbPass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$userId = 1;
// 第一次查询获取用户信息和当前版本号
$querySql = "SELECT id, balance, version FROM user_account WHERE id = ?";
$stmt = $pdo->prepare($querySql);
$stmt->execute([$userId]);
$userInfo = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$userInfo) {
throw new Exception('用户不存在');
}
// 模拟业务操作,扣除100元余额
$newBalance = $userInfo['balance'] - 100;
if ($newBalance < 0) {
throw new Exception('余额不足');
}
// 更新数据,条件中带上版本号,同时版本号加1
$oldVersion = $userInfo['version'];
$newVersion = $oldVersion + 1;
$updateSql = "UPDATE user_account SET balance = ?, version = ? WHERE id = ? AND version = ?";
$updateStmt = $pdo->prepare($updateSql);
$updateStmt->execute([$newBalance, $newVersion, $userId, $oldVersion]);
// 判断影响行数
if ($updateStmt->rowCount() === 0) {
throw new Exception('数据已被其他用户修改,请重试');
}
echo "余额更新成功,当前余额:$newBalance,新版本号:$newVersion";
} catch (Exception $e) {
echo "操作失败:" . $e->getMessage();
}
?>
如果需要重试机制,可以在更新失败时重新执行查询和更新的逻辑,不过要设置最大重试次数,避免无限重试消耗资源。除了版本号,也可以使用update_time时间戳实现,更新时判断update_time是否和查询时一致即可,原理和版本号完全一致。
三、两种锁的适用场景对比
我们可以通过下面的表格更清晰地对比两种锁的特点和适用场景:
| 对比项 | 悲观锁 | 乐观锁 |
|---|---|---|
| 加锁方式 | 操作前主动加锁 | 操作前不加锁,更新时校验 |
| 适用场景 | 写多读少、并发冲突概率高的场景 | 读多写少、并发冲突概率低的场景 |
| 性能影响 | 持有锁期间阻塞其他请求,性能开销较大 | 无阻塞,性能开销小 |
| 实现复杂度 | 需要依赖数据库事务和行锁,实现简单 | 需要额外版本字段,更新失败需要处理重试逻辑 |
在实际开发中,我们可以根据业务的具体并发情况选择合适的方案,比如秒杀场景写操作多、冲突概率高,更适合用悲观锁;而普通的信息编辑场景读多写少,用乐观锁更合适。如果业务并发量特别高,还可以结合Redis等缓存工具实现分布式锁,进一步提升版本控制的可靠性。