必须用数据库事务包裹积分增减操作,否则高并发下会因“查+改”非原子性导致余额错误;推荐直接使用带条件的UPDATE语句校验并更新,配合Redis缓存仅作展示、积分明细化存储、规则表驱动、显式时区处理等方案保障一致性与可维护性。

积分增减必须用数据库事务包裹
不加事务,高并发下积分会算错——比如用户同时抢券和下单,两次扣减可能都读到旧余额,结果只扣了一次。MySQL 的 UPDATE 单条语句虽原子,但「查+改」两步就不是了。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 所有涉及
user_points表的增减操作,必须走BEGIN TRANSACTION→SELECT ... FOR UPDATE(或直接用带条件的UPDATE)→COMMIT - 避免先
SELECT再 PHP 判断再UPDATE;改成一条带校验的 SQL:UPDATE users SET points = points + 10 WHERE id = 123 AND points >= 0,然后检查affected_rows是否为 1 - Redis 缓存积分仅作展示用,写操作永远以 MySQL 为准;缓存更新用延迟双删或 binlog 同步,别用 cache-aside 模式直写
过期积分不能只靠定时任务扫表
单纯每天凌晨跑 UPDATE users SET points = points - expired_points WHERE ...,大表会锁死、主从延迟飙升,且无法支撑「按批次过期」(比如注册满 365 天后首笔赠送积分才过期)。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 把积分拆成明细记录,存进
user_point_logs表,每条带expire_at和status(active/expired/consumed) - 查询当前可用积分时,用
SUM(points) WHERE status = 'active' AND expire_at > NOW(),而不是维护一个汇总字段 - 过期逻辑交给异步队列(如 Laravel Horizon 或纯 cron 调用
php artisan points:expire --batch=1000),每次只处理固定数量,避免单次执行超时
获取与消耗规则要分离存储,别硬编码在 if 里
把「签到+10」「分享+50」「退款-200」全写在控制器里,下次运营要改规则就得发版、还得测所有分支——而且没法回溯某次活动送了多少分。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 建
point_rules表:字段含event_type('signin'/'order_complete')、points、condition_json(如{"min_order_amount": 99})、is_enabled - 触发积分变动时,先查
SELECT * FROM point_rules WHERE event_type = 'order_complete' AND is_enabled = 1,再执行计算,不匹配就跳过 - 消耗类规则(如兑换优惠券)额外加
require_points和max_times_per_user字段,校验逻辑统一收口到PointService::canConsume()
PHP 时间处理必须用 DateTimeZone 显式指定
用 time() 或 date('Y-m-d') 算过期,服务器时区一变,整套规则就乱——比如配置「7天后过期」,结果在 UTC+8 服务器上生成的 expire_at 在 UTC 数据库里被当成 UTC 时间存进去,实际提前 8 小时失效。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 所有时间生成必须用
new DateTime('now', new DateTimeZone('Asia/Shanghai')),写入数据库前转成 UTC($dt->setTimezone(new DateTimeZone('UTC'))),读取时再转回本地时区 - MySQL 连接初始化时强制设时区:
$pdo->exec("SET time_zone = '+00:00'"),避免依赖系统默认 - 不要用
strtotime('+7 days'),改用$dt->modify('+7 days'),它尊重对象自身的时区上下文
真正麻烦的不是怎么加减分,而是规则变更时如何保证历史数据不翻车——比如把「签到奖励从 5 改成 10」,老用户之前领的 5 分要不要补?这类补偿逻辑得留好钩子,别等运营半夜打电话才现写脚本。











