资讯详情

PHP库存对账脚本:Redis与数据库一致性校验实践

📅 2026/10/21 8:00:43 | 华诺云谱 👁 阅读
PHP库存对账脚本:Redis与数据库一致性校验实践
库存对不上账这件事几乎每个电商项目都会碰到。最典型的一种场景是下单时 Redis 扣减成功了数据库的库存却没来得及同步等到第二天盘库存卖出去三百件Redis 显示还余一千DB 里却只剩九百。我这次要说的就是用 PHP 写一个每分钟跑一次的对账脚本把活跃商品的 DB 库存和 Redis 库存拉出来逐条比对把差异记下来、告警发出去必要时还能做一轮自动修复。这套脚本适合谁适合那些已经上了 Redis 缓存库存、但还没有一套专门做“两级存储一致性校验”的小团队也适合想从零搭一个库存对账模块、但不想直接引入重型分布式事务框架的开发者。逻辑不复杂核心就三件事拉数据、做比对、处理差异。但真正写好它需要搞清楚三个前置问题库存为什么会不一致、每分钟这个节奏合不合理、活跃商品怎么定义。下面我按我自己的实现思路一条条拆开讲。1. 库存为什么会不一致先搞清楚你究竟在对什么账1.1 双写架构下的典型故障场景绝大多数电商系统不会把所有库存读写都塞进一个数据库事务里而是让 Redis 扛读和扣减DB 做最终记账。常见的写入链路有两种先扣 Redis再通过消息队列或同步任务把扣减结果异步落库或者先写 DB再回写 Redis。无论哪种都会存在一个窗口期Redis 和 DB 的数据天然就不是实时一致的。但真正需要人工介入的是窗口期之后依然不一致的残留差异。我在实际项目里见到的诱因大概有这几种异步落库任务失败队列消费抛异常重试几轮之后还是失败DB 库存没减成功。分布式扣减后没有补偿Redis 扣了事务回滚了 DB但 Redis 没有回补。缓存过期或主动清理Redis key 过期后DB 有值、缓存没值读请求回源时才发现库存总量对不上。并发扣减绕过 Lua 脚本业务代码没有用原子操作先 get 再 set导致超卖。多环境混用 key测试环境或脏数据写入污染了生产缓存。对账脚本的价值就在这里它不解决“如何避免不一致”而是解决“不一致发生之后能在几分钟内发现并定位”。订单对不上账这种事拖一天和拖五分钟处理成本完全不一样。1.2 为什么只关心“活跃商品”标题里特别写了“活跃商品”这是一个很重要的约束。全量商品可能几十万、上百万条每分钟全表扫描比对DB 扛不住Redis 也扛不住脚本执行时间会超过下一次调度最终变成一堆重叠任务堆在进程里。活跃商品的含义是“当前时间段内真正有库存读写需求、需要强一致保证的商品”。怎么定义取决于业务形态。我之前维护的一个电商项目里活跃商品集合是这样算出来的最近 15 分钟内有过库存查询、扣减、回补操作的商品加上最近 1 小时内有过下单记录的商品再并上运营手动标记的“重点监控款”。这个集合通常只有几百到几千个 SKU比对成本可控而且真正容易出问题的就是这些商品。冷门商品库存即使错了短时间内也不会暴露在用户面前可以放到低频全量对账任务里慢慢查。所以这个脚本的思路不是全量扫描而是“抓大放小、高频盯盘”。2. 每分钟对账的设计取舍任务划分、节奏与控制2.1 对账周期为什么是“每分钟”题目直接要求每分钟运行这个选择其实是合理的。库存扣减是毫秒级操作一致性问题的膨胀速度很快。每秒跑一次对系统压力太大每 5 分钟跑一次又可能让错误库存持续暴露给几百个用户。1 分钟这个粒度基本是“能及时发现”和“系统不被打扰”之间的平衡点。但每分钟运行意味着脚本自身必须足够轻。轻的度量标准很简单执行完一轮的时间远远小于 1 分钟且不对 DB 和 Redis 造成可感知的压力。如果一轮要跑 40 秒那基本就废了得想办法拆批。在实际部署里我用的调度方式是 Linux cron一行就够* * * * * /usr/bin/php /data/scripts/stock_reconciliation.php /data/logs/reconcile.log 21cron 的写法没有特殊技巧关键是在脚本里做好“幂等与互斥”。因为 PHP 脚本没有内置调度框架同一分钟如果上一个进程还没跑完cron 会再拉一个进程两个进程同时比对、同时修数据很容易互相覆盖。所以第一件事就是要加运行锁。?php $redisLock new Redis(); $redisLock-connect(127.0.0.1, 6379, 2.5); $lockKey lock:stock_reconcile; $locked $redisLock-set($lockKey, (string)time(), [NX, EX 120]); if (!$locked) { // 上一个进程还在运行直接退出 exit(0); }用 Redis 的 SET NX EX 做互斥锁120 秒过期是兜底防止进程异常退出后锁一直不释放。这里有个小细节锁的过期时间必须远大于脚本预期执行时间否则会出现锁提前失效、任务并发执行的情况。正常脚本设计目标是 10 秒内跑完120 秒足够了。2.2 活跃商品集合怎么确定活跃商品集合可以用一张标记表也可以用 Redis 里的 ZSET 来做。我选的是 ZSET因为读写方便天然带时间维度。每次有库存操作时业务代码顺便执行一条命令把商品 ID 写入一个固定 key 的 ZSETscore 记当前时间戳。对账脚本只要读取最近 N 分钟内的成员就得到了活跃商品列表。ZADD stock:active:products 1699999999 10086 ZADD stock:active:products 1699999999 10087 ZREVRANGEBYSCORE stock:active:products 1699999999 1699995000如果业务代码不方便加埋点也可以退而求其次直接从订单表、库存流水表里捞最近有操作的商品 ID。但这种方式对 DB 有查询压力每天高峰期不太推荐。我最终是在商品库存写接口上加了一行埋点成本极低效果最直接。2.3 不能忽略的防重入机制除了运行锁脚本里还要处理一个问题一次性从 DB 读取大量商品 ID 然后逐个查 Redis会有明显的毛刺。大多数情况下活跃商品集合是几百个直接循环没问题。但如果碰上大促集合可能涨到几万一个进程循环处理会非常慢超过 1 分钟调度周期几乎是必然的。我采取的做法是按 ID 分批每批 500 个一批比对完再处理下一批。批处理有两个好处一是控制单次查询的数据量避免 DB IN 查询条件过长二是如果中间崩了重启之后至少已经处理过的批次已经落盘了日志可以接着跑下一批。当然必要的时候还可以引入进程内延迟比如每批之间 sleep 0.2 秒给 DB 和 Redis 一点喘息时间。3. 从 DB 捞数据到 Redis 比对核心实现全过程3.1 第一步拉取活跃商品 ID整个脚本的骨架是一个 PHP 命令行脚本我拆成了几个私有方法来做职责隔离。第一步是拿活跃商品 ID从之前说的 ZSET 里取最近 10 分钟的数据再筛掉已经下架或标记删除的商品。?php private function fetchActiveProductIds(int $minutes 10): array { $start time() - $minutes * 60; $ids $this-redis-zRevRangeByScore( stock:active:products, time(), $start, [limit [0, 2000]] ); if (empty($ids)) { return []; } $slots implode(,, array_fill(0, count($ids), ?)); $stmt $this-db-prepare(SELECT id FROM product WHERE id IN ({$slots}) AND status 1); $stmt-execute($ids); return $stmt-fetchAll(PDO::FETCH_COLUMN); }这里要注意一点ZSET 里的商品 ID 是字符串取出来之后要确保是整型防止拼进 SQL 时出现注入风险。我上面的写法用了预处理占位符不过在实际项目中我更建议再做一次array_map(intval, $ids)双保险。3.2 第二步按 ID 批量获取 DB 库存获取 DB 库存时会遇到一个现实问题库存表不一定叫product_stock也可能就是商品表上的一个字段。我下面按独立库存表来写逻辑更清晰。?php private function fetchDbStockMap(array $ids): array { $chunks array_chunk($ids, 500); $stockMap []; foreach ($chunks as $chunk) { $slots implode(,, array_fill(0, count($chunk), ?)); $sql SELECT product_id, stock, updated_at FROM product_stock WHERE product_id IN ({$slots}); $stmt $this-db-prepare($sql); $stmt-execute($chunk); foreach ($stmt-fetchAll(PDO::FETCH_ASSOC) as $row) { $stockMap[(int)$row[product_id]] (int)$row[stock]; } } return $stockMap; }DB 的stock字段要确认是整型如果有拆分子库存的情况比如按仓库拆这一步就得先做汇总。我遇到过一个项目把库存按“总仓、华东仓、华南仓”分开存对账的时候必须先按 product_id 做 SUM不然 Redis 里的单一值根本比不出来。这个坑很容易踩做之前先聊清楚库存模型。3.3 第三步并行获取 Redis 库存Redis 库存的 key 一般是stock:{product_id}存储类型可能是 string也可能是 hash。我这里按最常用的 string 来写。批量获取一定要用 pipeline 或 mget不要在一个 for 循环里逐个 get否则几百个商品的网络往返就够你受的。pipeline 的写法在 phpredis 扩展里很简洁?php private function fetchRedisStockMap(array $ids): array { $pipeline $this-redis-multi(Redis::PIPELINE); foreach ($ids as $id) { $pipeline-get(stock: . $id); } $results $pipeline-exec(); $stockMap []; foreach ($ids as $index $id) { $stockMap[(int)$id] $results[$index] false ? null : (int)$results[$index]; } return $stockMap; }这里有一个容易被忽略的细节当 Redis key 不存在时get返回 false和返回0有本质区别。0表示缓存里明确存了 0false 表示缓存缺失。对账逻辑必须能区分这两种情况因为它们对应的修复动作完全不一样。3.4 第四步差异分类与记录有了 DB 库存和 Redis 库存两份 Map把两个数组做一次全比对发现差异时不要急着下结论先按类型分类。我一般分四类差异类型判断条件可能原因值不一致DB stock ! Redis stock扣减未落库、缓存未回补缓存缺失DB 有值Redis 无 keykey 过期、被清理、未初始化DB 缺失Redis 有值DB 无记录商品被逻辑删除但缓存未清理时间维度偏差值差异超过阈值正在发生交易、异步任务未完成分类之后每条差异会写入一张对账差异表表结构大致是对账批次 ID、商品 ID、DB 库存、Redis 库存、差异类型、检测时间、处理状态。写入差异表的意义是给后续的统计和自动修复留数据基础而不是每次跑完只打一条日志就完事。?php private function compare(array $dbMap, array $redisMap): array { $result []; foreach ($dbMap as $id $dbStock) { $redisStock $redisMap[$id] ?? null; if ($redisStock null) { $result[] [product_id $id, diff_type cache_missing, db_stock $dbStock, redis_stock -1]; continue; } if ($redisStock ! $dbStock) { $result[] [ product_id $id, diff_type $redisStock $dbStock ? redis_higher : redis_lower, db_stock $dbStock, redis_stock $redisStock, gap $dbStock - $redisStock, ]; } } return $result; }3.5 第五步告警与临时修复差异记录完最后一步是决定动作。我的默认策略是不直接在脚本里改库存而是把差异内容组装成一条消息推送到团队告警群。风险等级和修复建议都写在消息里由值班人判断是否立即处理。告警通道我用的是 Webhook不管团队用的哪款即时通讯工具都支持 Post 一个 JSON。下面这段是组装消息的核心逻辑?php private function sendAlert(array $discrepancies): void { $items array_map( fn($diff) sprintf( 商品 %dDB%dRedis%d类型%s, $diff[product_id], $diff[db_stock], $diff[redis_stock], $diff[diff_type] ), array_slice($discrepancies, 0, 20) ); $webhookUrl $this-config[alert_webhook]; $payload json_encode([ msgtype text, text [content 库存对账异常 . count($discrepancies) . 条\n . implode(\n, $items)], ], JSON_UNESCAPED_UNICODE); $ch curl_init($webhookUrl); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, [Content-Type: application/json]); curl_setopt($ch, CURLOPT_TIMEOUT, 3); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_exec($ch); curl_close($ch); }之所以默认不自动改是因为库存修复是有风险的。自动修复很容易把“账对了”做成“数据被污染了”所以我的原则是告警先行、人工决策、自动化只处理明确无风险的类型比如缓存 key 缺失时自动从 DB 回填。自动回填这个动作比较安全但其他情况先让人看一眼。4. 跑起来之后的现实问题误报、延迟与热 Key4.1 正在成交的商品必然“对不上”把脚本放到生产环境之后你遇到的第一个问题大概率是误报某个爆款商品刚上架DB 库存和 Redis 库存差了 1 到 3但下一分钟再查又恢复了。原因很简单就是有用户在那一分钟内下单Redis 已经扣了异步任务还没把结果刷到 DB。这种误报本身不是 bug而是对账模型里缺少“交易中的容忍窗口”。解决思路不是加长比对周期而是把比对逻辑改成多轮确认第一次发现差异先不告警只打一条“观察”日志下一次执行时再用同一商品 ID 查一次如果连续两轮差异方向一致且数字不变才判定为真异常。我在代码里是给差异表加了一个retry_count字段。检测到差异时先查这个商品上一轮是否也有差异如果有retry_count 加 1只有 retry_count 达到 2 才触发告警和修复动作。这个方案很简单但能过滤掉绝大多数“正在交易”导致的瞬时差异。4.2 数据库主从延迟造成的假差异第二个高发问题来自数据库复制延迟。如果你对账脚本连的是只读从库而 Redis 扣减是基于主库同步过来的库存记录当主库还没有把最新扣减 binlog 同步到从库时对账脚本读到的是旧库存数据必然和 Redis 对不上。这个问题最坑的地方在于它不是每次都出现而是随机、瞬时的且往往在你准备排查的时候已经恢复一致。我用过两种处理办法一是对账脚本强制走主库。库存量级不大对主库多几十次查询压力可以接受但大促期间要评估。二是把 DB 表的updated_at带出来比对时排除掉“最近 5 秒内更新过”的商品因为它们在主从延迟窗口内数据不可信。?php // 查询时排除最近 5 秒更新过的商品 $sql SELECT product_id, stock FROM product_stock WHERE product_id IN ({$slots}) AND updated_at DATE_SUB(NOW(), INTERVAL 5 SECOND);这个方法简单有效代价是对账的实时性会稍微打折扣但对目标是“每分钟发现真异常”的脚本来说这一点点延迟完全可以接受。4.3 Redis 热 Key 与网络抖动每分钟跑一次的脚本本身会给 Redis 增加一部分读压力。如果活跃商品集合里恰好有高热商品脚本的 pipeline 请求和业务请求会挤在一起虽然 pipeline 把网络往返压到了很低但 Redis 单线程模型下大 value 的 get 操作依然可能带来毫秒级毛刺。我踩过一次很具体的坑某个商品 ID 对应的库存 key 里被业务方误存了一个包含几十 KB 的 JSON 对象整个 key 大得离谱。对账脚本每次 get 这个 keyRedis 都要多花几毫秒偶尔网络出现抖动脚本执行时间直接从 3 秒飙到 15 秒。后来我用DEBUG OBJECT stock:{id}查了一下才发现这个大 key 问题。对账脚本本身没法完全避开热 key 和大 key但它能把问题暴露出来。我给脚本加了一项统计每次比对结束后把本轮最耗时的商品 ID 记录到日志里。运营一段时间后把这些耗时商品拉出来看就能发现异常的 key。这也算是对账脚本的额外价值。5. 差额处理策略什么该自动修什么必须等人5.1 确定基准库写入顺序决定修复方向说到底对账发现问题之后总要有人处理。从长期维护的角度我建议先明确一个原则哪边是基准。大多数库存模型里DB 是最终账本Redis 是高速缓存那修复方向就是从 DB 向 Redis 单向同步。这是最简单的模型也最容易解释清楚。但也存在反过来的场景。如果系统设计是“Redis 先扣减成功即视为交易成立DB 异步入账兜底”那 Redis 在特定窗口内反而是业务真相。这种场景下自动修复不能盲目用 DB 覆盖 Redis否则可能把已经生效的交易扣减给回滚掉。所以第一步永远是确认写入链路而不是直接写脚本。我自己的做法是脚本里把基准认为可配置默认是 DB_TO_REDIS同时把所有自动修复动作都记录审计日志谁改的、什么时候改的、基于哪次对账批次改的全部留痕。5.2 自动修复的阈值与熔断如果团队确定了自动修复的范围那必须在脚本里加上阈值和熔断避免错误蔓延。我给自动修复设置了几个前提条件单个商品差异绝对值不超过 10且方向稳定连续 3 轮都是同一方向。本轮差异商品数量不超过活跃商品总数的 2%超过则停止修复只告警。修复前先检查 Redis key 最近 30 秒内是否有修改如果有说明有并发写跳过本轮。这些条件的代码实现不复杂但逻辑上很重要。自动修复的核心不是“用谁覆盖谁”而是“在什么条件下才安全”。?php private function shouldAutoFix(array $diff, int $totalActive, array $recentModifiedIds): bool { if (abs($diff[gap]) 10) { return false; } if (in_array($diff[product_id], $recentModifiedIds, true)) { return false; } return true; }5.3 对账数据的沉淀与改进脚本稳定运行之后会积累一大批对账差异记录。这些数据不要丢定期分析能反推出很多系统隐患。比如某个商品反复出现“redis_lower”说明它的异步落库链路一直在失败某类目商品频繁出现“cache_missing”说明它们的缓存生命周期设置不合理。我在实际使用中就把对账差异表接了一个简单的日报统计每天看三类数据差异数量趋势、差异商品分布、差异类型占比。这个报表比任何监控面板都直观因为它是直接从业务结果反推出来的每个数字背后都对应一个真实的技术问题。后面继续优化可以考虑对这些差异商品做定向的代码链路排查能省下大量靠猜的定位时间。最后再分享一个小技巧对账脚本的输出日志不要只记错误每一轮跑完哪怕没有任何差异也要打一行[OK] checked350, diff0, cost1.2s。这行日志看着朴素但它是判断“脚本是不是真的在正常工作”的最快依据。如果哪天这行日志消失了八成是进程挂了、锁没释放、Redis 连接出问题了。一个每分钟跑一次的脚本最重要的品质不是功能有多复杂而是稳定、可观测、不会在半夜悄悄死掉。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑