在高并发的业务场景中,比如商品库存扣减、订单状态更新等操作,多个请求同时修改同一条数据很容易出现数据覆盖、库存超扣等问题,乐观锁就是解决这类并发问题的常用方案,其中通过Update语句增加版本号是最主流的实现方式。

乐观锁与版本号机制的核心原理
乐观锁的核心思想是假设并发冲突发生的概率较低,因此不会在读取数据时就加锁,而是在更新数据时判断数据是否被其他事务修改过。版本号机制是乐观锁的常见实现形式,具体逻辑如下:
- 数据表中新增一个
version字段,初始值为0或者1 - 读取数据时,同时获取当前数据的
version值 - 更新数据时,在Update语句的Where条件中加入
version等于之前读取到的版本号,同时把version值加1 - 如果Update语句返回的影响行数为1,说明更新成功,没有其他事务修改过该数据;如果影响行数为0,说明数据已经被其他事务修改,当前更新失败,需要进行重试或者提示用户
数据库表结构设计
我们以商品库存表为例,设计包含版本号字段的表结构,这里使用MySQL数据库作为示例:
-- 创建商品库存表,包含版本号字段
CREATE TABLE `product_stock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_name` varchar(50) NOT NULL COMMENT '商品名称',
`stock_num` int(11) NOT NULL COMMENT '库存数量',
`version` int(11) NOT NULL DEFAULT '1' COMMENT '版本号,用于乐观锁控制',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';
-- 插入一条测试数据
INSERT INTO `product_stock` (`product_name`, `stock_num`, `version`) VALUES ('测试商品', 100, 1);
PHP实现乐观锁的完整代码
我们使用PDO扩展连接数据库,实现商品库存扣减的乐观锁控制逻辑,完整代码如下:
<?php
// 数据库配置
$dbConfig = [
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'test_db',
'username' => 'root',
'password' => '123456',
'charset' => 'utf8mb4'
];
// 连接数据库
try {
$dsn = "mysql:host={$dbConfig['host']};port={$dbConfig['port']};dbname={$dbConfig['dbname']};charset={$dbConfig['charset']}";
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die("数据库连接失败:" . $e->getMessage());
}
// 商品ID,假设要扣减ID为1的商品库存
$productId = 1;
// 要扣减的库存数量
$deductNum = 1;
// 重试次数,最多重试3次
$retryTimes = 3;
$success = false;
for ($i = 0; $i < $retryTimes; $i++) {
// 1. 读取当前商品的库存和版本号
$querySql = "SELECT `stock_num`, `version` FROM `product_stock` WHERE `id` = :product_id";
$stmt = $pdo->prepare($querySql);
$stmt->execute([':product_id' => $productId]);
$productInfo = $stmt->fetch(PDO::FETCH_ASSOC);
// 判断商品是否存在
if (empty($productInfo)) {
die("商品不存在");
}
// 判断库存是否足够
if ($productInfo['stock_num'] < $deductNum) {
die("商品库存不足");
}
// 2. 执行带版本号的Update语句
$updateSql = "UPDATE `product_stock`
SET `stock_num` = `stock_num` - :deduct_num,
`version` = `version` + 1
WHERE `id` = :product_id
AND `version` = :old_version";
$updateStmt = $pdo->prepare($updateSql);
$updateParams = [
':deduct_num' => $deductNum,
':product_id' => $productId,
':old_version' => $productInfo['version']
];
$updateStmt->execute($updateParams);
$affectedRows = $updateStmt->rowCount();
// 3. 判断更新是否成功
if ($affectedRows == 1) {
$success = true;
echo "库存扣减成功,当前剩余库存:" . ($productInfo['stock_num'] - $deductNum) . PHP_EOL;
break;
} else {
echo "第" . ($i + 1) . "次扣减失败,数据已被其他请求修改,准备重试" . PHP_EOL;
// 重试前可以短暂休眠,避免无效重试
usleep(100000); // 休眠100毫秒
}
}
if (!$success) {
echo "库存扣减失败,重试次数已用完" . PHP_EOL;
}
并发场景下的验证方法
我们可以通过模拟并发请求来验证乐观锁的效果,比如同时发起10个请求扣减同一商品的库存,每个请求扣减1件,初始库存为100,最终库存应该为90,不会出现超扣的情况。
如果是本地测试,可以使用Apache Bench工具发起并发请求,命令示例如下:
# 发起10个并发请求,总共请求10次,调用库存扣减的PHP接口 ab -n 10 -c 10 http://127.0.0.1/stock_deduct.php
执行完成后查询数据库的商品库存,会发现库存正确扣减,没有出现负数或者扣减数量不对的情况,说明乐观锁生效。
注意事项与适用场景
适用场景
- 并发冲突概率较低的场景,比如大部分业务场景中,同一数据同时被修改的概率不高
- 读多写少的业务场景,乐观锁不会阻塞读操作,性能相对更好
注意事项
- 重试次数需要合理设置,避免无限重试导致请求阻塞,一般设置3到5次即可
- 版本号字段建议使用整型,自增效率高,也可以根据业务需求使用时间戳作为版本号,但要注意时间戳的精度问题
- 如果业务场景并发冲突非常高,乐观锁的重试成本会很高,此时更适合使用悲观锁
- Update语句中版本号的判断和更新要放在同一个语句中,避免先查询再更新的非原子操作问题
需要注意的是,乐观锁的版本号机制只适用于单数据库实例的场景,如果是分库分表或者分布式数据库场景,需要结合分布式锁或者其他分布式乐观锁方案实现。