Product Pass权益系统设计:从幂等到并发防超发
2026/8/30 13:19:09 网站建设 项目流程

在实际会员制产品中,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风险因子汇总,不在业务代码里写死
绕过资格校验直接调用内部接口,跳过前置条件服务端二次校验,不能信任前端参数
并发核销超发多个请求同时读到剩余次数为 1Redis 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字段随便改。建议用状态机约束转换关系,避免出现“已回收之后还能核销”这类问题。

状态定义:

状态枚举值含义
CREATED0已创建,未激活
ACTIVATED1已激活,可正常使用
EXPIRED2已过期
REVOKED3已回收
EXHAUSTED4已用完

状态转换规则:

当前状态允许转换到触发条件
CREATEDACTIVATED支付成功或手动激活
CREATEDREVOKED发放失败、关闭订单
ACTIVATEDEXPIRED超过 end_time
ACTIVATEDREVOKED退款、风控回收
ACTIVATEDEXHAUSTEDused_quota 达到 total_quota
EXPIREDREVOKED运营介入回收
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 1

Java 侧调用:

@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_FOUNDPass 不存在检查 passNo 是否正确
PASS_DISABLEDPass 已下架不允许新发放,不影响已发放
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 最小功能验证清单

写完接口后,建议按下面的清单逐项验证:

  1. 正常领取:调用发放接口,返回 passNo,数据库新增一条记录。
  2. 重复领取:用同一个 request_id 请求两次,第二次返回第一次的 passNo,不新增记录。
  3. 并发领取:同一用户同时发 10 个请求,最终只有一条成功,其余返回重复或约束冲突。
  4. 正常核销:调用核销接口,剩余次数减一,生成一条核销流水。
  5. 超量核销:把剩余次数用到 0,再发起核销,返回 QUOTA_INSUFFICIENT。
  6. 过期核销:把 end_time 调到过去,调用核销返回 PASS_EXPIRED。
  7. 退款回收:模拟退款回调,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 的“羊毛”和“坑”都不在表面功能里,而在边界条件、并发和一致性里。把这些边界用工程手段卡住,系统才能既接得住正常用户,也防得住规则滥用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询