webman 适合构建高性能积分系统,关键在于异步化积分变动、多租户隔离、redis 原子防超兑及定时清理过期数据。

Webman 本身不提供积分系统,但它的异步能力、协程安全的上下文、Redis 高频读写支持和可插拔中间件,恰好是构建高性能积分系统的理想底座——关键不在“有没有积分模块”,而在“如何让每笔积分变动不阻塞主流程、不跨租户污染、不丢数据”。
积分变动必须异步化,不能在 HTTP 或 WebSocket 主流程里直接写库
常见错误是:用户签到后,在 UserController::checkIn() 里直接 $db->insert() 记录积分流水,再 $db->update() 更新用户总分。QPS 上千时,数据库连接池立刻打满,后续请求排队,响应延迟飙升。
- 所有积分增减操作(签到、下单、抽奖、兑换)只做两件事:生成唯一流水号 + 写入 Redis 队列(如
queue:point:pending) - 用 Webman 的
process目录启动独立协程消费者,从队列取任务,批量 commit 到 MySQL 或 TiDB;失败任务进重试队列(queue:point:retry),带指数退避 - 用户查实时余额时,优先读 Redis 缓存(
user:123:points),缓存失效才查 DB;写操作完成后再SET缓存,避免脏读
多租户场景下,积分隔离必须靠上下文 + 强制前缀
如果系统支持多租户(比如不同品牌共用同一套积分后台),仅靠 tenant_id 字段过滤远远不够——ORM 查询漏写 WHERE tenant_id = ? 就会越权。
- 在最早期中间件(如
MultiSiteMiddleware)中调用TenantContext::setContext(),把tenant_id注入协程上下文 - 所有积分模型(
PointLogModel、UserPointModel)的save()和query()方法内部自动补上tenant_id = TenantContext::getTenantId() - Redis key 必须带租户前缀:
tenant:abc:user:123:points,而不是user:123:points;否则 A 租户的缓存可能被 B 租户误刷
高并发兑积分时,防超兑必须靠 Redis 原子操作 + 状态机
用户点击“1000 积分换 5 元券”按钮,前端重复提交或恶意脚本发起 10 次请求,若只靠 MySQL 行锁,仍可能超兑——因为锁粒度在事务内,而事务还没开始就已读出余额。
- 兑换前先用
GETSET或EVAL脚本原子读取并冻结额度:EVAL "if redis.call('GET', KEYS[1]) >= ARGV[1] then return redis.call('DECRBY', KEYS[1], ARGV[1]) else return -1 end" 1 user:123:points 1000 - 返回 -1 表示余额不足,直接拒绝;返回新余额则继续走异步扣减流程,并记录冻结流水(status = 'frozen')
- 冻结后 15 分钟未完成实际扣减,由定时协程扫描
point_log表,把超时冻结流水置为expired并回滚 Redis
积分过期与清理不能依赖 MySQL 的 EVENT 或 CRON
用 EVENT 自动删过期积分,容易在大表上锁表;用 PHP 脚本每天跑一次 DELETE FROM point_log WHERE expire_at ,高峰期执行会拖慢整个 DB。
- 过期时间统一存在 Redis Sorted Set 中:
ZADD point:expire:202607 zscore=1719619200 user:123(时间戳为 score) - 每 5 分钟启一个轻量协程,
ZRANGEBYSCORE point:expire:202607 -inf 1719619200 WITHSCORES LIMIT 100批量获取待过期用户,异步归零并落日志 - MySQL 侧只保留最近 90 天流水,老数据归档到 ClickHouse 或对象存储,避免主库膨胀
最易被忽略的是:积分变动的幂等性必须由业务层兜底,而不是依赖数据库唯一索引。因为网络重试、页面刷新、消息重复投递都会触发多次请求,而唯一索引只能拦住完全相同的 INSERT,拦不住“同一用户同类型行为多次发生”。











