本文目录导读:

这是一个非常经典且重要的问题,直接给结论:“先查后更”不一定更安全,反而可能引入新的并发问题;在多数情况下,使用“条件更新”(原子操作)才是更安全的选择。
下面详细拆解这个问题,并给出正确的实践方案。
什么是“先查后更”?
这是指代码中常见的模式:
// 1. 先查询
$user = $db->query("SELECT * FROM users WHERE id = 1");
// 2. 在 PHP 中判断
if ($user['balance'] >= 100) {
// 3. 再更新
$db->query("UPDATE users SET balance = balance - 100 WHERE id = 1");
}
为什么“先查后更”不安全?
它最大的问题在于并发(Race Condition,竞态条件)。
假设两个请求(例如用户同时点击两次“购买”)同时到达:
- 请求 A 查询:余额为 100。
- 请求 B 查询:余额为 100。
- 请求 A 判断:100 >= 100,执行扣款,余额变为 0。
- 请求 B 判断:100 >= 100,执行扣款,余额变为 -100。
这种情况下,“先查”的结果已经过期了,导致数据不一致。
直接 UPDATE 就安全吗?
也不一定,如果你直接写 UPDATE users SET balance = balance - 100 WHERE id = 1,在没有条件判断的情况下,虽然不会产生负数(取决于你是否有 CHECK 约束),但无法防止逻辑上的超卖。
推荐的“安全”写法(原子操作)
核心思路:把“判断”和“更新”合并成一个 SQL 语句,让数据库来保证原子性。
方案 A:条件更新(推荐,最常用)
// 意图:扣款 100,但要求当前余额必须 >= 100
$affectedRows = $db->execute(
"UPDATE users
SET balance = balance - 100
WHERE id = ? AND balance >= 100",
[1]
);
// 关键判断:受影响的行数
if ($affectedRows > 0) {
// 更新成功,说明余额充足,扣款完成
} else {
// 更新失败,说明余额不足(或者用户不存在)
}
原理:数据库在执行 UPDATE 时,会对匹配的行加锁。WHERE balance >= 100 这个条件在锁内判断,是绝对准确的,如果条件不满足,影响行数为 0,你就知道不能扣款。
方案 B:使用事务 + 排他锁(仅当需要先查询复杂逻辑时)
如果逻辑比较复杂,要先读取 A 表数据,计算后写入 B 表”,且计算过程无法用纯 SQL 表达,那么必须用事务:
$pdo->beginTransaction();
try {
// 1. 使用 SELECT ... FOR UPDATE 锁定该行(重要!)
$stmt = $pdo->query("SELECT balance FROM users WHERE id = 1 FOR UPDATE");
$balance = $stmt->fetchColumn();
// 2. 在 PHP 中做业务判断
if ($balance < 100) {
throw new Exception("余额不足");
}
// 3. 执行更新
$pdo->exec("UPDATE users SET balance = balance - 100 WHERE id = 1");
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
}
原理:FOR UPDATE 会锁定这一行数据,在事务提交前,其他请求的 SELECT ... FOR UPDATE 或 UPDATE 会被阻塞,直到第一个事务结束,这样就保证了“查”和“更”之间没有缝隙。
什么时候“先查后更”是安全的?
只有在非并发、低压力、单用户或数据允许最终一致的场景下(比如后台手动修改配置),先查后更才够用,对于涉及金额、库存这类关键数据,一定要避免。
总结建议
- 简单条件(如库存扣减):直接用
UPDATE ... WHERE condition+ 检查rowCount(),这是性能最好、最安全的方式。 - 复杂业务逻辑(如多重条件判断):使用事务 +
SELECT ... FOR UPDATE。 - 绝对不要:在非事务环境下,先
SELECT出来判断,再UPDATE。
如果你使用的是 Laravel 或 ThinkPHP,它们也提供了对应的原子更新方法(如 increment 和 where 条件结合),实战中应优先使用这些框架封装好的原子操作。