必须用user_points流水表并配合事务锁和余额快照,否则必现数据不一致;每笔变动需记录type、related_id、balance_after,兑换时用select...for update加锁,库存与积分均需业务层双重校验。

ThinkPHP 做积分商城,不能只写个 $user->points -= $cost 就完事。真这么干,上线三天就出数据不一致、积分凭空消失、库存超卖、对账对不上——不是“可能”,是“一定”。
为什么必须用 user_points 流水表,而不是直接改 users.points
- 直接更新
users.points字段:没有操作痕迹,查不到谁在什么时候扣了哪笔、为什么扣、关联什么订单;审计、投诉、财务对账全抓瞎 -
user_points表强制记录每笔变动的上下文:type(如'exchange_gift')、related_id(对应points_goods.id或订单号)、balance_after(操作后余额),天然支持回溯和校验 - 所有积分变动必须带
balance_after字段:避免并发时读-改-写覆盖(比如两个请求同时读到 1000 分,各自减 500,结果只剩 500 而非 0) - 示例插入语句必须含余额快照:
INSERT INTO user_points (user_id, change_amount, balance_after, type, related_id) VALUES (123, -480, 520, 'exchange_gift', 89)
兑换接口必须用事务 + 显式锁,不能靠 MySQL 默认隔离级别
READ-COMMITTED或REPEATABLE-READ都防不住库存和积分的“先查后扣”竞态:A 查库存=1,B 同时查也是 1,A 扣完 B 再扣 → 库存变 -1-
正确做法:在事务内对商品行加
SELECT ... FOR UPDATEDB::transaction(function () use ($goodsId, $userId, $requiredPoints) { $goods = DB::table('points_goods') ->where('id', $goodsId) ->where('status', 1) ->lockForUpdate() // 关键 ->first(); <p>if (!$goods || $goods->stock </p><div class="aritcle_card flexRow artxards"> <div class="artcardd flexRow"> <a class="aritcle_card_img" rel="nofollow" href="/xiazai/skill5233" title="btpanel phpsite 宝塔面板PHP网站"><img src="https://img.php.cn/upload/skill/000/000/081/179040786932301.jpg" alt="btpanel phpsite 宝塔面板PHP网站" onerror="this.onerror='';this.src='/static/lhimages/moren/morentu.png'" ></a> <div class="aritcle_card_info flexColumn"> <a rel="nofollow" href="/xiazai/skill5233" title="btpanel phpsite 宝塔面板PHP网站" class="overflowclass">btpanel phpsite 宝塔面板PHP网站</a> <p class="overflowclass">宝塔面板 PHP 网站管理:站点创建、删除、启停、PHP 版本切换、域名管理、SSL证书管理、伪静态管理、数据库管理</p> </div> <a rel="nofollow" href="/xiazai/skill5233" title="btpanel phpsite 宝塔面板PHP网站" class="aritcle_card_btn flexRow flexcenter"><b></b><span>下载</span> </a> </div> </div><p>// 再查用户可用积分(排除冻结/过期) $available = DB::table('user_points') ->where('user_id', $userId) ->orderByDesc('id') ->value('balance_after') ?: 0;</p><p>if ($available </p><p>// 扣库存、写流水、生成订单(全部在同一个事务) DB::table('points_goods')->where('id', $goodsId)->decrement('stock'); DB::table('user_points')->insert([...]); DB::table('redemption_orders')->insert([...]); });</p> 漏掉
lockForUpdate或把查积分逻辑放到事务外 → 并发下必出问题
points_goods 表的 stock 字段不能只靠数据库约束兜底
-
CHECK (stock >= 0)在老版本 MySQL( - 必须在业务层双重校验:
- 查询时加
WHERE stock > 0过滤前端可展示商品 - 扣减前再次
SELECT ... FOR UPDATE确认当前值 ≥ 1
- 查询时加
- 热点商品(如限量球衣)建议加 Redis 计数器做前置限流:
$key = "points:goods:{$goodsId}:stock"; $left = Redis::decr($key); if ($left 后续再进 DB 事务做最终一致性校验
已取消订单的积分回退不能靠“人工补单”或定时扫描
- 用户取消订单后,积分必须 实时、自动、幂等 回退,否则体验断裂(用户看到“已取消”但积分没回来)
- 正确方式:监听订单状态变更事件(如 ThinkPHP 的
Hook::listen('order.cancel')),触发补偿流水:DB::table('user_points')->insert([ 'user_id' => $orderId->user_id, 'change_amount' => $orderId->used_points, // 正数回退 'balance_after' => $currentBalance + $orderId->used_points, 'type' => 'order_cancel_refund', 'related_id' => $orderId->id, ]) - 关键细节:
- 回退前必须查最新
balance_after,不能简单+ used_points(中间可能有其他变动) - 插入前用
user_id + type + related_id唯一索引防重复执行(网络重试、消息重复)
- 回退前必须查最新
最常被跳过的环节,是每次写 user_points 都没校验 balance_after 是否等于 “上一笔余额 + 本次变动”。这个等式断了,整套积分体系就失去可信基础。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!










