news 2026/10/20 3:01:28

PHP+Redis+MySQL库存对账脚本:数据一致性保障实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP+Redis+MySQL库存对账脚本:数据一致性保障实践

库存对账这件事,听起来简单,真做起来全是细节。我前段时间刚给某个电商项目写过一个 PHP 对账脚本,每分钟跑一次,比对活跃商品在 MySQL(DB)和 Redis 里的库存是否一致。用到的技术不复杂,就是 PDO 连库、Redis 连接、文件锁防重入、Cron 定时触发,但真正让这个脚本“能上线”的,是对业务场景的理解和对边角情况的处理。如果你也遇到过 Redis 扣了库存但 DB 没扣、或者数据库回滚了 Redis 却扣成功这类问题,这篇内容值得你完整看一遍。

这篇东西适合谁?适合已经在用 Redis 做商品库存缓存、但还停留在“手动核对”阶段的开发者,也适合准备给订单系统做兜底一致性的后端工程师。我会从需求拆解、方案设计、核心代码到问题排查,把整个脚本完整说一遍,连 Cron 怎么配、线上踩过哪些坑都会写清楚。

1. 需求拆解:为什么每分钟要对一遍库存

1.1 库存不一致是怎么产生的

很多项目为了提高下单接口的吞吐量,会把库存从 DB 塞一份到 Redis,下单时直接DECRRedis 里的库存,异步或者同步再回调 DB 去扣减。这个方案本身没问题,问题出在“两边都有扣减动作”的时候,只要任何一步失败,不一致就产生了。

常见场景有几个:

  • 用户下单后 Redis 扣减成功,但写 DB 的订单记录时超时或事务回滚,DB 库存没扣。
  • 下单回调 DB 时用了异步消息队列,消息丢失或重复消费,导致 DB 扣多了或者没扣。
  • 运营后台直接在 DB 里改了库存,Redis 没有同步更新。
  • 退款、取消订单时只回了 Redis,DB 没回补,或者两边回补数不一致。
  • 接口被刷,同一时间大量并发请求打到 Redis 上,但由于某些超时异常,Redis 的扣减实际上没生效,DB 却被扣了。

这些情况只要出现一次,用户看到的库存和真实可卖库存就对不上。前台显示有货,下单却提示库存不足;或者前台显示无货,后台其实还有货。每一次不一致都等于一次营收损失或者用户体验事故。

1.2 以谁为准:数据一致性的主从策略

做对账前必须回答一个问题:DB 和 Redis 不一致时,以谁为准。

我见过不少团队把 Redis 当作“事实源”,理由是下单实时扣的是 Redis,DB 反正是异步的。但实际操作下来,绝大多数项目应该以 DB 为准。原因很简单:DB 是最终持久化层,订单、支付、库存流水都在那边,想追溯、想审计、想修复都必须落到 DB。Redis 本质上是加速层,是 DB 的一份“缓存”,缓存丢了可以重建,缓存错了必须向 DB 看齐。

但这个结论不能一刀切。有些场景下,Redis 里的库存才是更“新”的。比如大促时用户大量下单,Redis 扣了之后 DB 的回调正在排队,此刻 DB 还没扣完,如果脚本立刻用 DB 去覆盖 Redis,就会把刚扣下去的库存又加回来,造成超卖。所以对账脚本必须区分“DB 和 Redis 谁领先”。我的处理策略是:只把 Redis 小于 DB 的情况视为“可自动修复”的,因为这说明 Redis 缓存丢了或者某笔扣减没同步;而 Redis 大于 DB 的情况,通常意味着 Redis 扣减了但 DB 还没扣,或者 DB 回滚了,这类情况不能盲目覆盖,应该记日志、发告警,让后续的订单回调机制去处理,或者人工确认后再修。

这个原则是整个脚本的核心,后面所有逻辑都围绕它展开。

2. 方案设计:一个脚本要解决的三件事

2.1 活跃商品怎么圈定

“活跃商品”是一个业务概念,不能简单地理解成“所有商品”。如果全量商品都去对账,商品量大了以后,一分钟内根本跑不完,而且线上对账也容易把慢查询打到主库上。

我当时的圈定条件有三条,写成 SQL 就是:

SELECT id, stock FROM product WHERE status = 1 AND stock > 0 AND updated_time >= NOW() - INTERVAL 10 MINUTE

第一条status = 1表示商品是上架状态,下架商品的对账没有意义;第二条stock > 0排除掉长期无货的商品,这类商品库存本来就是 0,Redis 里即使没有 key 也等价于 0,不用每次比对。第三条updated_time是关键,只取最近 10 分钟内有更新的商品,因为活跃商品更新频繁,出问题的概率也高。再加一条隐含条件:如果 Redis 里根本没有这个商品的库存 key,而 DB 库存大于 0,那也算不一致,要重新回填。

有人会问:既然脚本每分钟跑一次,为什么只圈最近 10 分钟更新的?因为分钟级对账的目的是快速发现高频变更商品的异常,而不是做全量数据稽核。全量稽核适合放在凌晨低峰期,用独立的离线任务慢慢跑。把高频热点的对账收敛到最近更新区间,既能控制脚本运行时延,又能保证热点商品最快被发现,这是性价比最高的组合。

2.2 对账与修复策略

脚本的核心循环逻辑其实只有三步:查 DB 库存、查 Redis 库存、比较。

比较结果分为三种情况:

场景含义处理方式
DB 库存 = Redis 库存一致跳过
Redis 库存 < DB 库存Redis 缓存丢失或回补异常用 DB 值覆盖 Redis
Redis 库存 > DB 库存Redis 扣减后 DB 尚未扣,或 DB 回滚记录告警,不自动覆盖

第一种情况什么都不做。第二种情况直接SET回去,因为缓存丢了一拍,恢复成本最低。第三种情况我刚才说过,不能自动覆盖。但“不自动覆盖”不代表“不做任何事”,脚本要把差异记录下来,写进一张对账日志表,同时触发告警,让值班的人去看一眼订单回调链路是否堆积了。

修复 Redis 的时候还有个细节:写入 Redis 时要带上过期时间。库存 key 不能设成永久 key,否则商品下架或者库存清零后,Redis 里会残留一堆无用 key。一般我会对库存 key 设置一个 24 小时左右的 TTL,对账脚本每次回填都会续期,这样 Redis 内存也不会有太大压力。

2.3 脚本本身要防什么坑

对账脚本跑在线上,最大的敌人不是业务逻辑,而是“脚本把自己搞崩了”。具体来说有三类问题:

一是并发重入。Cron 触发如果频率稳定问题不大,但有时系统负载高,上一个任务还没跑完,下一个任务又被 Cron 拉起,两个进程同时去修 Redis,会出现二次覆盖、重复告警。解决办法是给脚本加锁,我用的是 Redis 的SET NX EX,或者更简单的文件锁,二选一即可。

二是内存泄漏。PHP 的 CLI 脚本如果在循环里反复建立 PDO 连接、Redis 连接,或者绑定了大量数据不释放,跑几十分钟后内存会涨上去。核心对策是:数据库连接全程复用,处理完一批数据就unset释放引用,不要用array_push无限制堆数据。

三是要避免影响主业务。线上 DB 主库很娇贵,一个每分钟跑一次的全表扫描就能把主库拖垮。所以脚本里所有查 DB 的操作都要走索引,updated_time和status要建联合索引。必要时甚至可以把SELECT请求挂到从库上,但要注意从库延迟可能导致数据旧了,因此我建议还是走主库、但严格限制查询范围,配合索引通常不会有问题。

3. 核心代码实现:从连库到落库

3.1 基础环境与连接配置

我用的是 PHP 7.4 以上版本,配合 PDO 扩展和 Redis 扩展。命令行脚本和 Web 请求不同,它没有请求上下文,所以配置项都要自己显式定义。我习惯把配置集中写在一个数组里,便于维护:

<?php declare(strict_types=1); $config = [ 'db' => [ 'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=mall;charset=utf8mb4', 'user' => 'app_user', 'pass' => 'your_password', ], 'redis' => [ 'host' => '127.0.0.1', 'port' => 6379, 'db' => 0, 'auth' => 'your_redis_auth', 'key_prefix' => 'mall:stock:', ], 'alert_enabled' => true, 'alert_webhook' => 'https://example.com/api/alert', ]; $lock = fopen('/tmp/stock_reconcile.lock', 'w');

这里有个值得注意的点:Redis 的库存 key 一定要有统一前缀,比如mall:stock:商品ID。很多项目 Redis 里面存了各种东西,如果 key 命名不规范,对账脚本根本没法从 key 反查商品 ID。

3.2 主流程实现

主流程分四步:查活跃商品、逐一对账、修复 Redis、记录异常。为了控制内存,我用 limit 分批查询,每次处理 500 个商品:

<?php try { $pdo = new PDO($config['db']['dsn'], $config['db']['user'], $config['db']['pass'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $redis = new Redis(); $redis->connect($config['redis']['host'], $config['redis']['port']); if (!empty($config['redis']['auth'])) { $redis->auth($config['redis']['auth']); } $redis->select($config['redis']['db']); $offset = 0; $limit = 500; while (true) { $stmt = $pdo->prepare( "SELECT id, stock FROM product WHERE status = 1 AND stock > 0 AND updated_time >= NOW() - INTERVAL 10 MINUTE ORDER BY id ASC LIMIT $limit OFFSET $offset" ); $stmt->execute(); $products = $stmt->fetchAll(); if (count($products) === 0) { break; } foreach ($products as $product) { $key = $config['redis']['key_prefix'] . $product['id']; $redisStock = $redis->get($key); if ($redisStock === false) { // Redis 无 key,说明缓存丢失,直接回填 $redis->setex($key, 86400, (string)$product['stock']); logReconcile($pdo, $product['id'], 'cache_missing', $product['stock'], 0, $product['stock']); continue; } $redisStock = (int)$redisStock; if ($redisStock === (int)$product['stock']) { continue; } if ($redisStock < (int)$product['stock']) { // Redis 少了,用 DB 覆盖 $redis->setex($key, 86400, (string)$product['stock']); logReconcile($pdo, $product['id'], 'redis_low', $product['stock'], $redisStock, $product['stock']); } else { // Redis 多了,不覆盖,只记录告警 logReconcile($pdo, $product['id'], 'redis_high', $product['stock'], $redisStock, $product['stock']); sendAlert($config, $product['id'], $product['stock'], $redisStock); } } $offset += $limit; } echo "reconcile done: " . date('Y-m-d H:i:s') . PHP_EOL; } catch (Throwable $e) { echo "[ERROR] " . $e->getMessage() . PHP_EOL; exit(1); }

这段代码的逻辑非常直白。$redis->get()返回false有两种可能:一是 key 不存在,二是 Redis 连接出错。为了区分这两种情况,我建议在代码里做一次显式判断:如果get返回false,再用$redis->exists($key)确认一下 key 是否真的不存在。连接出错的话,整个脚本应该直接退出并告警,而不是把“连接失败”当成“缓存丢失”去回填。

logReconcile是把对账记录写入一张日志表,字段包括商品 ID、对账时间、DB 库存、Redis 库存、修复后的库存、异常类型。这张表一是为了留痕,二是为了后续做统计,看看哪个时间段最容易出现不一致。

3.3 防重入与限流设计

锁的实现我选了文件锁,用flock判断是否已有进程在跑。相比 Redis 锁,文件锁在单机场景下更简单,也不占用 Redis 连接资源,唯一的缺陷是多机部署时每台机器都会执行一次,这时候就要改成 Redis 锁。

文件锁的写法是:

<?php $lockFile = fopen('/tmp/stock_reconcile.lock', 'w'); if (!flock($lockFile, LOCK_EX | LOCK_NB)) { echo "another process is running, quit.\n"; exit(0); }

LOCK_NB表示不阻塞,如果拿不到锁直接退出。这样即使 Cron 重复触发,也只有一个进程在执行。脚本跑完记得用flock($lockFile, LOCK_UN)释放锁,或者干脆交给 PHP 进程结束时自动释放。

还有一个隐藏问题:CLI 脚本如果因为致命错误退出,锁文件会留着,但flock的锁在文件描述符关闭时自动释放,所以不会造成“死锁”。真正要担心的是脚本没有退出码控制,导致 Cron 以为每次执行都成功了。我给脚本设计了一个约定:正常结束输出一行“reconcile done”,异常退出用exit(1),这样运维侧可以用日志监控脚本健康状态。

3.4 定时任务接入 Cron

脚本写完之后,接入定时任务非常简单。在 crontab 里加一行:

* * * * * /usr/bin/php /data/www/mall/bin/stock_reconcile.php >> /data/logs/stock_reconcile.log 2>&1

这里有个容易踩的坑:Cron 环境变量和登录 Shell 不一样,php命令的路径一定要写绝对路径。用which php查一下实际路径,不要直接写php。另外,脚本里如果用到了相对路径,比如日志目录、锁文件目录,Cron 环境下很容易出问题,所以脚本里所有路径都建议用绝对路径,或者通过__DIR__动态拼出来。

一分钟跑一次,每次跑多久不重要,但你必须知道它每次跑多久。我建议在脚本开头和结尾各记一个时间点,输出到日志里,方便后续排查性能问题。如果脚本运行时间接近 60 秒,说明处理量过大,要优化分批大小或者扩大筛选区间。

4. 常见问题与排查实录

4.1 Redis 和 DB 永远差一点

我遇到最多的现象是:脚本跑完之后,下次跑又发现同一批商品不一致。这说明对账脚本虽然修了 Redis,但业务链路里的某个环节在持续制造不一致。这时候不要急着加大脚本频率,应该去查订单回调的消费链路。

典型的情况是:业务代码里 Redis 扣减用的是DECR,但 DB 扣减用的是UPDATE product SET stock = stock - 1,两者是基于不同时间点的库存快照执行的。如果并发下单,Redis 是原子扣减,DB 的UPDATE也是原子扣减,理论上没问题;但如果 DB 扣减前先SELECT了一次库存,然后落订单、再UPDATE,中间就可能读到旧值,导致 DB 扣减结果异常。排查时可以打开订单系统的慢查询日志,看看是否存在这类非原子操作。

4.2 脚本跑着跑着卡死

脚本运行时间越来越长,甚至一次跑十几分钟,多半是查询没走索引。WHERE status = 1 AND stock > 0 AND updated_time >= NOW() - INTERVAL 10 MINUTE这个查询条件如果不建索引,数据量一上来就是全表扫描。DB 里如果有几十万商品,这个查询每秒钟都要让 DB 吃一次全表扫描的代价,DB 的 CPU 和 IO 很快就打满。

我的解决办法是给product表加一个组合索引(status, updated_time),注意顺序要按选择性来决定。实际上updated_time的选择性远高于status,所以更合理的索引应该是(updated_time, status),查询时先按时间过滤,再过滤状态,效率更高。我在线上就是这样调整的,加完索引后单次查询从几百毫秒降到了几十毫秒。

4.3 大促抢购场景下的误报

大促期间,Redis 库存会因为高并发被疯狂扣减,DB 的异步扣减滞后非常严重。这时候对账脚本很容易大量命中“Redis 库存大于 DB 库存”的场景,告警满天飞。

我有一次遇到过:一个商品 DB 库存显示 200,Redis 库存已经被扣到 50,但 DB 的回调队列里还有几百个扣减消息没处理完。用 DB 值去覆盖 Redis 显然会错误地“补回去”,导致超卖。后来我在脚本里加了一个判断:当发现 Redis 库存大于 DB 库存时,延迟 N 秒后再读一次 Redis,如果 Redis 的值还在变化,说明扣减还在继续,就跳过本次修复,只做记录。这个“观察期”机制能大幅减少误报。

另外,大促期间我会主动降低告警级别,或者直接把自动修复开关临时关掉,改为纯记录模式,等流量平稳后再恢复。

4.4 日志表膨胀太快

每分钟跑一次,每次处理几百上千个商品的对账记录,日志表一个月就能涨到几百万行。虽然这张表只做留痕,但查询起来也会变慢,尤其是你写告警页面去做筛选查询的时候。

我的建议是:日志表只保留最近 7 天的数据,用 Cron 每天凌晨清理一次;或者按月分表,比如stock_reconcile_log_202501。同时给(product_id, create_time)建联合索引,保证按商品维度追溯时性能不差。

5. 对账结果怎么通知:让异常“有人管”

脚本修 Redis 是自动的,但总有一部分异常是脚本故意不处理的,尤其是“Redis 大于 DB”这种需要业务判断的情况。如果只写进日志表,没人看就等于白搭。我当时的做法是接一个告警 webhook,把差异信息 POST 到一个统一告警接口,再由这个接口转发到工作通知群里。

告警信息要包含这几个字段:

{ "title": "库存对账异常", "product_id": 123456, "db_stock": 200, "redis_stock": 180, "diff": -20, "occur_time": "2025-01-15 10:30:00", "scene": "redis_high" }

scene字段特别重要,它让收到告警的人第一眼就知道属于哪一类异常,不用再去翻日志。我还会在告警里附带当前订单积压量、消息队列长度等上下文信息,这样值班的人能立刻判断是单纯的数据问题,还是链路拥堵导致的连锁反应。

发送告警的函数要注意一个问题:如果告警接口本身超时,不能拖垮主流程。我用的是独立的 HTTP 发送,并设置了 2 秒超时,失败就只写本地日志,不重试、不阻塞。告警是辅助手段,不能因为告警通道不好用反而把对账任务给堵住。

至于自动修复和人工介入的边界,我的原则是:只有“缓存缺失、Redis 值偏小”这类明摆着要恢复缓存的情况才自动处理;“Redis 值偏大”这类可能意味着正在进行中的业务操作的情况,一律留给人来判断。这样既能把脏数据消灭在萌芽状态,又不会因为脚本过于激进引发更大问题。

个人体会是,对账脚本最忌讳的是“想一次解决所有问题”。我一开始也尝试把所有不一致都自动校准,结果大促时差点把库存改出错。后来把所有“不确定的修复”都改成“记录+告警”,系统的稳定性反而上来了。库存对账的本质不是让 DB 和 Redis 永远相等,而是在它们不等的时候,你能快速知道、快速定位、安全恢复。把这三点做好,脚本的使命就完成了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 3:01:28

Kafka Leader Epoch机制深度解析:副本同步与数据一致性实践

1. 从一个真实故障说起&#xff1a;副本同步的“隐形杀手”如果你维护过生产环境的Kafka集群&#xff0c;大概率遇到过这样一种诡异场景&#xff1a;某个Topic的部分分区突然出现副本不同步&#xff0c;ISR列表反复收缩又扩张&#xff0c;Broker日志里频繁刷出“Leader and fol…

作者头像 李华
网站建设 2026/10/20 3:00:57

RAG从原型到生产:检索增强生成的工程化落地实践

最近好几个做后端的朋友私信我同一个问题&#xff1a;照着网上的教程把RAG&#xff08;检索增强生成&#xff09;Demo跑通了&#xff0c;问答效果看着还挺像那么回事&#xff0c;可一上真实业务数据就各种翻车。召回不准、回答幻觉、接口超时、成本爆炸&#xff0c;问题一个接一…

作者头像 李华
网站建设 2026/10/20 3:00:54

基于深度学习的PCB板缺陷检测系统完整实践指南

从产线堆满报废板&#xff0c;到模型跑通的那天晚上&#xff0c;说实话我整个人是有点恍惚的。前前后后折腾了大概两个月&#xff0c;搭完这套基于深度学习的PCB板缺陷检测系统&#xff0c;从数据标注、模型选型、训练调参&#xff0c;到最后接上工业相机部署到测试线上&#x…

作者头像 李华
网站建设 2026/10/20 3:00:10

Impala 4.0 Join策略源码解析:BROADCAST与SHUFFLE代价模型

1. 从一次执行计划的"意外选择"说起如果你在生产环境里用过 Impala 4.0 跑过多表关联&#xff0c;大概率遇到过这种让人挠头的场景&#xff1a;明明右表只有几万行&#xff0c;按直觉应该走 BROADCAST 把这张小表复制到每个节点上&#xff0c;结果执行计划里偏偏选了…

作者头像 李华
网站建设 2026/10/20 2:59:18

167k星开源AI智能体框架OpenClaw:架构解析与部署实战

167k星什么概念&#xff1f;在开源圈&#xff0c;能破万星已经算凤毛麟角&#xff0c;上十万基本就是现象级了。而 OpenClaw 这个AI智能体框架&#xff0c;硬是把"AI数字员工"从PPT里的概念&#xff0c;拽成了能下载、能部署、能真刀真枪干活的开源项目。我从它刚破万…

作者头像 李华
网站建设 2026/10/20 2:58:48

Claude Code 接入 VSCode 完整指南:集成终端配置与项目级优化

把 Claude Code 接进 VSCode 里跑&#xff0c;听起来只是把终端从系统自带换成了编辑器内置的那一个&#xff0c;但实际用下来&#xff0c;这套组合带来的体验提升远不止“换了个窗口”那么轻巧。过去写接口改造、排查多文件问题&#xff0c;总得在浏览器、终端、编辑器之间来回…

作者头像 李华