广告位 · 页头(后台-模块管理-新建模块后替换此区域)

PHP与MySQL:如何安全地实现数据库数值累加更新?

假设一个商品库存为10,两个下单请求几乎同时到达。请求A和请求B各自执行了SELECT,都读到stock=10,然后分别执行UPDATE SET stock=11。最终库存变成11,但正确结果应该是12。这就是并发累加中最常见的丢失更新。类似的问题还会出现在文章阅读量、账户余额、积分变动等场景。只要业务代码先查询再更新,中间没有数据库层的保护,高并发下就一定会踩坑。

PHP与MySQL:如何安全地实现数据库数值累加更新?

一、为什么先SELECT再UPDATE不安全

关系型数据库的并发控制依靠事务隔离级别和锁。MySQL的InnoDB引擎在默认的REPEATABLE READ隔离级别下,普通的SELECT语句读取的是快照,不会对行加锁。这意味着两个事务可以同时读取到相同的stock值。当它们各自执行UPDATE时,后提交的事务会覆盖前一个事务的写入,前者的增量就丢失了。这个过程在代码层面看不出任何错误,但数据已经不一致。

下面是一段典型的错误写法:先读取,再在PHP中计算新值,最后执行更新。表面看逻辑没问题,但在两个请求并发执行时,可能都读到10,然后都写回11。

<?php
$stmt = $pdo->query("SELECT stock FROM products WHERE id = 1");
$row = $stmt->fetch();
$newStock = $row['stock'] + 1;
$pdo->exec("UPDATE products SET stock = $newStock WHERE id = 1");
?>

更隐蔽的问题是,如果UPDATE语句中直接拼接了$newStock,还存在SQL注入的风险。而且即使没有并发,这种写法也增加了网络往返和PHP计算开销。解决思路其实很简单:把累加的计算过程放到数据库的UPDATE语句中,利用数据库的原子性来保证正确性。

二、原子UPDATE:把计算交给数据库

最简单的安全做法是使用一条UPDATE语句直接对列进行自增运算。例如UPDATE products SET stock = stock + 1 WHERE id = 1。数据库执行这条语句时,会读取该行当前已提交的最新值,加1后写回。整个过程是原子的,多个事务同时执行也不会丢失更新,因为行锁保证了串行化修改。

在PHP中,应该使用PDO预处理语句来绑定参数,既安全又清晰。下面代码演示了如何让库存增加指定数量,并判断受影响行数。

<?php
$increment = 1;
$productId = 100;
$stmt = $pdo->prepare("UPDATE products SET stock = stock + ? WHERE id = ?");
$stmt->execute([$increment, $productId]);
$affected = $stmt->rowCount();
if ($affected === 1) {
    echo "库存累加成功";
} else {
    echo "未找到对应商品";
}
?>

rowCount()返回受影响的行数。如果id对应的记录不存在,返回0。如果累加了0(例如增量参数为0),MySQL默认不会改变行,rowCount()也会返回0,这一点在处理逻辑时需要留意。对于单纯的数值累加,这种方案性能最好,因为只执行一次SQL,没有显式事务和锁等待。

另外要注意绑定参数的类型。如果增量是浮点数,可以使用bindValue指定PDO::PARAM_STR或PDO::PARAM_INT,避免隐式类型转换导致精度问题。但无论如何,不要再回到拼接SQL的老路。

三、事务与行锁处理复杂业务条件

原子UPDATE虽然简单,但无法处理“先判断再累加”的场景。比如扣减库存前要确保库存大于0,或者累加积分后要更新用户等级。这时就需要在事务内先锁定目标行,读取当前值,做业务判断,再更新。悲观锁的典型做法是使用SELECT ... FOR UPDATE,它会在事务期间锁定读取的行,其他事务必须等待。

下面的代码展示了一个完整的扣减库存事务:开启事务,查询并锁定行,判断库存是否充足,不够则回滚并抛出异常,足够则更新并提交。

<?php
$pdo->beginTransaction();
try {
    $stmt = $pdo->prepare("SELECT stock FROM products WHERE id = ? FOR UPDATE");
    $stmt->execute([$productId]);
    $stock = $stmt->fetchColumn();
    if ($stock === false) {
        throw new \Exception("商品不存在");
    }
    if ($stock <= 0) {
        throw new \Exception("库存不足");
    }
    $newStock = $stock - 1;
    $update = $pdo->prepare("UPDATE products SET stock = ? WHERE id = ?");
    $update->execute([$newStock, $productId]);
    $pdo->commit();
} catch (\Exception $e) {
    $pdo->rollBack();
    echo "操作失败: " . $e->getMessage();
}
?>

注意这里使用了反斜杠命名空间形式的异常类\Exception,保证在全局命名空间下正确捕获。事务必须配合try/catch,任何一步出错都要回滚,避免长时间占用锁。锁的粒度与事务隔离级别有关,默认的可重复读下,FOR UPDATE会锁定扫描到的索引记录。如果WHERE条件没有走索引,可能会锁全表,因此务必给id等条件字段建立索引。

死锁是另一个需要警惕的问题。当两个事务以不同顺序锁定多行时,可能互相等待。应当统一加锁顺序,并尽量缩短事务执行时间,把远程调用、日志写入等耗时操作放到事务外。

四、乐观锁版本号方案与适用场景

悲观锁虽然安全,但阻塞其他事务,在高并发下可能影响吞吐。如果业务冲突概率不高,可以采用乐观锁。方法是在表中增加一个version字段,读取时拿到版本号,更新时把版本号作为WHERE条件,并同时将版本号加1。如果受影响行数为0,说明期间有其他事务修改了数据,可以选择重试或返回冲突。

<?php
$productId = 100;
$increment = 1;
$maxRetry = 3;
$success = false;
for ($i = 0; $i < $maxRetry; $i++) {
    $stmt = $pdo->prepare("SELECT stock, version FROM products WHERE id = ?");
    $stmt->execute([$productId]);
    $row = $stmt->fetch();
    if (!$row) break;
    $newStock = $row['stock'] + $increment;
    $newVersion = $row['version'] + 1;
    $update = $pdo->prepare("UPDATE products SET stock = ?, version = ? WHERE id = ? AND version = ?");
    $update->execute([$newStock, $newVersion, $productId, $row['version']]);
    if ($update->rowCount() === 1) {
        $success = true;
        break;
    }
}
if ($success) {
    echo "累加成功";
} else {
    echo "更新冲突,请重试";
}
?>

乐观锁的核心是版本号或时间戳条件,它不会阻塞其他事务,冲突时由应用层决定重试策略。但代价是增加了重试逻辑和代码复杂度。如果冲突频繁,反复重试可能比悲观锁更消耗资源。

总结起来,优先级应该是:能只用原子UPDATE就不要引入锁;有业务判断就用事务加FOR UPDATE;读多写少、冲突少的场景再考虑乐观锁。无论哪种方案,都要坚持预处理语句和参数绑定,从源头避免SQL注入。

广告位 · 文章底部(后台-模块管理替换此区域)