必须用事务加行锁处理积分增减,统一记录带上下文的流水日志,查分需空值缓存防穿透,批量发放要分片限流。

积分增减必须走事务,否则并发时会丢分
ThinkPHP 自带的 Db::transaction() 是唯一靠谱的选择。直接用 update 加减字段值(比如 score = score + 10)看似简单,但在高并发下仍可能因读-改-写中间被其他请求覆盖而少加。必须把「查当前分」「计算新分」「更新」三步包进事务,并配合行锁。
实操建议:
- 用
Db::table('user')->lock(true)->where('id', $uid)->find()显式加 for update 行锁,不能只靠事务自动锁 - 更新时统一用
Db::table('user')->where('id', $uid)->update(['score' => $newScore]),别用inc(),它不参与事务回滚 - 事务内避免调用外部 HTTP 请求或耗时操作,否则锁持有时间过长,拖垮数据库
积分流水表必须记录完整上下文,不能只存变化量
线上排查时最常遇到的问题是:用户说“少了20分”,但流水里只看到一条 +20,没法定位是哪个订单、哪个接口、哪个时间点触发的。ThinkPHP 的模型事件(creating、saved)容易漏掉非模型写入路径(如原生 SQL 或定时任务)。
实操建议:
- 所有积分变更入口,无论是否走模型,都强制调用统一方法
recordScoreLog($uid, $change, $type, $related_id, $remark) -
$type必须是预定义常量(如'order_pay'、'invite_reward'),禁止传字符串拼接 -
$related_id存关联业务主键(如订单号'ORD2024052012345'),不是数字 ID,避免跨库关联失败 - 流水表字段至少含:
user_id、change_amount、balance_after(变更后余额)、type、related_id、ip、created_at
查询用户当前积分要绕开缓存穿透,别直接查数据库
用户中心页、订单确认页高频查 score 字段,如果每次请求都查库,Redis 缓存未命中时瞬间打穿 MySQL。ThinkPHP 的 Cache::get() 默认不处理空值缓存,key 不存在就反复穿透。
实操建议:
- 用
Cache::remember('score_'.$uid, 3600, function() use ($uid) { return Db::table('user')->value('score'); }),但必须加空值缓存保护 - 对
null或0结果也写入缓存,值设为'empty',并设较短过期时间(如 60 秒),防止脏数据长期滞留 - 积分变更后,立刻执行
Cache::delete('score_'.$uid),不要依赖过期,避免窗口期不一致 - 慎用
Db::cache(),它只缓存查询结果,不感知后续更新,极易导致读到旧值
批量发放积分要用 chunk + 事务分片,不能一次性 commit
运营后台导出 Excel 发放 10 万用户积分,如果用一个事务提交全部,MySQL 会锁住整个用户表,同时生成超大 undo log,极可能触发 Lock wait timeout exceeded 或 OOM。
实操建议:
- 用
Db::table('user')->chunk(500, function($users) { ... })分批,每批单独事务 - 每批内再用
Db::transaction()包裹该批用户的积分更新和流水写入 - 批次间加
usleep(10000)(10ms),缓解主库压力,避免瞬时 IO 打满 - 记录每批次起始
id和成功数,失败时可断点续发,不要重头跑
真正难的不是写完逻辑,而是当凌晨三点用户投诉“积分没到账”时,你能 30 秒内从流水表定位到那条缺失的 type='sign_in' 记录——这取决于你当初有没有把 $remark 写成 “签到奖励(v2.3.1)”,而不是 “ok”。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











