在实际会员制产品中,Product Pass(产品通行证)是一种很常见的权益载体,通常用来给用户提供限时试用、内容包、功能包或一系列产品的访问权。上线这类功能时,很多团队只关注“用户能不能领到、能不能用”,却忽略了更麻烦的一层:总会有用户尝试在规则里找漏洞,用最小成本拿到最大权益,也就是俗称的“羊毛”。与此同时,开发和运营踩到的“坑”往往不在功能能不能跑通,而在并发核销、幂等、状态流转、对账和异常排查这些工程细节上。
下面以 Product Pass 权益系统为例,从业务模型、数据模型、核心接口、防滥用规则、验证排错到生产落地,逐步拆解一套可复现的设计和实现要点。文中代码用于说明工程方法,具体版本、包名和表名需要按实际项目调整。
1. 先理解 Product Pass 的业务模型,以及它到底要防什么
1.1 Product Pass 的产品形态和权益类型
Product Pass 可以理解为一种“通行凭证”。用户获得某个 Pass 后,在有效期内可以访问对应的产品或权益。常见的产品形态包括:
- 限时试用:用户领取后 7 天内可以无限次使用某项功能。
- 次数包:用户在一个月内可以使用 10 次某类服务,用完为止。
- 内容包:用户可解锁指定课程、报告、模板集合。
- 组合权益:一个 Pass 同时包含多个子权益,比如“基础功能 + 专属客服 + 高级报表”。
- 跨产品访问权:购买 A 产品后,附带 B 产品的受限访问权。
这些形态虽然在业务上差别很大,但在技术上都指向同一组问题:什么时候允许领取、领取后怎么记录、用户使用某项权益时怎么校验和扣减、权益过期或退款后怎么处理。
| 权益类型 | 典型限制维度 | 核销方式 | 容易出现的问题 |
|---|---|---|---|
| 限时访问 | 时间窗口 | 判断有效期,不扣次数 | 时区错误导致提前过期 |
| 次数包 | 总次数、剩余次数 | 每次使用扣减一次 | 并发扣减出现超发 |
| 内容包 | 内容标识、领取状态 | 按内容标识校验 | 重复领取、退款后仍可访问 |
| 组合权益 | 多个子项分别校验 | 子项独立核销 | 子项状态不一致 |
| 跨产品访问 | 产品编码、用户身份 | 产品侧回调校验 | 缺少统一鉴权入口 |
一个容易犯的错误是:把 Pass 的校验逻辑直接散落在各业务接口里。比如在订单接口里写if (userHasPass(userId)),在内容接口里再写一遍if (userHasPass(userId))。这种写法短期能跑通,但一旦权益规则变化,所有接口都要跟着改,而且很难做统一审计。
更合理的做法是:把“Pass 的发放、激活、校验、核销、失效”收敛成一个独立的领域模块,所有业务方通过统一接口访问。下面所有设计和代码都围绕这个主线展开。
1.2 “羊毛”在技术上的真实形态
标题里说的“羊毛虽大,坑也不少”,落到系统上并不是指某个黑客在攻击服务,而是指大量普通用户在规则边缘反复试探。常见形态包括:
- 同一个用户重复领取同一个 Pass。
- 一个用户使用多个账号领取同一批权益。
- 绕过资格校验,比如用脚本直接调用发放接口。
- 在并发场景下同时发起多个核销请求,把 10 次权益用成 20 次。
- 退款后,已领取的 Pass 仍然没有被回收。
- 利用缓存与数据库不一致的时间差,重复使用同一份权益。
这些行为在技术上都对应具体的检查点。
| 滥用行为 | 技术表现 | 对应防线 |
|---|---|---|
| 重复领取 | 同一用户、同一 Pass SKU 多次写入 | 数据库唯一索引、幂等键 |
| 多账号领取 | 同一设备或同一身份标识关联多个 user_id | 风险因子汇总,不在业务代码里写死 |
| 绕过资格校验 | 直接调用内部接口,跳过前置条件 | 服务端二次校验,不能信任前端参数 |
| 并发核销超发 | 多个请求同时读到剩余次数为 1 | Redis Lua 原子扣减 |
| 退款后仍使用 | Pass 状态没有随订单状态变更 | 退款回调里更新 Pass 状态机 |
| 缓存与库不一致 | 缓存扣了但流水没落库,或反过来 | 对账任务补偿 |
后面每一章都会回到这些形态。先理解这一点很有必要:防“羊毛”并不是要多复杂的风控系统,而是把关键边界用工程手段卡住,让每次领取和核销都有据可查。
2. 先设计数据模型和状态机,再写接口
2.1 核心表结构和字段说明
实现一个 Product Pass 系统,至少要三张表:Pass 定义表、用户 Pass 实例表、权益核销流水表。表结构可以按业务扩展,但核心字段建议保持一致。
以下 DDL 用于说明思路,实际项目要结合自身数据库规范调整。
CREATE TABLE product_pass ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pass_sku VARCHAR(64) NOT NULL COMMENT 'Pass 商品编码', title VARCHAR(128) NOT NULL COMMENT 'Pass 名称', quota_type TINYINT NOT NULL COMMENT '1=限时 2=次数 3=内容 4=组合', total_quota INT NOT NULL DEFAULT 0 COMMENT '总次数,限时类型可填 0', duration_days INT NOT NULL DEFAULT 0 COMMENT '有效天数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=上架 0=下架', config_json JSON COMMENT '扩展配置', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_pass_sku (pass_sku) ) COMMENT '产品通行证定义表';CREATE TABLE user_product_pass ( id BIGINT PRIMARY KEY AUTO_INCREMENT, pass_no VARCHAR(64) NOT NULL COMMENT '业务唯一编号', user_id VARCHAR(64) NOT NULL COMMENT '用户 ID', pass_sku VARCHAR(64) NOT NULL COMMENT 'Pass 编码', status TINYINT NOT NULL COMMENT '0=已创建 1=已激活 2=已过期 3=已回收 4=已用完', start_time DATETIME NULL, end_time DATETIME NULL, total_quota INT NOT NULL DEFAULT 0, used_quota INT NOT NULL DEFAULT 0, source_order_no VARCHAR(64) NULL COMMENT '来源订单号', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_pass_no (pass_no), UNIQUE KEY uk_user_pass_sku (user_id, pass_sku, source_order_no) ) COMMENT '用户产品通行证实例表';CREATE TABLE benefit_consumption ( id BIGINT PRIMARY KEY AUTO_INCREMENT, consumption_no VARCHAR(64) NOT NULL COMMENT '核销流水号', pass_no VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, benefit_code VARCHAR(64) NOT NULL COMMENT '子权益编码', request_id VARCHAR(64) NOT NULL COMMENT '幂等键', action TINYINT NOT NULL COMMENT '1=发放 2=核销 3=退还 4=回收', amount INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1 COMMENT '1=成功 0=失败', created_at DATETIME NOT NULL, UNIQUE KEY uk_request_id (request_id) ) COMMENT '权益核销流水表';这里有几个关键点:
user_product_pass表里的唯一索引uk_user_pass_sku是防重复领取的第一道防线。如果业务允许同一用户领取多个来源不同的同类 Pass,就把source_order_no也放进唯一索引。pass_no是业务编号,推荐使用带业务前缀的 UUID 或雪花 ID,不要直接用数据库自增 ID 对外暴露。- 核销流水表必须有
request_id唯一键。这样可以保证同一个业务请求重试多次时,只有第一次能真正生效。
2.2 状态机设计比接口更重要
用户 Pass 实例的状态不能只靠一个status字段随便改。建议用状态机约束转换关系,避免出现“已回收之后还能核销”这类问题。
状态定义:
| 状态 | 枚举值 | 含义 |
|---|---|---|
| CREATED | 0 | 已创建,未激活 |
| ACTIVATED | 1 | 已激活,可正常使用 |
| EXPIRED | 2 | 已过期 |
| REVOKED | 3 | 已回收 |
| EXHAUSTED | 4 | 已用完 |
状态转换规则:
| 当前状态 | 允许转换到 | 触发条件 |
|---|---|---|
| CREATED | ACTIVATED | 支付成功或手动激活 |
| CREATED | REVOKED | 发放失败、关闭订单 |
| ACTIVATED | EXPIRED | 超过 end_time |
| ACTIVATED | REVOKED | 退款、风控回收 |
| ACTIVATED | EXHAUSTED | used_quota 达到 total_quota |
| EXPIRED | REVOKED | 运营介入回收 |
| REVOKED | 无 | 终态 |
状态机不一定要引入复杂的框架,在 Service 层封装一个transition(current, target)方法即可。核心是让状态变更集中管理,而不是散落在各个业务代码里。
public class PassStatusMachine { private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put(0, new HashSet<>(Arrays.asList(1, 3))); ALLOWED_TRANSITIONS.put(1, new HashSet<>(Arrays.asList(2, 3, 4))); ALLOWED_TRANSITIONS.put(2, new HashSet<>(Collections.singletonList(3))); } public boolean canTransition(int current, int target) { Set<Integer> allowed = ALLOWED_TRANSITIONS.get(current); return allowed != null && allowed.contains(target); } }很多“坑”并不是代码逻辑写错,而是状态没有约束。比如退款回调把订单状态改了,但 Pass 状态没有同步更新;再比如用户 Pass 已经过期,但核销接口只判断了end_time,没有校验status,导致已经回收的 Pass 还能继续使用。状态机可以在编码阶段就避免这一批问题。
3. 用最小闭环实现领取、校验、核销三个核心动作
3.1 环境准备和依赖配置
本文示例使用 Java 17、Spring Boot 3.x、MySQL 8.x、Redis 6.x。实际项目请先确认依赖版本,再决定是否使用最新版本。
假设使用 Maven 管理依赖,需要引入 Web、Redis、数据库访问相关组件。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency>数据库访问层可以选用 MyBatis-Plus、Spring Data JPA 或 JdbcTemplate。下面代码以 JdbcTemplate 为例,便于展示 SQL 和事务控制。
Redis 连接配置:
spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 2s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2这里要提醒一点:如果只是本地学习,Redis 可以不开持久化;但如果是生产环境,必须根据数据重要性开启 RDB 或 AOF,并配置合理的淘汰策略。下面还会提到,缓存只是加速层,数据库流水才是最终事实源。
3.2 领取接口:唯一约束和幂等是重点
领取接口的目标是:在满足前置条件的情况下,给用户创建一个 Pass 实例。
@Service public class PassIssueService { @Transactional public String issue(IssueRequest request) { // 1. 幂等检查:同一个 request_id 只能成功一次 String existNo = benefitConsumptionDao.selectPassNoByRequestId(request.getRequestId()); if (StringUtils.hasText(existNo)) { return existNo; } // 2. 校验 Pass 是否可发放 ProductPass pass = productPassDao.selectBySku(request.getPassSku()); if (pass == null || pass.getStatus() != 1) { throw new BizException("PASS_NOT_AVAILABLE"); } // 3. 创建用户 Pass 实例 String passNo = PassNoGenerator.generate(); UserProductPass userPass = new UserProductPass(); userPass.setPassNo(passNo); userPass.setUserId(request.getUserId()); userPass.setPassSku(request.getPassSku()); userPass.setStatus(0); userPass.setTotalQuota(pass.getTotalQuota()); userPass.setUsedQuota(0); userProductPassDao.insert(userPass); // 4. 记录发放流水 benefitConsumptionDao.insert(ConsumptionBuilder.build( request.getRequestId(), passNo, request.getUserId(), request.getBenefitCode(), 1, 0 )); return passNo; } }这段代码有几个关键点:
- 第 1 步的幂等检查放在事务最前面,能减少重复请求落到业务逻辑里的概率。但真正的兜底是
benefit_consumption表上的uk_request_id唯一索引。并发场景下即使两个请求同时通过检查,数据库也只会让一个插入成功。 - 如果业务允许同一用户领取多个相同 SKU 的 Pass,需要靠
source_order_no区分。不要把唯一索引拆掉,否则重复领取的问题会重新出现。 pass_no生成器建议使用带机房标识的雪花 ID 或者 UUID,避免在分布式环境下碰撞。
3.3 核销接口:Redis Lua 原子扣减
核销是最容易出现“超发”的地方。用户点击一次使用权益,前端可能因为超时重试多次,网关也可能做自动重试。如果服务端用“先查剩余次数,再判断,再扣减”的方式,并发情况下就会超卖。
正确做法是使用 Redis 的 Lua 脚本做原子扣减,保证判断和扣减在同一次操作里完成。
-- KEYS[1] = 用户 Pass 在 Redis 中的剩余次数 key -- KEYS[2] = 用户 Pass 状态 key -- ARGV[1] = 本次要扣减的次数 -- 返回值:1 扣减成功,0 失败 local current = tonumber(redis.call('GET', KEYS[1]) or '-1') if current < 0 then return 0 end if current < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], tonumber(ARGV[1])) return 1Java 侧调用:
@Component public class QuotaService { private final StringRedisTemplate redisTemplate; public boolean tryConsume(String passNo, int amount) { String quotaKey = "pass:quota:" + passNo; String statusKey = "pass:status:" + passNo; DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(QUOTA_CONSUME_LUA); script.setResultType(Long.class); Long result = redisTemplate.execute( script, Arrays.asList(quotaKey, statusKey), String.valueOf(amount) ); return Long.valueOf(1).equals(result); } }注意,这里有一个取舍:先扣缓存,再异步落数据库流水。这种设计能抗住高并发,但会让缓存和数据库出现短暂不一致。推荐把“缓存扣减”和“数据库流水写入”通过事务消息或本地消息表解耦,并配合定时对账任务兜底。
如果团队初期没有消息队列,也可以在同一个本地事务里先写流水,再直接更新 Redis。这样一致性更好,但 Redis 操作会受数据库事务耗时影响。两种方式各有适用场景,关键是提前约定清楚,并给对账任务留好接口。
3.4 核销失败和回滚处理
核销失败不能只返回“失败”,要区分原因:
| 错误码 | 含义 | 处理建议 |
|---|---|---|
| PASS_NOT_FOUND | Pass 不存在 | 检查 passNo 是否正确 |
| PASS_DISABLED | Pass 已下架 | 不允许新发放,不影响已发放 |
| PASS_EXPIRED | 已过期 | 引导用户续期或重新购买 |
| PASS_REVOKED | 已回收 | 提示联系客服 |
| QUOTA_INSUFFICIENT | 次数不足 | 展示剩余次数 |
| REQUEST_DUPLICATED | 重复请求 | 返回上一次成功结果 |
在业务代码里,建议把核销动作拆成“预扣减”和“确认”两步。如果后续业务步骤失败,就执行“回补”动作,把预扣的次数还回去。回补也要有幂等键,避免错误地把正常扣减抵消。
public void compensate(String requestId, String passNo, int amount) { String compensationRequestId = "compensate:" + requestId; int inserted = benefitConsumptionDao.insertIfAbsent( compensationRequestId, passNo, amount ); if (inserted == 1) { quotaService.increase(passNo, amount); } }这里的关键是:回补动作本身也要做幂等。否则网络超时后重试两次,可能把用户原本已经消耗的权益也“补”回来了。
4. 防滥用规则和限流:把“羊毛”挡在规则外
4.1 资格校验要配置化,不要写死在业务代码里
防滥用的第一步是在发放前做资格校验。但资格规则经常变化,比如“只有新用户可以领取”“每天限量 1000 份”“同一设备只能领一次”。如果每次规则变更都改代码、发版,运营效率太低,Bug 概率也高。
推荐把规则做成可配置项。简单场景可以存 JSON,复杂场景建议引入规则引擎。这里给出一个最小可用的配置思路:
{ "rules": [ { "factor": "USER_REGISTER_DAYS", "operator": "LTE", "value": 30, "message": "仅限注册 30 天内的新用户" }, { "factor": "DAILY_ISSUE_COUNT", "operator": "LT", "value": 1000, "message": "今日发放已达上限" } ] }服务端在发放前加载规则,逐条执行。
public void checkEligibility(String userId, String deviceId, String passSku) { List<RuleConfig> rules = ruleConfigService.load(passSku); for (RuleConfig rule : rules) { Object actual = riskFactorService.fetch(rule.getFactor(), userId, deviceId); boolean passed = ruleEvaluator.evaluate(rule.getOperator(), actual, rule.getValue()); if (!passed) { throw new BizException("ELIGIBILITY_FAILED", rule.getMessage()); } } }这里有两个容易踩的坑:
- 不要依赖前端传入的“用户是否新用户”这类布尔值,必须由服务端根据注册时间重新计算。
- 采集用户设备、IP 等信息时,要遵守数据最小化原则,只采集业务需要的因子,并明确告知用户使用目的。不要为了风控无限采集数据,这会给合规带来风险。
4.2 接口级限流和用户级限流要分开
限流分为两类:
- 接口级限流:保护服务本身,防止被大规模脚本请求打爆。
- 用户级限流:防止单个用户或单个设备在短时间内大量领取核销。
接口级限流可以用网关层统一做,比如 Nginx 或 Spring Cloud Gateway。用户级限流适合用 Redis 计数。
下面是一个简单的固定窗口限流实现:
public boolean hit(String key, int maxCount, long windowSeconds) { String redisKey = "rate:limit:" + key; String lua = "local c = redis.call('INCR', KEYS[1]) " + "if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end " + "if c > tonumber(ARGV[2]) then return 0 else return 1 end"; DefaultRedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class); Long result = redisTemplate.execute(script, Collections.singletonList(redisKey), String.valueOf(windowSeconds), String.valueOf(maxCount)); return Long.valueOf(1).equals(result); }使用示例:
if (!rateLimitService.hit("issue:" + userId, 3, 60)) { throw new BizException("TOO_MANY_REQUESTS"); }参数参考:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 领取接口用户级 | 3 次 / 60 秒 | 防止脚本频繁领取 |
| 核销接口用户级 | 10 次 / 60 秒 | 具体值根据业务频率调整 |
| 接口级 QPS | 按压测结果 | 建议低于服务实际容量的 70% |
| 黑名单有效期 | 暂时不建议 | 容易误伤,先观察再决定 |
固定窗口限流的缺点是窗口边界可能突刺,比如 59 秒和 61 秒各允许一次,实际两次请求间隔只有 2 秒。如果需要更精确,可以改用滑动窗口或令牌桶。但如果业务对限流精度要求不高,固定窗口足够简单可靠。
4.3 对账任务:发现“羊毛”的关键手段
缓存和数据库之间会产生不一致,业务逻辑也可能有漏洞。对账任务是发现问题的最后一道防线。
对账目标包括:
- 每日发放总数和产品侧订单激活数是否一致。
- 每日核销流水数和缓存扣减总数是否一致。
- 已退款订单对应的 Pass 实例是否已进入 REVOKED 状态。
- 是否存在 status 为 ACTIVATED,但 end_time 已过期的 Pass 实例。
对账脚本可以用离线任务实现,每天凌晨执行。
-- 找出过期但状态仍然是活动中的 Pass SELECT id, pass_no, user_id, end_time FROM user_product_pass WHERE status = 1 AND end_time IS NOT NULL AND end_time < NOW() LIMIT 1000;对账发现异常的排查路径:
| 不一致现象 | 可能原因 | 处理建议 |
|---|---|---|
| 缓存有值但无流水 | 扣缓存成功,写流水失败 | 用流水表补偿回补缓存 |
| 流水存在但缓存没有 | 缓存过期或被人为清理 | 从流水重建缓存 |
| Pass 状态和订单状态不一致 | 退款回调处理失败 | 补发退款事件,重新执行状态变更 |
| 已领取数量大于发放数量 | 唯一索引未生效或删除了索引 | 检查表结构,修复后先冻结异常用户 |
对账任务本身要支持手动触发,并且每次运行都要记录执行日志。出现差异时不要直接修改数据,先导出差异明细,确认原因后再用补偿程序处理。
5. 运行验证和问题排查:从现象倒推到根因
5.1 最小功能验证清单
写完接口后,建议按下面的清单逐项验证:
- 正常领取:调用发放接口,返回 passNo,数据库新增一条记录。
- 重复领取:用同一个 request_id 请求两次,第二次返回第一次的 passNo,不新增记录。
- 并发领取:同一用户同时发 10 个请求,最终只有一条成功,其余返回重复或约束冲突。
- 正常核销:调用核销接口,剩余次数减一,生成一条核销流水。
- 超量核销:把剩余次数用到 0,再发起核销,返回 QUOTA_INSUFFICIENT。
- 过期核销:把 end_time 调到过去,调用核销返回 PASS_EXPIRED。
- 退款回收:模拟退款回调,Pass 状态变为 REVOKED,之后不能再核销。
使用 curl 快速验证发放接口:
curl -X POST http://localhost:8080/api/pass/issue \ -H "Content-Type: application/json" \ -d '{ "userId": "U10001", "passSku": "PRO_MONTHLY", "requestId": "req-001" }'预期返回:
{ "code": 0, "data": { "passNo": "PSN20250101000001" } }再请求一次同一个requestId,返回结果应该完全一致,而不是新建一个 Pass。
5.2 日志、监控和关键指标
生产环境排查问题,不能靠“感觉”。日志里必须包含足够的关键字段,方便按用户、按 Pass、按请求维度串联。
建议在打印日志时统一携带以下字段:
passNo=PSN20250101000001 userId=U10001 requestId=req-001 action=issue result=success costMs=23在日志框架中可以使用 MDC 自动携带 requestId,这样一次请求产生的所有日志都可以通过同一个 ID 搜索。
监控指标至少覆盖:
- 发放接口 QPS、成功率、耗时。
- 核销接口 QPS、成功率、错误码分布。
- Redis 剩余次数 key 的数量和过期情况。
- 对账任务执行时长和差异数量。
- 数据库新增流水量与缓存扣减量的差值。
出现问题时,优先查看错误码分布。如果某个错误码在短期内突然升高,说明有规则或接口被脚本绕过,需要立刻检查对应日志。
5.3 高频坑和排查路径
| 问题现象 | 典型原因 | 检查顺序 | 处理建议 |
|---|---|---|---|
| 重复领取仍能成功 | 唯一索引没建或索引字段选错 | 先查表结构和唯一索引,再看写入 SQL | 补唯一索引,对存量数据先做清洗 |
| 核销后剩余次数没变 | Redis 扣减成功但数据库流水失败 | 先查 Redis,再查流水表,最后看异常日志 | 用对账任务补偿,或补发 MQ 消息 |
| 领取时数据库报唯一约束错误,但业务日志没有 | 并发请求直接插入同一 request_id | 查看数据库错误日志和异常堆栈 | 在 Dao 层捕获 DuplicateKeyException 并转成幂等返回 |
| 过期时间比预期早 8 小时 | 数据库时区与 JVM 时区不一致 | 检查 MySQL time_zone、连接参数、jackson 序列化时区 | 统一使用 Asia/Shanghai,日期字段建议用 TIMESTAMP 并显式指定时区 |
| 退款后用户仍能访问权益 | 退款回调没有触发 Pass 状态回收 | 查订单状态、退款回调日志、Pass 状态机 | 增加退款事件消费者,把状态更新做成幂等 |
| 并发核销出现超发 | 代码是“先查再扣”,没有用原子操作 | 查看核销接口 Redis 调用方式和 SQL 日志 | 改用 Lua 脚本原子扣减,数据库用条件更新兜底 |
其中“先查再扣”是最常见的并发问题。错误写法如下:
int quota = quotaService.get(passNo); if (quota > 0) { quotaService.decrease(passNo); return true; } return false;两个请求同时读到 quota 为 1,都通过判断,最终扣了 2 次,实际只剩 0 次。这就是超发。正确做法是保证“判断并扣减”是一个原子操作。
5.4 时区问题的快速验证
时区问题很容易被忽略。可以在数据库中执行:
SELECT NOW(); SHOW VARIABLES LIKE '%time_zone%';在 Java 侧打印:
System.out.println(ZoneId.systemDefault()); System.out.println(new Timestamp(System.currentTimeMillis()));如果数据库返回的时间和 Java 侧相差 8 小时,先统一连接参数:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/pass_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai时区问题的坑往往不在存储,而在前端展示和接口返回序列化。建议项目里所有时间字段统一使用带时区的时间类型,并在 API 层统一格式。
6. 学习环境与生产环境的差异,以及最佳实践清单
6.1 本地验证和上线前要补齐的配置
学习环境里,服务能跑通就算完成;生产环境还需要考虑更多。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| Redis | 不持久化即可 | RDB + AOF,设置 maxmemory 和淘汰策略 |
| 数据库 | 单库单表 | 主从、备份、慢 SQL 监控 |
| 并发 | 手工验证 | 压测并设置限流 |
| 日志 | 控制台输出 | JSON 日志、集中采集 |
| 密钥 | 本地配置 | 配置中心或环境变量,禁止提交仓库 |
| 权限 | 不敏感 | 最小权限,接口鉴权 |
| 回滚 | 直接改代码 | 发布前准备数据库脚本和配置回滚方案 |
特别是 Redis 的淘汰策略。如果使用allkeys-lru,在内存不足时可能会把用户 Pass 的剩余次数 key 淘汰掉。这本身不会导致超发,因为数据库还有流水表,但如果缓存重建逻辑没有做好,用户可能会看不到剩余次数。
生产环境还要注意:核销接口的 Redis key 一定要设置合理的过期时间。过期时间不能短于整个核销流程的最长耗时,否则缓存已过期,数据库流水还在写入,对账时会出现差异。
6.2 可复用的设计评审清单
在接手或评审一个 Product Pass 模块时,可以逐项检查:
- [ ] 每个 Pass 是否有唯一 SKU,发放时是否做了 SKU 状态校验。
- [ ] 用户 Pass 实例是否有业务唯一编号,是否对外暴露自增主键。
- [ ] 重复领取是否由唯一索引兜底,而不仅仅是代码判断。
- [ ] Pass 状态是否通过状态机管理,能否防止非法状态跳转。
- [ ] 核销是否使用了原子扣减,是否同时更新缓存和流水。
- [ ] 每次核销是否有幂等键,重试是否会重复扣减。
- [ ] 退款回调是否会触发 Pass 回收,是否幂等。
- [ ] 是否有每日对账任务,能否发现缓存与数据库不一致。
- [ ] 日志是否包含 passNo、userId、requestId 等关键维度。
- [ ] 用户资格校验是否配置化,是否依赖前端参数。
- [ ] 是否对领取和核销接口做了用户级限流。
- [ ] 生产环境 Redis 是否有持久化和合理过期策略。
这份清单既是代码评审工具,也可以作为接手遗留系统时的风险排查表。
6.3 扩展方向
如果系统已经稳定运行,下一步可以考虑:
- 把核销流水写入消息队列,异步落到数据库,进一步提升接口吞吐。
- 引入规则引擎,让运营在后台可视化配置资格和限流规则。
- 增加多租户隔离,不同业务线拥有独立的 Pass 产品和核销策略。
- 对“组合权益”做更细粒度的子权益状态管理,避免一个子权益耗尽导致整个 Pass 不可用。
- 使用可观测性工具,把发放、核销、对账全链路的 trace 串起来。
无论怎么扩展,核心原则不变:数据库流水是最终事实源,缓存只是加速层;状态变更必须受约束;所有写操作必须有幂等键;异常情况必须能通过对账发现。回到标题那句话,Product Pass 的“羊毛”和“坑”都不在表面功能里,而在边界条件、并发和一致性里。把这些边界用工程手段卡住,系统才能既接得住正常用户,也防得住规则滥用。