PHP 怎么数据一致性

wen PHP项目 2

PHP 数据一致性:从原理到实战,彻底告别脏读与超卖

目录导读

  1. 什么是数据一致性?为什么PHP项目总是踩坑?
  2. 核心场景:订单超卖、库存扣减、余额转账
  3. 数据库事务与锁机制(悲观锁/乐观锁)
  4. Redis分布式锁与队列削峰
  5. 最终一致性方案(消息队列+重试)
  6. 常见陷阱与性能平衡
  7. 实战问答(Q&A)

为什么你的PHP代码会“丢数据”?

很多PHP开发者遇到过这样的问题:两个用户同时抢购最后一件商品,结果都显示“购买成功”,但库存变成了负数,或者在余额转账时,A给B转钱,A扣款成功但B收款失败,这背后都是数据一致性被破坏。

PHP 怎么数据一致性

数据一致性指:在并发操作下,数据库的最终状态必须与业务逻辑的预期完全一致,PHP本身是单线程语言,但Web服务器(Nginx+PHP-FPM)会同时启动多个进程处理请求,每个进程操作同一份数据,就产生了竞争。

最经典的三个踩坑场景

1 订单超卖

// 错误代码
$stock = $db->query("SELECT stock FROM product WHERE id=1");
if ($stock > 0) {
    $db->query("UPDATE product SET stock=stock-1 WHERE id=1");
    // 创建订单...
}

两个并发请求都读到stock=1,都通过判断,都执行update,最终库存变成-1。

2 余额并发转账

// 错误代码
$balance = $db->query("SELECT balance FROM user WHERE id=100");
if ($balance >= 100) {
    $db->query("UPDATE user SET balance=balance-100 WHERE id=100");
    $db->query("UPDATE user SET balance=balance+100 WHERE id=200");
}

第一个UPDATE成功,第二个失败(例如字段限制),导致资金凭空消失。

3 重复提交

用户快速点击“提交订单”按钮,产生多条相同订单。


第一道防线:数据库事务 + 锁

1 使用InnoDB事务

$pdo->beginTransaction();
try {
    // 所有读写操作必须放在这里
    $pdo->exec("UPDATE product SET stock=stock-1 WHERE id=1 AND stock>0");
    if ($pdo->rowCount() == 0) throw new Exception("库存不足");
    $pdo->exec("INSERT INTO orders ...");
    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
}

关键点:WHERE stock>0 这一步是原子操作,数据库行锁保证同一时间只有一个事务能更新成功,这是最基础且高效的方式。

2 悲观锁(SELECT FOR UPDATE)

$pdo->beginTransaction();
$stmt = $pdo->query("SELECT stock FROM product WHERE id=1 FOR UPDATE");
$stock = $stmt->fetchColumn();
if ($stock > 0) {
    $pdo->exec("UPDATE product SET stock=stock-1 WHERE id=1");
}
$pdo->commit();

FOR UPDATE会锁定该行,直到事务结束,适合并发量不高的后台系统,但性能差,容易造成锁等待。

3 乐观锁(版本号)

$row = $db->query("SELECT stock, version FROM product WHERE id=1")->fetch();
$oldVersion = $row['version'];
if ($row['stock'] > 0) {
    $res = $db->exec("UPDATE product SET stock=stock-1, version=version+1 WHERE id=1 AND version=$oldVersion");
    if ($res == 0) {
        // 更新失败,说明被其他线程改过,重试或报错
    }
}

适合读多写少的场景,不需要数据库锁,但需要处理重试逻辑。


进阶方案:Redis分布式锁

当你的PHP应用部署在多台服务器(集群),数据库行锁只能锁单库,跨库或跨服务需要分布式锁。

$redis = new Redis();
$lockKey = "lock:product:1";
$lockValue = uniqid(); // 唯一标识,防止误删别人锁
$expire = 10; // 秒
// 获取锁(SET NX EX 原子命令)
if ($redis->set($lockKey, $lockValue, ['NX', 'EX' => $expire])) {
    try {
        // 执行业务:扣库存、创建订单
    } finally {
        // 释放锁:用Lua脚本保证原子性
        $lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        $redis->eval($lua, [$lockKey, $lockValue], 1);
    }
} else {
    // 获取失败,等待重试
}

注意:锁的粒度要小(只锁商品ID),超时时间要合理(业务执行一般<2秒),否则业务卡死会导致锁自动过期产生问题。


终极方案:消息队列保证最终一致性

对于跨系统(如支付成功回调后修改订单状态、通知仓储系统),不可能做到强一致,此时用最终一致性

1 流程设计

  1. 主业务(订单)更新状态为“已支付”,并发送消息到RabbitMQ/Kafka。
  2. 消费者接收消息,处理仓储扣减、积分赠送等。
  3. 如果消费失败,重试N次;N次后进入死信队列,人工介入。
// 生产者(支付回调后)
$msg = json_encode(['order_id' => 123, 'user_id' => 456]);
$rabbit->publish('order.paid', $msg);
// 消费者(异步)
$callback = function($msg) {
    $data = json_decode($msg->body, true);
    try {
        // 扣减库存,可能失败(例如网络超时)
        // 失败时:$msg->nack(false, true) 重新入队
    } catch (Exception $e) {
        $msg->nack(false, true); // 重试
    }
};

2 用本地消息表(推荐)

在业务数据库中创建message_log表,业务操作和消息写入放在同一个事务中,然后定时任务扫描发送消息,发送成功后标记状态。

$pdo->beginTransaction();
try {
    // 更新订单状态
    $pdo->exec("UPDATE orders SET status='paid' WHERE id=123");
    // 插入待发送消息
    $pdo->exec("INSERT INTO message_log (status, payload) VALUES ('pending', '{\"order_id\":123}')");
    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
}
// 之后定时任务轮询发送

陷阱与性能平衡

  • 锁过多导致死锁:保持SQL操作顺序一致(例如总是先扣库存再生成订单)。
  • 不要在循环里加锁:一次查出所有需要的行,批量UPDATE。
  • Redis锁超时误删:用UUID作为value,删除前检查。
  • 数据库隔离级别:默认REPEATABLE READ,高并发下可能产生间隙锁,可使用READ COMMITTED
  • 不要滥用分布式事务:2PC/TCC复杂且慢,能用数据库事务就用数据库事务。

实战问答

Q1: 我的系统并发量只有100,需要Redis锁吗? A: 不需要,用MySQL悲观锁或乐观锁足够,Redis锁会增加网络IO,纯属浪费。

Q2: 扣库存更新失败(rowCount=0),重试3次还是失败,怎么办? A: 直接返回“库存不足”,并释放锁,不要死循环,容易拖垮数据库。

Q3: 乐观锁重试的代码怎么写? A: 用for循环最多重试5次,每次重新SELECT版本号,更新成功则break,失败则sleep(微小随机时间)。

Q4: 消息队列一直消费失败,最终一致性怎么保证? A: 设置最大重试次数(如3次),超过后写入死信队列,开发人员定时扫描处理,订单状态必须留下人工补偿接口。

Q5: 数据库死锁了怎么办? A: SHOW ENGINE INNODB STATUS查看死锁日志,调整SQL顺序,或者缩短事务执行时间(不要select后sleep)。


数据一致性没有银弹,你的选择顺序应该是:

  1. 单库:用事务+条件更新(WHERE stock>0)解决90%问题。
  2. 集群:加Redis分布式锁,锁代码要原子化。
  3. 跨系统:本地消息表+MQ异步,保证最终一致。

PHP开发者不要一上来就上Kafka、分布式事务,先学会在数据库层面解决,再逐步升级,理解“原子操作”是核心,所有方案都是在用不同方式将“检查+修改”变成不可分割的步骤。

抱歉,评论功能暂时关闭!