CatchAdmin PHP 后台管理框架 Logo CatchAdmin

两个 PHP 请求同时命中同一数据库行时会发生什么

设想一个演唱会售票场景。只剩一个座位。两个用户在完全相同的毫秒点击“购买”。两个请求都读取数据库:还有一个座位。两个请求都继续执行。两个请求都完成扣款。两个请求都把座位标记为已售出。

现在,系统拥有两个已付款客户,却只有一个座位。

没有错误被抛出。没有异常被记录。代码完全按照写法执行,只是这段代码没有面向“两个事情同时发生”的现实世界编写,而所有生产 Web 应用都运行在这个现实世界里。

这就是竞态条件。它是 PHP 应用中最常见的缺陷之一,在开发环境中极难复现,在生产环境中又极具破坏性。修复方式并不复杂,前提是开发者知道它的存在,并主动应用对应手段。

下面说明两个请求同时碰撞到同一数据库行时实际会发生什么,以及阻止问题发生的具体工具。

TL;DR 快速版

当两个 PHP 请求同时读取同一行时,它们会看到相同的数据,并都基于“自己是唯一执行者”的假设继续运行,最终造成重复处理、超卖、重复记录或状态损坏。

PHP 是无状态的,请求之间互不共享内存。数据库是共享状态所在的位置;缺少协调机制时,碰撞随时可能发生。

三类主要解决方案是:悲观锁(锁住行,让同一时间只有一个请求继续执行)、乐观锁(事后检测碰撞并重试)、原子数据库操作(把逻辑移动到一个不可分割的查询中)。

每种方法都有不同取舍:悲观锁最安全,但速度较慢;乐观锁更快,但需要重试逻辑;原子操作最快,但适用于更简单的状态转换。

对于关键操作,例如支付、库存、座位预订,必须使用显式并发控制。可靠的工程策略是让数据库执行开发者明确设计的并发规则。

你将学到什么

  • 并发 PHP 请求发生时,数据库层面实际会发生什么
  • PHP 的无状态模型为什么容易意外制造竞态条件
  • 三类并发解决方案及其适用场景
  • 在 PHP 中实现悲观锁、乐观锁和原子操作的具体方式
  • 如何正确使用数据库事务,以及会让事务失效的常见错误
  • 数据库能力不足时如何使用基于 Redis 的分布式锁

数据库层面实际会发生什么

理解这个问题,需要先理解时间线。

假设两个请求 R1 和 R2 同时命中购票接口。数据库看到的过程如下,按纳秒级顺序展开:

text
T=0ms   R1: SELECT quantity FROM tickets WHERE id = 1
T=0ms   R2: SELECT quantity FROM tickets WHERE id = 1
T=0ms   R1: reads quantity = 1 ✓
T=0ms   R2: reads quantity = 1 ✓
T=1ms   R1: quantity > 0, proceed with purchase
T=1ms   R2: quantity > 0, proceed with purchase
T=2ms   R1: charge customer A
T=2ms   R2: charge customer B
T=3ms   R1: UPDATE tickets SET quantity = 0 WHERE id = 1
T=3ms   R2: UPDATE tickets SET quantity = 0 WHERE id = 1
T=4ms   R1: INSERT INTO orders (ticket_id, customer_id) VALUES (1, A)
T=4ms   R2: INSERT INTO orders (ticket_id, customer_id) VALUES (1, B)

两个客户都被扣款。两张订单都被创建。库存数量正确显示为零。与此同时,数据库里已经固化了一次欺诈性的重复销售,日志中没有任何能够说明系统出错的证据。

产生该问题的代码如下:

php
// 竞态条件:看起来完全正确,实际存在问题
public function purchase(int $ticketId, int $customerId): bool
{
    $ticket = $this->db->fetchOne(
        "SELECT * FROM tickets WHERE id = ?", [$ticketId]
    );

    if ($ticket['quantity'] <= 0) {
        throw new OutOfStockException("No tickets available");
    }

    // ← 间隙:这两行之间任何事情都可能发生
    //   R2 在 R1 写入前完成读取。两个请求都看到 quantity = 1。
    $this->db->execute(
        "UPDATE tickets SET quantity = quantity - 1 WHERE id = ?",
        [$ticketId]
    );

    $this->db->execute(
        "INSERT INTO orders (ticket_id, customer_id) VALUES (?, ?)",
        [$ticketId, $customerId]
    );

    return true;
}

SELECTUPDATE 之间的间隙就是竞态条件存在的位置。在普通单用户条件下,这段代码运行得非常完美。并发负载下,重复销售只是时间问题。

为什么 PHP 容易让这个问题发生

PHP 的“无共享”架构是其历史优势之一:每个请求都有自己的内存空间、变量和状态。请求结束时,所有内容都会被清理。请求之间没有共享内存,也没有会被破坏的全局状态。

这种无共享模型也意味着,每个 PHP 请求确实不知道其他请求正在同时运行。请求 R1 不知道 R2 的存在。语言运行时没有内置协调层。没有互斥锁、信号量或通道。

数据库是 PHP 请求共享状态的唯一核心位置。数据库提供并发工具,但 PHP 开发者必须有意识地使用它们。它们不会自动出现。ORM 默认不会加上它们。框架也不会在代码存在竞态条件时给出警告。

开发者必须主动识别。

方案一:悲观锁 SELECT FOR UPDATE

悲观锁基于最保守的假设:如果正在读取一行并打算修改它,很可能其他请求也正在做同样的事情。因此,在读取的那一刻就锁住该行。

SELECT ... FOR UPDATE 会在 MySQL/PostgreSQL 中获取排他行锁。任何其他事务如果尝试用 FOR UPDATE 读取同一行,都会阻塞,直到第一个事务提交或回滚。

php
public function purchase(int $ticketId, int $customerId): bool
{
    $this->db->beginTransaction();

    try {
        // 锁住该行,R2 会在这里阻塞,直到 R1 提交
        $ticket = $this->db->fetchOne(
            "SELECT * FROM tickets WHERE id = ? FOR UPDATE",
            [$ticketId]
        );

        if ($ticket['quantity'] <= 0) {
            $this->db->rollBack();
            throw new OutOfStockException("No tickets available");
        }

        $this->db->execute(
            "UPDATE tickets SET quantity = quantity - 1 WHERE id = ?",
            [$ticketId]
        );

        $this->db->execute(
            "INSERT INTO orders (ticket_id, customer_id) VALUES (?, ?)",
            [$ticketId, $customerId]
        );

        $this->db->commit();

        return true;
    } catch (\Exception $e) {
        $this->db->rollBack();
        throw $e;
    }
}

现在,时间线会变成这样:

text
T=0ms   R1: BEGIN TRANSACTION
T=0ms   R2: BEGIN TRANSACTION
T=0ms   R1: SELECT ... FOR UPDATE → acquires lock, reads quantity = 1
T=0ms   R2: SELECT ... FOR UPDATE → BLOCKS (waiting for R1's lock)
T=1ms   R1: quantity > 0, proceed
T=2ms   R1: UPDATE quantity to 0
T=3ms   R1: INSERT order for customer A
T=3ms   R1: COMMIT → releases lock
T=3ms   R2: lock acquired, reads quantity = 0
T=3ms   R2: quantity <= 0 → throws OutOfStockException
T=3ms   R2: ROLLBACK

客户 B 收到“库存不足”错误。系统创建一张订单,处理一次扣款。这就是正确行为。

关键细节是:FOR UPDATE 只在事务内部生效。在事务外部,锁会被获取并立即释放,无法保护任何内容。beginTransaction() 调用就是保护机制本身。

适合使用悲观锁的场景:

  • 高竞争场景,碰撞频繁发生,例如秒杀、有限库存、座位预订
  • 碰撞代价高昂或难以撤销的操作,例如支付、财务记录
  • 需要读取当前状态、做出决策并写入,且这三步必须构成一个原子单元的工作流

取舍是:被阻塞的请求需要等待。在极端负载下,锁竞争可能成为瓶颈。对于大多数应用,这并不重要。对于需要在同一批行上处理数千个并发写入的高吞吐系统,它会成为问题。

方案二:乐观锁版本列

乐观锁采用相反的假设:碰撞很少发生,因此先不加锁。它会在事后检测碰撞是否发生,然后重试或优雅失败。

实现方式使用一个版本列,有时也会使用 updated_at 时间戳,有时使用整数 version。读取一行时,同时读取其版本。写入时,在 WHERE 子句中带上这个版本。如果版本自读取后发生变化,说明另一个请求已经修改该行,UPDATE 会影响零行,由此可以知道碰撞已经发生。

先添加版本列:

sql
ALTER TABLE tickets ADD COLUMN version INT NOT NULL DEFAULT 0;

PHP 代码如下:

php
public function purchase(int $ticketId, int $customerId): bool
{
    $maxRetries = 3;
    $attempt    = 0;

    while ($attempt < $maxRetries) {
        $ticket = $this->db->fetchOne(
            "SELECT * FROM tickets WHERE id = ?", [$ticketId]
        );

        if ($ticket['quantity'] <= 0) {
            throw new OutOfStockException("No tickets available");
        }

        $this->db->beginTransaction();

        try {
            // 只有版本自读取后没有变化时,UPDATE 才会成功
            $affected = $this->db->execute(
                "UPDATE tickets
                 SET quantity = quantity - 1,
                     version  = version + 1
                 WHERE id      = ?
                   AND version = ?    -- ← 乐观锁检查
                   AND quantity > 0", -- 安全保护
                [$ticketId, $ticket['version']]
            );

            if ($affected === 0) {
                // 另一个请求在读取和写入之间修改了该行
                $this->db->rollBack();
                $attempt++;
                usleep(random_int(1000, 5000)); // 小幅随机退避
                continue;
            }

            $this->db->execute(
                "INSERT INTO orders (ticket_id, customer_id) VALUES (?, ?)",
                [$ticketId, $customerId]
            );

            $this->db->commit();

            return true;
        } catch (\Exception $e) {
            $this->db->rollBack();
            throw $e;
        }
    }

    throw new \RuntimeException("Could not complete purchase after {$maxRetries} attempts. Please try again.");
}

两个并发请求会发生如下过程:

text
T=0ms   R1: SELECT ticket (version=5, quantity=1)
T=0ms   R2: SELECT ticket (version=5, quantity=1)
T=1ms   R1: UPDATE WHERE version=5 → affects 1 row ✓ (version now 6)
T=1ms   R2: UPDATE WHERE version=5 → affects 0 rows ✗ (version is now 6)
T=1ms   R1: INSERT order for customer A, COMMIT
T=1ms   R2: detects affected=0, rolls back, retries
T=2ms   R2: SELECT ticket (version=6, quantity=0)
T=2ms   R2: quantity <= 0 → throws OutOfStockException

适合使用乐观锁的场景:

  • 低到中等竞争场景,大多数读取不会碰撞
  • 读多写少的工作负载,希望避免每次读取都承担锁开销
  • 分布式系统中,跨服务使用悲观锁并不现实
  • 希望向用户提供明确的“请重试”体验

取舍是:重试逻辑会增加复杂度。在高竞争下,重试会叠加;持续输掉竞争的请求会在重复尝试中消耗 CPU 和数据库连接。随机退避 usleep(random_int(...)) 有帮助,但在持续高竞争下,乐观锁的退化方式更难控制。

方案三:原子数据库操作

对于更简单的状态转换,最干净的解决方案是直接跳过“读取-修改-写入”循环,把所有事情放进一个原子查询。

php
public function purchase(int $ticketId, int $customerId): bool
{
    // 一个查询完成读取、检查和扣减,整体具备原子性
    $affected = $this->db->execute(
        "UPDATE tickets
         SET quantity = quantity - 1
         WHERE id       = ?
           AND quantity > 0",
        [$ticketId]
    );

    if ($affected === 0) {
        throw new OutOfStockException("No tickets available");
    }

    $this->db->execute(
        "INSERT INTO orders (ticket_id, customer_id) VALUES (?, ?)",
        [$ticketId, $customerId]
    );

    return true;
}

WHERE quantity > 0 子句就是保护条件。数据库会在一个不可分割的操作中评估条件并执行扣减。没有间隙,也就没有竞态。如果两个请求同时触发,数据库会将它们串行化:一个成功,另一个不扣减任何内容并返回 affected = 0

这是最优雅的解决方案,但它只适用于以下情况:

  • 逻辑可以表达为单条 SQL UPDATE
  • 决定写入什么之前不需要读取该行数据
  • 操作不涉及多个必须一起更新的表

对于购票示例中的“检查数量并扣减”,它非常合适。对于支付流程,如果需要读取当前余额、应用业务规则、扣款,然后跨三张表写入新余额,就需要带锁的完整事务。

方案四:将原子更新与事务结合

大多数真实场景需要两者结合。原子 UPDATE 负责数量保护,事务确保订单 INSERT 与它一起成功,或一起回滚。

php
public function purchase(int $ticketId, int $customerId): bool
{
    $this->db->beginTransaction();

    try {
        // 带保护条件的原子扣减,无需 SELECT
        $affected = $this->db->execute(
            "UPDATE tickets
             SET quantity = quantity - 1
             WHERE id       = ?
               AND quantity > 0",
            [$ticketId]
        );

        if ($affected === 0) {
            $this->db->rollBack();
            throw new OutOfStockException("No tickets available");
        }

        $this->db->execute(
            "INSERT INTO orders (ticket_id, customer_id) VALUES (?, ?)",
            [$ticketId, $customerId]
        );

        // 其他必须一起成功的操作
        $this->notifier->queueConfirmationEmail($customerId, $ticketId);

        $this->db->commit();

        return true;
    } catch (\Exception $e) {
        $this->db->rollBack();
        throw $e;
    }
}

这是覆盖大多数购买、预订和库存场景的模式:对稀缺资源使用原子保护条件,用事务保证多步骤操作的一致性。

方案五:使用 Redis 分布式锁

设想一个系统,其中关键资源是一个执行流程。每个用户只能有一个后台任务。每个订单只能有一个支付处理请求。每份报表只能有一个文件生成流程。需要保护的“东西”是执行本身。

对于这些场景,需要分布式锁,也就是一种能够跨 PHP 进程、跨服务器、跨服务工作的协调机制。

Redis 是这类场景的标准工具。SET NX PX 命令具备原子性:只有 key 不存在时才设置该 key(NX),并设置毫秒级过期时间(PX)。只有一个调用者能拿到锁。其他调用者会得到 nil

php
class RedisLock
{
    private \Redis $redis;

    public function __construct(\Redis $redis)
    {
        $this->redis = $redis;
    }

    /**
     * 获取锁。成功时返回 token,失败时返回 null。
     * token 用于安全释放锁。
     */
    public function acquire(string $resource, int $ttlMs = 5000): ?string
    {
        $token = bin2hex(random_bytes(16)); // 每次锁获取都有唯一 token

        $result = $this->redis->set(
            "lock:{$resource}",
            $token,
            ['NX', 'PX' => $ttlMs]
        );

        return $result ? $token : null;
    }

    /**
     * 释放锁,且只在 token 匹配时释放。
     * 防止慢进程释放其他进程获取的锁。
     */
    public function release(string $resource, string $token): bool
    {
        // Lua 脚本保证检查与删除具备原子性
        $script = <<<LUA
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
        LUA;

        return (bool) $this->redis->eval(
            $script,
            ["lock:{$resource}", $token],
            1
        );
    }
}

在支付处理器中使用:

php
public function processPayment(int $orderId): void
{
    $lock  = new RedisLock($this->redis);
    $token = $lock->acquire("payment:order:{$orderId}", ttlMs: 10000);

    if ($token === null) {
        throw new \RuntimeException(
            "Payment for order {$orderId} is already being processed. Please wait."
        );
    }

    try {
        $this->chargeCard($orderId);
        $this->updateOrderStatus($orderId, 'paid');
        $this->sendReceipt($orderId);
    } finally {
        $lock->release("payment:order:{$orderId}", $token);
    }
}

finally 块确保即使处理中途抛出异常,锁也始终会被释放。TTL(10000ms = 10 秒)是安全网:如果进程在释放前崩溃,锁会自动过期,避免永久停留。

token 非常重要。缺少它时,慢进程可能会释放一个已经在原始 TTL 过期后由其他进程获取的新锁,从而制造两个进程同时运行的窗口。Lua 脚本的检查并删除保证释放操作具备原子性:只有获取锁的进程才能释放它。

适合使用 Redis 锁的场景:

  • 保护一个执行流程
  • 跨服务协调
  • 按资源维度进行限流
  • 防止后台任务重复执行
  • 需要保护的共享状态位于数据库之外的场景

事务中最常见的错误

事务是这一切的基础,也是许多开发者犯错并让其他保护手段失效的位置。

php
// ❌ 这个事务没有保护有价值的内容
$this->db->beginTransaction();
$ticket = $this->db->fetchOne("SELECT * FROM tickets WHERE id = ?", [$ticketId]);
$this->db->commit(); // ← 立即提交,锁如果存在也会在这里释放

// ← 间隙重新出现。事务已经结束。
if ($ticket['quantity'] > 0) {
    $this->db->execute("UPDATE tickets SET quantity = quantity - 1 WHERE id = ?", [$ticketId]);
}

事务在 commit() 处结束。其后的所有内容都位于事务之外。这种模式先开启事务,在事务中读取,写入前提交,然后再写入,本质上是在给竞态条件增加额外步骤。

规则是:事务必须同时包含读取和写入,并且提交必须发生在二者之后。

php
// ✓ 正确:读取和写入都在事务内部
$this->db->beginTransaction();

try {
    $ticket = $this->db->fetchOne(
        "SELECT * FROM tickets WHERE id = ? FOR UPDATE", [$ticketId]
    );

    // ... 业务逻辑 ...
    $this->db->execute(
        "UPDATE tickets SET quantity = quantity - 1 WHERE id = ?", [$ticketId]
    );

    $this->db->commit(); // ← 在读取和写入之后提交
} catch (\Exception $e) {
    $this->db->rollBack();
    throw $e;
}

第二个错误是自动提交模式。大多数 PHP 数据库连接默认运行在自动提交模式下,每个查询都是自己的隐式事务,并会立即提交。调用 beginTransaction() 会在该事务持续期间禁用自动提交。提交或回滚后,自动提交恢复。

结果是:在 beginTransaction() 之外使用 SELECT FOR UPDATE 会获取锁并立即释放锁。保护窗口实际上为零。调试意外竞态条件时,始终要验证连接的自动提交设置。

真实场景:秒杀

设想一次秒杀。100 件商品。10,000 个并发用户。所有用户都在同一个 30 秒窗口点击“购买”。

悲观锁(FOR UPDATE)可以工作,但在这种竞争程度下,等待事务队列会迅速增长。每个事务都会在工作持续期间持有锁,包括数据库写入,以及可能调用外部支付 API。如果支付处理需要两秒,而 1,000 个并发请求在同一把锁后排队,就会形成严重瓶颈。

极端并发下更合适的架构如下。

步骤 1:使用 Redis 做原子预扣减。

php
// 原子扣减 Redis 计数器
$remaining = $this->redis->decr("inventory:item:{$itemId}");

if ($remaining < 0) {
    // 补偿:某个请求将计数扣至负数,把它加回来
    $this->redis->incr("inventory:item:{$itemId}");
    throw new OutOfStockException();
}

// 到这里,已经获得一个由 Redis 保证的“预留”

DECR 在 Redis 中具备原子性。两个调用不会互相竞争。一个得到 remaining = 1,下一个得到 remaining = 0,再下一个得到 remaining = -1 并执行补偿。只有得到 remaining >= 0 的调用者继续执行。

步骤 2:异步订单处理。

获得 Redis 预扣减资格的调用者会获得预留 token,并进入异步处理队列。实际数据库写入、支付扣款和履约由后台任务完成。秒杀页面响应会很快,因为“是否售出”的决策在 Redis 的微秒级原子操作上完成,避开了数据库锁竞争。

步骤 3:让 Redis 与数据库对账。

后台任务处理队列、写入订单到数据库并确认销售。如果支付失败,Redis 计数器会被加回,预留被释放,商品重新可售。

这种架构能在 100 件商品、10,000 个并发用户场景下降低数据库瓶颈风险。取舍是复杂度:Redis 成为依赖,异步队列增加最终履约延迟,对账逻辑也扩大了代码维护面。

对于大多数应用,在事务中使用 SELECT FOR UPDATE 是正确答案。对于极端规模,需要在前面增加 Redis 层。

如何选择工具

简化后的决策矩阵如下。

悲观锁(SELECT FOR UPDATE):适用于竞争高、碰撞代价高,并且需要先读取当前状态再决定写入内容的场景。易于推理。适用于任何使用关系型数据库的应用。财务交易、库存、座位预订的首选。

乐观锁(版本列):适用于竞争低、大多数读取不会碰撞,并且可以承受偶发重试开销的场景。它也适合数据库锁会引入跨服务依赖的分布式系统。读取性能优于悲观锁。

带保护条件的原子 UPDATE:适用于整个操作都能表达为一条 SQL 的场景。锁开销为零。代码最干净。适用于更简单的状态转换,例如扣减、递增、带保护条件的状态变更。

Redis 分布式锁:适用于保护执行流程、跨服务或跨服务器协调,或者数据库级锁已经不足以覆盖保护范围的场景。

组合方式(原子 UPDATE + 事务):适用于常见的中间地带,也就是带保护条件的状态变更需要与同一事务中的相关写入保持一致。

需要避免的陷阱

在写入之前提交事务。commit() 之后的任何工作都位于事务之外。如果写入失败,读取已经提交,无法回滚。原子性保证已经消失。

在事务外使用 FOR UPDATE。锁会被获取并立即释放。系统没有获得保护,却付出了开销。始终用 beginTransaction() 包裹 FOR UPDATE 查询。

在原子更新中忘记 AND quantity > 0 保护条件。缺少它时,数量可能变成负数。两个并发请求都可能通过 affected > 0 检查:第一个扣减到零,第二个扣减到负一。保护条件让 UPDATE 自身成为检查。

在锁定事务中执行长时间运行的工作。如果事务中包含一次外部支付 API 调用,而该调用可能耗时 2 到 5 秒,数据库锁会在整个期间被持有。所有等待同一行的其他请求都会阻塞。锁定事务应尽可能短;可行时,在提交后执行支付调用。

未给 Redis 锁设置 TTL。如果持有锁的进程崩溃,锁会永久停留。后续所有请求都会无限期阻塞。始终设置一个长于预期最大执行时间、同时又不会在崩溃时造成数小时停机的 TTL。

信任应用层唯一性检查,忽视数据库约束。插入前先检查重复是否存在,本身就是竞态条件。两个请求都检查,都发现没有重复,然后都插入。使用数据库级 UNIQUE 约束并捕获约束违例;数据库会以原子方式强制唯一性。

php
// ❌ 竞态条件
$existing = $this->db->fetchOne("SELECT id FROM orders WHERE ref = ?", [$ref]);

if (!$existing) {
    $this->db->execute("INSERT INTO orders (ref, ...) VALUES (?, ...)", [$ref, ...]);
}

// ✓ 让数据库强制执行
try {
    $this->db->execute("INSERT INTO orders (ref, ...) VALUES (?, ...)", [$ref, ...]);
} catch (\PDOException $e) {
    if ($e->getCode() === '23000') { // 完整性约束违例
        throw new DuplicateOrderException("Order {$ref} already exists");
    }

    throw $e;
}

迷你问答

问:把代码包进事务就会自动防止竞态条件吗?
不会。事务保证原子性,也就是全有或全无,以及一致性;但默认情况下并不保证所需的隔离性。缺少 FOR UPDATE 或等价隔离级别时,两个事务可以同时读取同一行并继续执行。事务防止部分写入,并不会自动阻止并发读取。

问:应该使用什么隔离级别?
MySQL 默认的 REPEATABLE READ 可以防止事务内的幻读,但不能防止导致重复销售的丢失更新问题。SERIALIZABLE 隔离级别可以防止它,但性能开销显著,而且大多数应用不需要让每个查询都运行在该级别。对需要保护的特定行显式使用 FOR UPDATE 更有针对性,也更实用。

问:Eloquent(Laravel ORM)会自动处理这个问题吗?
不会。Eloquent 的 update()save() 方法是单条查询;它们不会给前面的 find() 调用自动添加 FOR UPDATE。Laravel 提供 DB::transaction() 来包裹事务,也在查询构造器上提供 lockForUpdate() / sharedLock()。开发者需要显式使用它们。

php
// Laravel 悲观锁
DB::transaction(function () use ($ticketId, $customerId) {
    $ticket = Ticket::where('id', $ticketId)->lockForUpdate()->first();

    if ($ticket->quantity <= 0) {
        throw new OutOfStockException();
    }

    $ticket->decrement('quantity');

    Order::create(['ticket_id' => $ticketId, 'customer_id' => $customerId]);
});

问:如何测试竞态条件?
最可靠的方法是使用 ab(Apache Bench)或 k6 做并行负载测试:针对数量为 1 的单个商品,向购买接口发送 50 个并发请求,然后统计生成的订单数。如果结果超过一条,就说明存在竞态条件。单元测试按顺序运行,无法复现依赖时序的缺陷。

为什么它现在比过去更重要

并发缺陷现在比十年前更危险,原因有两个。

第一,流量模式已经改变。点击“购买”的用户现在会与自动化机器人、运行脚本的黄牛,以及高速网络上的其他用户竞争;这些请求都可能在同一个毫秒窗口内发出。“请求一个接一个到达”的假设过去也不完全成立,如今更不成立。

第二,PHP 的部署模型已经改变。传统 PHP-FPM 配置会生成多个 worker 进程,并发一直存在。但现代 PHP 应用越来越多地运行在负载均衡器之后,分布在多台服务器上,处于可以在数秒内水平扩展的容器化环境中,并通过并行处理任务的队列 worker 运行。竞态条件的暴露面已经扩大。

理解并发的开发者未必会写更多代码。他们写的是能在应用实际运行条件下保持正确的代码,而不仅是在开发环境模拟的单用户、单请求场景下正确。

收束:代码库里已经存在的竞态条件

现在设想对代码库做一次搜索,找出所有先对同一行执行 SELECT,随后执行 UPDATE 的位置,并且它们之间没有 FOR UPDATE 或原子保护条件。

在大多数 PHP 应用中,这个搜索会返回结果。通常不止一个。它们还经常出现在应用最敏感的部分:支付、库存、用户余额、订阅限制。

修复方式并不复杂。给 SELECTFOR UPDATE。给表加版本列。给 UPDATEWHERE quantity > 0 保护条件。这些都是小改动,却会产生巨大后果:系统在负载下正确运行,或者在两个事情同时发生时静默产生错误答案。

竞态条件不会主动宣告自己的存在。它会等待流量足够高、两个请求真正重叠的那一刻,然后生成一个日志里看起来正确、现实中却错误的结果。

现在已经知道该寻找什么。去检查它。

7 天小计划

第 1 天:搜索代码库中的模式:同一张表上 SELECT 后接 UPDATE,且没有事务。列出所有位置。

第 2 天:选择风险最高的一处,也就是涉及资金、库存或稀缺资源的位置。添加 FOR UPDATE 并用事务包裹。

第 3 天:用 ab 或 k6 为该接口编写负载测试。发送 50 个并发请求。验证最终记录数量正确。

第 4 天:凡是应用在插入前做存在性检查的位置,都添加 UNIQUE 数据库约束。在 PHP 中捕获约束违例。

第 5 天:审查代码库中任何适合使用 Redis INCRDECR 做原子计数的位置。

第 6 天:对于任何绝对不能并发运行的后台任务,添加带合理 TTL 的 Redis 分布式锁。

第 7 天:在团队工程文档中记录并发策略。“库存/支付使用 FOR UPDATE,用户可编辑记录使用版本列,后台任务使用 Redis 锁”这类明确约定能防止竞态条件被重新引入。

关键指标:关键接口能够正确处理的并发请求数量。使用负载测试,并用生产环境实际会遇到的并发级别测试。

常见错误:假设开发环境中能运行,负载下也能运行。开发环境几乎不会让并发请求同时命中同一行。竞态条件会一直隐藏,直到生产流量将其暴露出来。凡是涉及稀缺资源的功能,在发布前都应该用并行请求显式测试。

行动号召

竞态条件是否曾经在经手的系统中造成真实损害?例如重复扣款、商品超卖,或者一个花了一周才理清的重复记录?可以在评论区分享,当然要去掉可识别细节。这些故事具有启发性,社区从“实际发生了什么”中学到的内容,通常比理论示例更多。

如果这篇文章为一个早已被怀疑但一直无法命名的问题提供了词汇,就把它分享给负责支付流程的开发者。他们会想运行那次负载测试。

闭环

设想现在有两个请求命中应用。相同接口。相同行。相同毫秒。

修复已经上线。锁已经就位。事务包裹了写入。原子保护条件位于 WHERE 子句中。

一个请求成功。另一个请求得到一个可以处理的清晰错误。

这就是正确的并发处理。竞态尝试永远会发生,代码只是不会再让它获胜。

读者常问的 8 个问题

1. PHP 中的竞态条件是什么?
竞态条件指两个 PHP 请求同时读取并修改同一份数据,最终结果取决于它们完成的顺序。最常见的例子是:两个请求都读到数量为 1,都检查“是否有库存?”,都继续执行,然后都扣减,最终造成数量为 -1,并为一个商品生成两张订单。

2. 如何在 PHP 和 MySQL 中防止竞态条件?
主要工具包括:SELECT ... FOR UPDATE(悲观锁,会阻塞并发读取直到事务提交)、带版本列的乐观锁(事后检测碰撞)、带保护条件的原子 UPDATE 语句(WHERE quantity > 0)。这些方案都需要正确使用数据库事务。

3. MySQL 中的 SELECT FOR UPDATE 是什么?
SELECT ... FOR UPDATE 会对选中的行获取排他行锁。任何其他事务如果尝试用 FOR UPDATE 读取同一批行,都会阻塞到第一个事务提交或回滚。它必须在 BEGIN TRANSACTION / COMMIT 块中使用;在事务外部,锁会被获取并立即释放。

4. PHP 中的乐观锁是什么?
乐观锁使用数据库行上的版本列。读取时捕获版本。写入时,UPDATE 中包含 WHERE version = [captured version]。如果另一个请求在此期间修改了该行,版本就会变化,当前 UPDATE 影响零行;这表示发生碰撞,应用通过重试或优雅失败来处理。

5. 如何在 Laravel PHP 中处理并发请求?
Laravel 提供 DB::transaction() 用于事务包裹,并在 Eloquent 查询上提供 lockForUpdate() 用于悲观锁。对于乐观锁,Laravel 没有内置支持;需要实现版本列并手动检查受影响行数。atomic() helper(Laravel 10+)为常见原子操作提供了便捷封装。

6. 分布式锁是什么,PHP 什么时候需要它?
分布式锁会跨多个 PHP 进程、服务器或服务协调访问,协调范围超出单个数据库事务。Redis 是标准工具:SET key token NX PX ttl 会在 key 不存在时以原子方式设置 key。保护一个执行流程、防止后台任务重复执行或跨服务协调时,可以使用分布式锁。

7. 如何测试 PHP 中的竞态条件?
使用 Apache Bench(ab -n 50 -c 50)或 k6 等负载测试工具,向目标接口发送大量并发请求。对于超卖这类竞态条件,可以对数量为 1 的商品发送 50 个并发购买请求,然后统计订单记录数量。超过一条就说明存在竞态条件。顺序单元测试无法复现依赖时序的缺陷。

8. 使用数据库事务能防止竞态条件吗?
不能自动防止。事务保证原子性,也就是全有或全无;但默认隔离级别(MySQL 中的 REPEATABLE READ)仍然允许两个事务同时读取同一行并继续执行。需要显式锁(FOR UPDATE)、原子 UPDATE 保护条件,或更高隔离级别(SERIALIZABLE)来防止导致重复销售的丢失更新问题。

注:本文中的竞态条件场景是对已被充分记录的生产故障模式的说明性组合。时间线图为便于理解而简化;实际数据库调度行为会因引擎、隔离级别和硬件而异。在依赖任何单一方案之前,应在具体环境中测试并发处理能力。

本作品采用《CC 协议》,转载必须注明作者和本文链接