PHP编写对账脚本:立即开发一个每分钟运行的 PHP 脚本,比对活跃商品的 DB 和 Redis 库存。
开发一个每分钟运行的 PHP 库存对账脚本,是构建**“最终一致性”防线的关键一环。它不是简单的SELECT对比,而是一场性能、安全与准确性**的平衡艺术。
如果对账脚本本身拖垮了数据库,或者错误地修复了数据,那就是灾难。
一、对账策略:全量 vs 增量 vs 活跃
每分钟运行一次,意味着绝对不能全表扫描。
1. 全量对账 (T+1)
- 范围:所有商品。
- 频率:每天凌晨。
- 用途:兜底,发现长期潜伏的不一致。
2. 活跃对账 (T+0 实时) ——本脚本的目标
- 范围:最近 N 分钟内有交易发生、或库存发生过变动的商品。
- 频率:每 1-5 分钟。
- 用途:快速发现并修复高并发场景下的即时不一致。
- 如何获取活跃商品:
- 方案 A:扫描
orders表,查找最近 5 分钟创建的订单涉及的product_id。 - 方案 B:维护一个 Redis Set
active_products,每次库存变动时加入,对账脚本读取后清空(或保留)。 - 方案 C:扫描
products表,查找updated_at > NOW() - 5 MINUTE的记录。
- 方案 A:扫描
💡 核心洞察:对账的本质是“抽样检查”。通过聚焦“活跃商品”,用 1% 的检查成本,覆盖 90% 的风险场景。
二、核心代码实现 (PHP CLI)
这是一个基于原生 PHP + PDO + PhpRedis 的健壮实现示例。
#!/usr/bin/env php<?php/** * 库存对账脚本 (Stock Reconciliation) * 运行频率:每分钟 (*/1****)*/require'vendor/autoload.php';// 如果有依赖// 1. 配置$config=['db'=>['dsn'=>'mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4','user'=>'reconcile_user',// 建议专用只读账号'pass'=>'password'],'redis'=>['host'=>'127.0.0.1','port'=>6379],'batch_size'=>100,// 每次处理商品数量'time_window'=>5,// 检查最近 5 分钟活跃商品'auto_fix_threshold'=>10// 差异小于 10 才自动修复,大于则报警];// 2. 连接$pdo=newPDO($config['db']['dsn'],$config['db']['user'],$config['db']['pass'],[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC]);$redis=newRedis();$redis->connect($config['redis']['host'],$config['redis']['port']);// 3. 获取活跃商品 ID (方案:扫描最近更新的 product)// 注意:生产环境建议走从库 (Read Replica)$stmt=$pdo->prepare("SELECT id FROM products WHERE updated_at > DATE_SUB(NOW(), INTERVAL :mins MINUTE) LIMIT :limit");$stmt->bindValue(':mins',$config['time_window'],PDO::PARAM_INT);$stmt->bindValue(':limit',$config['batch_size'],PDO::PARAM_INT);$stmt->execute();$activeProducts=$stmt->fetchAll(PDO::FETCH_COLUMN);if(empty($activeProducts)){echo"No active products found.\n";exit(0);}echo"Checking ".count($activeProducts)." active products...\n";$fixedCount=0;$alertCount=0;// 4. 逐个比对foreach($activeProductsas$productId){try{// A. 获取 DB 库存 (假设逻辑:total - locked)// 建议直接查可用库存字段,避免计算误差$dbStockStmt=$pdo->prepare("SELECT stock, locked_stock FROM products WHERE id = ?");$dbStockStmt->execute([$productId]);$dbRow=$dbStockStmt->fetch();if(!$dbRow)continue;// 计算 DB 可用库存$dbAvailable=intval($dbRow['stock'])-intval($dbRow['locked_stock']);$dbAvailable=max(0,$dbAvailable);// 防止负数// B. 获取 Redis 库存$redisKey="stock:product:{$productId}";$redisStock=$redis->get($redisKey);$redisAvailable=$redisStock===false?0:intval($redisStock);// C. 比对$diff=$dbAvailable-$redisAvailable;if($diff!==0){$logMsg="Mismatch: Product{$productId}, DB:{$dbAvailable}, Redis:{$redisAvailable}, Diff:{$diff}";// D. 修复策略if(abs($diff)<=$config['auto_fix_threshold']){if($diff>0){// 情况 1: DB > Redis (少卖了,Redis 库存少了) -> 安全,直接补$redis->set($redisKey,$dbAvailable);logAction("FIXED",$logMsg);$fixedCount++;}elseif($diff<0){// 情况 2: DB < Redis (超卖风险,Redis 库存多了) -> 危险,仅报警,不自动修// 因为可能有正在进行的订单还没落库logAction("ALERT_DANGEROUS",$logMsg);sendAlert("Critical Stock Mismatch:{$logMsg}");$alertCount++;}}else{// 差异过大,人工介入logAction("ALERT_LARGE_DIFF",$logMsg);sendAlert("Large Stock Difference:{$logMsg}");$alertCount++;}}}catch(Exception$e){error_log("Reconcile error for product{$productId}: ".$e->getMessage());}}echo"Done. Fixed:{$fixedCount}, Alerts:{$alertCount}\n";// 辅助函数functionlogAction($type,$msg){file_put_contents('/var/log/stock_reconcile.log',date('Y-m-d H:i:s')." [$type]$msg\n",FILE_APPEND);}functionsendAlert($msg){// 对接钉钉/企业微信/邮件// file_put_contents('/var/log/stock_alert.log', $msg . "\n", FILE_APPEND);}三、调度与锁:防止重叠运行
每分钟运行一次,如果脚本执行时间超过 1 分钟,会导致两个实例同时运行,造成数据库压力倍增或重复修复。
1. 使用 Cron 锁
在 Crontab 中不要直接调 PHP,加一个锁检查。
# 错误写法*/1 * * * * php /var/www/scripts/reconcile.php# 正确写法 (使用 flock)*/1 * * * * flock-n/tmp/stock_reconcile.lock php /var/www/scripts/reconcile.php-n:非阻塞。如果上一个进程还在跑,当前进程直接退出。
2. 使用 Redis 分布式锁
在 PHP 代码内部加锁,更灵活。
$lockKey='lock:stock:reconcile';if(!$redis->set($lockKey,1,['NX','EX'=>60])){exit("Another instance is running.\n");}// 注册 shutdown 函数释放锁,或依赖过期时间register_shutdown_function(function()use($redis,$lockKey){$redis->del($lockKey);});四、安全修复机制:宁可错杀,不可乱修
对账脚本最危险的操作是自动修复。
1. 修复方向原则
- DB > Redis (库存冻结):✅可自动修复。
- 原因:通常是订单取消后 Redis 释放失败。补回 Redis 库存只会增加可售量,不会导致超卖。
- DB < Redis (超卖风险):❌禁止自动修复。
- 原因:可能是订单已创建但尚未扣减 Redis(异步延迟),或者正在支付中。强行改小 Redis 会导致正在下单的用户失败,或产生数据冲突。
- 对策:仅发送最高级别报警,由人工确认“无在途订单”后再手动修复。
2. 阈值保护
- 如果差异值
abs(diff) > 10,无论方向如何,都禁止自动修复。 - 大差异通常意味着系统性 Bug 或数据损坏,自动修复可能掩盖真相。
3. 修复日志
- 每一次自动修复必须记录日志:
产品 ID,修复前,修复后,时间,脚本版本。 - 这是事后审计的唯一依据。
五、性能优化与避坑
1. 数据库压力
- 走从库:对账是读操作,务必连接Read Replica,避免影响主库写入性能。
- 批量查询:代码示例中是循环查 DB,优化方案是使用
WHERE id IN (...)批量查询,减少网络 RTT。// 优化:一次性查出所有活跃商品的 DB 库存$placeholders=implode(',',array_fill(0,count($activeProducts),'?'));$stmt=$pdo->prepare("SELECT id, stock, locked_stock FROM products WHERE id IN ($placeholders)");$stmt->execute($activeProducts);$dbStocks=$stmt->fetchAll(PDO::FETCH_ASSOC);// 然后构建 [id => stock] 数组进行内存比对 - Redis Pipeline:如果检查几百个商品,使用
pipeline批量获取 Redis Key。$redis->pipeline(function($pipe)use($activeProducts){foreach($activeProductsas$id){$pipe->get("stock:product:{$id}");}});
2. 避免死锁
- 对账脚本只读 DB,通常不会死锁。但如果涉及修复(写 Redis),确保修复逻辑简单快速。
- 不要在对账脚本中调用外部 HTTP 接口(如报警接口),改为写入本地日志或推送到 MQ,由独立进程发送报警,避免阻塞对账流程。
3. 监控指标
- 执行时长:如果脚本运行超过 50 秒,说明数据量过大或 DB 慢,需优化。
- 差异率:监控
AlertCount / TotalCount。如果差异率突然升高,说明系统出现了系统性不一致。
🚀 总结:对账脚本全景图
| 维度 | 核心要点 | 最佳实践 |
|---|---|---|
| 范围 | 活跃商品 | 只查最近 N 分钟有变动的商品,避免全表扫描 |
| 频率 | 1-5 分钟 | 配合 Cron + 锁,防止重叠运行 |
| 数据源 | DB 从库 + Redis 主 | 隔离读写压力,保证数据实时性 |
| 修复 | 保守策略 | DB>Redis 可自动修,DB<Redis 仅报警 |
| 性能 | 批量查询 + Pipeline | 减少网络往返,控制单次处理量 |
| 审计 | 全量日志 | 记录每一次比对和修复操作,可追溯 |
终极心法:
对账脚本是系统的“免疫系统”。
它不产生业务价值,但它防止系统因数据腐烂而死亡。
最好的对账,是用户感知不到的“静默修复”;最坏的对账,是引发二次故障的“暴力修正”。
记住:对账脚本本身也是代码,也会 Bug,也会挂。所以要对“对账脚本”进行监控。
宁可漏过一千个正常商品,也不能误杀一个异常数据。
于细微处见真章,于差异中守底线。
行动指令:
- 编写脚本:基于上述代码,适配你的数据库结构和 Redis Key 规范。
- 配置从库:确保脚本连接的是只读数据库实例。
- 设置 Cron:配置
flock锁,确保每分钟最多运行一次。 - 灰度测试:先在测试环境运行,模拟库存不一致场景,验证修复逻辑是否符合预期。
- 接入监控:将脚本的执行时长、发现差异数、修复数接入 Prometheus/Grafana 监控面板。
- 制定预案:明确当脚本发出“超卖风险”报警时,值班人员的处理流程(如下架商品、人工核对)。
这就是 PHP 库存对账脚本:以活跃为界,以保守为策,于数据洪流中守护库存的真实底线。
