核心是用db::transaction()配合行级锁和唯一约束保障余额与库存不超发、不脏读;需包裹查余额、查库存、扣积分、减库存、写日志全流程,并加lock(true)、校验affectedrows、记录明细日志、建索引、清缓存、定时对账。

ThinkPHP 实现积分兑换系统,核心不是“用什么框架”,而是“怎么保证余额和库存不超发、不脏读”——TP6 的 Db::transaction() 必须用,且得配合行级锁或唯一约束,否则并发一高,积分扣成负数、商品多发几份是常态。
为什么直接用 Db::table()->where()->dec() 会出问题
常见写法:Db::name('user')->where('id', $uid)->dec('score', $need) 看似简洁,但它是先查再减再更新,中间没锁。两个请求同时读到 score=100,都判断够扣 80,结果都执行成功,最终 score 变成 20(应为 20,但实际可能被扣两次变成 -60)。
- 这不是 ThinkPHP 的 bug,是所有 ORM/QueryBuilder 在无事务+无锁时的共性风险
- TP5.1+ 默认不开启自动事务,
dec()是独立 SQL,不参与任何上下文一致性保障 - 真正安全的做法:把「查余额」「查库存」「扣积分」「减库存」「写日志」全部包进一个
Db::transaction(),且关键查询加lock(true)
兑换接口里必须写的三段关键逻辑
一个最小可用的兑换接口,要同时守住用户积分、商品库存、操作幂等三道关:
- 用
Db::transaction()包裹整个流程,任何一步失败自动回滚 - 查用户积分时加行锁:
Db::name('user')->where('id', $uid)->lock(true)->find(),防止其他请求并发修改同一行 - 扣库存必须用原子操作:
Db::name('goods')->where('id', $gid)->where('stock', '>', 0)->dec('stock'),并检查affectedRows()是否为 1 - 记录兑换日志建议用
insertGetId()并存下本次事务 ID(如uniqid('ex_')),方便对账和防重
虚拟货币(积分)的增减为何不能只靠 +\- 运算
积分本质是账户余额,必须满足:可追溯、不可篡改、有类型标记。直接 $score += 100 再 save() 会丢失操作上下文。
- 每次变动必须写明细表(如
score_log),字段至少含:user_id、type(1=签到 2=兑换 3=退款)、amount、before、after、remark - 余额字段(
score)只允许由后台脚本或事务内「根据 log 表重算」来校准,禁止前端或业务层直接赋值 - TP6 中推荐用模型事件(
afterWrite)自动触发 log 写入,但注意:事务未提交前 log 也不能提交,所以 log 表也得在同一个事务中
测试并发时最容易漏掉的两个点
本地用 ab -n 100 -c 20 或 PHP 多进程模拟请求后,90% 的超发问题其实暴露在两个地方:
- 忘记给
user.id和goods.id加数据库索引 ——lock(true)在无索引时会升级为表锁,性能崩,还可能死锁 - 兑换成功后没清缓存(如 Redis 里的
user:score:{$uid}),导致下次读到旧值,误判“还能再兑一次”
真实生产环境里,比代码更难的是对账 —— 每天定时跑脚本比对 user.score 和 sum(score_log.amount),差值不为 0 就得人工介入。这个逻辑没法偷懒,得写,还得加告警。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











