摘要:「下载额度」这种计数类能力只有四个操作,代码不到百行,但它藏着一个只在并发时出现、单请求测试永远发现不了的 bug —— lost update。本文用一次真实的并发对照实验证明它确实存在(5 个并发请求扣 1 次余额,直觉写法竟有 3 个都”扣成功”了),并给出「检查与扣减必须在同一条 SQL 里完成」的修法。另附计数类 API 的两条设计纪律。
适合阅读:写过余额 / 积分 / 次数限制 / 限领逻辑的开发者。文末附零依赖交付代码与 30 项断言。
一、四个操作与一个约定
计数类能力(下载次数、抽奖次数、试用次数)几乎都是这四个操作,把它们做成一个静态类就够了:
| 操作 | 语义 | 注意 |
|---|---|---|
add($uid, $n) | 发放/增加(叠加) | 必须挡掉 n <= 0 |
get($uid) | 查余额 | 只读 |
consume($uid) | 扣 1 次 | 必须原子(见第二章) |
set($uid, $n) | 绝对设置(后台手动调整) | clamp 到 >= 0;必须显式 uid |
还有一个「约定」要尽早定下来:哪些操作允许省略 uid(0 = 当前登录用户),哪些必须显式传。我的取舍是:
- 便利操作(add / get / consume)—— 0 表示「当前登录用户」,前台调用最省事;
- 危险操作(set)—— 0 直接失败。理由很实际:后台「给某用户设置额度」时,如果目标用户没选就提交,uid 会是 0;若它回落成「当前用户」,你就在悄悄改管理员的额度。让它失败并留日志,比让它”猜”要安全得多。 n
二、那个只在并发时出现的 bug
2.1 直觉写法
扣减余额的直觉写法是「读 → 判断 → 写」:
$cur = (int) get_user_meta($uid, $key, true);
if ($cur <= 0) { return false; } // 没余额了
update_user_meta($uid, $key, $cur - 1); // 扣 1
return true;
单请求下它百分之百正确。问题是用户会双击下载按钮,而两次点击的两个请求是并发的:
2.2 双击时发生了什么
余额 = 1
请求 A:读到 1 ┐
请求 B:读到 1 ┘ ← 两个请求都读到了同一个「1」
请求 A:判断 1 > 0,通过
请求 B:判断 1 > 0,通过 ← 两个请求都通过了判断
请求 A:写入 1 - 1 = 0
请求 B:写入 1 - 1 = 0 ← 两次扣减,余额只减了 1
结果:用户「扣了 2 次」,余额还剩 0 —— 实际上他只被扣了 1 次额度,另外一次白送。
反方向一样坑:两个并发的「发放 +1」,最终余额只 +1,少给一次。这类问题叫 lost update(丢失更新),它的特点是只在并发窗口内出现 —— 你用浏览器手动点一次、写个单请求测试,永远看不到。
2.3 实测:把两种写法放在同一场景下对照
为了确认这不是纸面推演,我在真实站点上做了一次对照实验:把余额设为 1,同时启动 5 个 PHP 进程去扣减,数一数有几个进程认为自己扣成功了。
| 写法 | 5 个并发扣减的结果 | 最终余额 | 结论 |
|---|---|---|---|
| 单条条件 UPDATE(交付代码) | 成功 1 个,失败 4 个 | 0 | ✅ 正确:扣减次数与余额变化一致 |
| 读 → 算 → 写(直觉写法) | 成功 3 个,失败 2 个 | 0 | ❌ 少扣 2 次:3 个请求都以为扣成功 |
实验里给直觉写法在「读」与「写」之间插了 20 毫秒延时 —— 这不是造假,而是把真实存在的间隙放大到能稳定复现:线上两个并发请求之间本来就有网络、PHP 解析、数据库往返的时间差。有间隙就有竞态,延时只是让它每次都出现。
2.4 修法:把「检查」和「扣减」放进同一条 SQL
既然问题出在「检查」与「写入」分成了两步,最直接的修法就是让它们变成一步:
$sql = $wpdb->prepare(
"UPDATE {$wpdb->usermeta}
SET meta_value = CAST(meta_value AS SIGNED) - 1
WHERE user_id = %d AND meta_key = %s
AND CAST(meta_value AS SIGNED) > 0
LIMIT 1",
$uid, $key
);
$wpdb->query($sql);
// rows_affected = 1 → 条件命中且扣成功;0 → 余额不足(条件没命中)
return (1 === (int) $wpdb->rows_affected);
三个要点:
AND CAST(meta_value AS SIGNED) > 0就是「余额够不够」的判断 —— 它被写进了 WHERE,所以「判断」与「更新」由数据库在同一条语句里原子完成,中间没有任何窗口给并发插进来; n- 返回值不再靠「读一次再比」,而是靠
rows_affected:1 = 扣成功、0 = 余额不足。这是数据库给出的、可信的「这次操作到底生效了没有」; n CAST(... AS SIGNED)不能省 ——meta_value是长文本列,不转换的话字符串相减在语义上不可靠。 n
发放(add)同理,改成原子自增:先尝试 UPDATE ... SET meta_value = CAST(meta_value AS SIGNED) + n,只有当行不存在时才 add_user_meta() 建行 —— 建行那一步是幂等的,不参与竞态。
三、计数类 API 的两条设计纪律
纪律一:危险操作要求显式目标
前面说过的 set() 约定就是这条:便利操作可以省略对象(默认当前用户),但会改动别人数据的操作必须显式指定对象。这不是洁癖 —— 它的价值在于把「参数缺失」从一个静默的错误变成一次明确的失败。同类场景还有:批量清零、删除、发放到指定用户。
纪律二:只用一个数据源(余额即真相)
这条我被坑过,也是限领 / 风控类 bug 的通用形态:不要另存一份「已用次数」再与余额对账。一旦两个数据源分叉,就会出现「判断时读 A、扣减时改 B」——用户明明还有额度却被拦,或者明明用完了还能继续。
正确的做法是:余额是唯一真相,所有判断都读它;审计日志只用来回答「谁在什么时候改了多少」,绝不参与业务判断。我在交付代码里把这条写进了注释和函数名:logs() 的文档明确写着「仅供排查,不要拿它做业务判断」。
四、边界值与非法输入
| 输入 | 处理 | 为什么 |
|---|---|---|
add($uid, -5) | 忽略(返回 false) | 🧨 不挡掉的话,「发放 -5」就变成一条隐藏的扣额后门 —— 表面上在调用「发放」接口,实际在扣用户额度 |
add($uid, 0) / 非数字 | 忽略 | 没有意义,且避免误用 |
set($uid, -3) | clamp 到 0 | 不允许出现负余额(负数会让「余额 > 0」的判断永远为真) |
余额为 0 时 consume() | 返回 false,余额保持 0 | 不能变成 -1:一旦为负,后续任何「还有额度吗」的判断都会误判为「有」 |
| 不存在的用户 id | 返回 0 / false | 不要抛异常,调用方判返回值即可 |
这几条的共同点是:坏输入不能让系统进入「看起来还能用」的状态。负余额、负数发放、静默回落目标用户,都属于这一类。
五、审计:记录,但不参与判断
每次变动都写一条记录(时间 / 动作 / 增量 / 变动后余额 / 触发者),保留最近 20 条。它解决的是「事后说不清」:用户投诉额度少了,你能一眼看到是哪一次操作、当时余额是多少。
但必须守住第 3 章的纪律二 —— 日志是日志,判断只读余额。我见过最典型的翻车方式就是把日志当成「已用次数」去判断限领,结果日志被清理(或只保留最近 20 条)后,限领直接失效。
六、实测(30 项)
| 层 | 规模 | 覆盖 | 结果 |
|---|---|---|---|
| ① 纯函数单测(PHP CLI,不需要 WP) | 16 项 | 增加量归一化(含负数后门)/ 绝对设置 clamp / 目标用户解析(含危险操作不回落)/ 常量 | 16/16 ✅ |
| ② 线上功能断言(真实站点 + 真实用户) | 12 项 | 初始余额 / add 叠加 / 负数被忽略 / consume 扣减 / 0 余额不为负 / set clamp / set(0) 被拒 / 审计三类动作 / 日志上限 / 日志与实读一致 / 不存在用户 | 12/12 ✅ |
| ③ 🔥 并发竞态对照实验 | 2 组 × 5 并发 | 单条条件 UPDATE vs 读-算-写,同一场景同时开 5 个进程扣 1 次余额 | 交付版恰好 1 个成功;直觉写法 3 个”成功” |
| ④ 测试件自清理 | — | 删除测试用户与其 meta,断言零残留 | 无残留 ✅ |
附:竞态实验怎么复现
这类 bug 的复现只需要两样东西:一个能并发跑的入口和放大的时间窗口。
# 1) 准备一个 worker:一次调用只尝试扣 1 次,打印成功/失败
# worker.php <mode> <uid>
# atomic → 交付代码的原子 consume
# naive → 读→算→写,并在读与写之间 usleep(20000) 放大间隙
# 2) 余额设为 1,然后同时开 5 个进程
php /tmp/dq_reset.php $UID 1
for i in $(seq 1 5); do php /tmp/dq_worker.php naive $UID & done
wait
# 3) 数一下有几个打印了「成功」
预期(实测结果):
atomic → 成功 1 个(余额 1 → 0)
naive → 成功 3 个(余额 1 → 0)← 3 个请求都以为自己扣到了
两个注意点:① 并发必须用独立进程(同一进程里顺序调用不会并发);② 单请求测试测不出这类问题,别指望它 —— 能并发跑起来才算测过。③ 跑完记得清理测试用户与其 meta,别在生产库留垃圾。
2026-10-05 更新:本文对应的主题模块(下载额度)设置分节后来出过一个后台「未找到字段!」——原因与令牌逻辑无关:模块把单个字段的关联数组当「字段列表」返回,CSF 遍历时把 id/type/title… 六个值逐个当字段渲染。修复与「admin 上下文探针」定位方法见《勋章活动的三个静默失败:时区、正则、角标》(本文交付代码 J 不受影响,无需改动)。
📚 zibll 子主题开发实录(共 22 篇) · 系列总目录与阅读路径 →
① 前端性能与加载
② 排版与样式系统
- 用 alt 后缀驱动的 WordPress 密集墙排版:从 float 到 flex-wrap 与纵向叠列
- 当主题模板没有过滤器:把「字数+阅读时间」并进它的 meta 行
- 设计令牌的最小实现:一个颜色变量驱动全站(附阅读进度条)
③ 功能实现与边界
- 一个 81 行的朗读按钮改了三轮:三个静默失效的坑
- 删数据类功能怎么写才不闯祸:一个「删文清附件」的六条边界
- 令牌模型与 lost update:一个只在并发时出现的计数 bug (本文)
- 「我填了 1 天,它却按 7 天算」:文章过期提示的四个边界
- 「历史上的今天」为什么永远是空的:三个不报错的死写法,与两个时区坑
- 文章系列卡片:一个 PHP 8 致命错误,和三处「不报错但白干」的写法
- 友链申请表单:一次越权写入,和一处「写了但不发」的死角
④ 系统与运维
- 404 监控的四个坑:计数翻倍、键名推导、点路径取值、与「自己看不到数据」
- 评论反垃圾的四个坑:先被自己拦住、节流伪装失败、空内容归属、与「WP 原生背锅」
- 页面缓存一次都没命中:六个静默失效,与一条 nonce 结构性保证
- 维护模式:五个「看起来在工作、其实开了后门」的写法
- WebP 自动转换:三个「上传成功、图片没变」的静默失效
- 安全扫描器:五个「扫了但没扫到」的坑,和一个实测确认的路径逃逸
⑤ 成就与活动系统










暂无评论内容