令牌模型与 lost update:一个只在并发时出现的计数 bug

令牌模型与 lost update:一个只在并发时出现的计数 bug-知集
令牌模型与 lost update:一个只在并发时出现的计数 bug
此内容为付费阅读,请付费后查看
¥1
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
付费阅读
阅读约 39 分钟11,543 字

摘要:「下载额度」这种计数类能力只有四个操作,代码不到百行,但它藏着一个只在并发时出现、单请求测试永远发现不了的 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);

三个要点:

  1. AND CAST(meta_value AS SIGNED) > 0 就是「余额够不够」的判断 —— 它被写进了 WHERE,所以「判断」与「更新」由数据库在同一条语句里原子完成,中间没有任何窗口给并发插进来;
  2. n
  3. 返回值不再靠「读一次再比」,而是靠 rows_affected:1 = 扣成功、0 = 余额不足。这是数据库给出的、可信的「这次操作到底生效了没有」;
  4. n
  5. CAST(... AS SIGNED) 不能省 —— meta_value 是长文本列,不转换的话字符串相减在语义上不可靠。
  6. 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 篇) · 系列总目录与阅读路径 →

① 前端性能与加载

② 排版与样式系统

③ 功能实现与边界

④ 系统与运维

⑤ 成就与活动系统

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容