面对“签到就能领,便宜就算了,还能免费领!!”这类运营活动,很多开发同学第一时间想到的是前端弹窗和文案设计,但真正决定活动能不能上线、会不会被薅羊毛薅到破产的,往往是后端的签到系统、领奖流程和风控策略。本文将从后端研发视角,完整拆解一个高并发签到领奖系统从需求分析、数据表设计、核心代码实现到上线前风控排查的全过程,涉及 Redis 缓存、分布式锁、幂等设计、接口防刷等关键技术点,既适合刚接触秒杀类业务的后端新人,也能给正在设计营销活动系统的团队提供一份可直接落地的参考方案。
1. 签到领奖系统的业务背景与核心难点
1.1 业务背景:为什么签到活动容易“翻车”
签到领奖、免费领取优惠券、连续签到送积分,这些玩法本身并不复杂。用户每天打开 App 或小程序点一下“签到”按钮,系统记录当天签到状态,累计天数达到条件后发放奖励。看起来只是一个简单的状态记录加发奖接口,但实际落地时往往会出现以下几类问题:
- 活动上线第一天,大量用户同时点击签到,数据库压力陡增,接口超时。
- 用户通过修改客户端时间、抓包重放、批量注册小号等方式重复领取奖励。
- 一天只能签到一次的业务规则,在高并发下被并发请求绕过,同一用户当天签到两次甚至多次。
- 奖励发放接口没有做幂等,网络超时后前端重试,导致同一签到记录发放了多份奖励。
- 活动结束或用户风控命中后,没有封禁机制,黑产继续批量刷取权益。
这些问题逐一解决并不难,但如果一开始没有统一设计,后面再打补丁会非常被动。所以,这篇文章先不谈前端页面怎么做,而是把业务规则、数据模型、代码实现、风控手段串起来,梳理一整套可以复用的工程方案。
1.2 核心业务规则建模
在写代码之前,需要先把活动规则抽象成可配置的模型。常见的签到领奖规则包括:
| 规则项 | 示例 | 设计要点 |
|---|---|---|
| 签到周期 | 自然日、活动周期 | 决定“连续签到”如何计算 |
| 连续签到奖励 | 第 3 天、第 7 天额外奖励 | 需要一个规则表配置阈值 |
| 单日签到次数 | 每天最多 1 次 | 需要唯一约束或分布式锁 |
| 奖励类型 | 积分、优惠券、实物抽奖 | 发奖逻辑需要支持扩展 |
| 奖励有效期 | 领取后 30 天内有效 | 关系奖励记录的有效时间字段 |
| 领取上限 | 同一用户总领取次数限制 | 防刷需要前置校验 |
这些规则不能写死在代码里,否则每次运营调整活动都要发版。更合理的做法是提供一张活动规则配置表,把签到周期、奖励阈值、每日限额、防刷开关都放进去,后端读取配置后执行。
1.3 技术难点拆解
- 并发控制:同一用户同一时间只能签到一次,需要幂等和锁机制。
- 防刷控制:设备维度、账号维度、IP 维度都需要做限制,且要支持动态调整。
- 发奖可靠性:奖励发放涉及积分账户变更或券码生成,必须保证最终一致。
- 性能:接口热点集中在“签到”动作上,缓存要承担大部分读压力,数据库只做最终落盘。
理清这些问题后,下面进入具体实现。
2. 环境准备与项目整体结构
2.1 技术栈选型
本文示例以 Java + Spring Boot 为主,辅以 Redis 和 MySQL,这也是营销活动系统的常见组合。版本不一定必须完全一致,只要思路相同即可。
| 组件 | 作用 | 说明 |
|---|---|---|
| Spring Boot 2.x/3.x | Web 框架 | 提供接口、定时任务、依赖注入 |
| MyBatis-Plus 或 Spring Data JPA | ORM | 操作签到记录和奖励记录 |
| MySQL 5.7+ / 8.x | 数据持久化 | 存储签到记录、奖励流水、规则配置 |
| Redis 5.x+ | 缓存与计数器 | 缓存签到状态、实现分布式锁、限流计数 |
| Lombok | 简化代码 | 减少 getter/setter 样板代码 |
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路和核心代码,复制到本地后按依赖版本微调即可。
2.2 项目目录结构
建议按下面方式组织代码,把签到流程和发奖流程分开,方便后期维护。
signin-system ├── pom.xml └── src/main/java/com/example/signin ├── SigninApplication.java ├── controller │ └── SigninController.java ├── service │ ├── SigninService.java │ └── RewardService.java ├── mapper │ ├── SigninRecordMapper.java │ └── RewardRecordMapper.java ├── entity │ ├── SigninRecord.java │ ├── RewardRecord.java │ └── ActivityRule.java ├── config │ └── RedisConfig.java └── common ├── Result.java └── BizException.java2.3 核心依赖
第一步先创建 Spring Boot 工程并引入基础依赖。如果使用 Maven,核心依赖如下。
<!-- pom.xml 核心依赖 --> <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>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>Redis 的 starter 已经包含了 Lettuce 客户端,一般情况下不需要引入额外依赖。
2.4 基础配置
接着在application.yml中配置数据源和 Redis 连接信息。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/signin_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 timeout: 3000ms mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里提醒一点,serverTimezone必须设置正确,否则日期字段在插入或查询时可能出现 8 小时时差问题。
3. 数据库与缓存设计
3.1 数据库表设计
签到领奖系统至少需要以下三张核心表。
第一张:活动规则表
这张表用来配置活动周期、奖励规则和每日限额。
CREATE TABLE `activity_rule` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `activity_code` varchar(64) NOT NULL COMMENT '活动编码', `rule_type` varchar(32) NOT NULL COMMENT '规则类型:sign_cycle/reward/limit', `rule_key` varchar(64) NOT NULL COMMENT '规则键,如连续签到天数', `rule_value` varchar(128) NOT NULL COMMENT '规则值', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_activity_code` (`activity_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动规则表';第二张:签到记录表
记录每个用户每天的签到明细。这里要特别注意唯一索引的设计,它是防止重复签到的最后一道数据库防线。
CREATE TABLE `signin_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `activity_code` varchar(64) NOT NULL COMMENT '活动编码', `sign_date` date NOT NULL COMMENT '签到日期', `continue_days` int NOT NULL DEFAULT '1' COMMENT '连续签到天数', `source` varchar(16) DEFAULT 'h5' COMMENT '来源:h5/app/mini', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_activity_date` (`user_id`, `activity_code`, `sign_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';唯一索引uk_user_activity_date非常重要,它可以在数据库层面拒绝同一天重复签到。即便应用层代码出现并发漏洞,数据库仍能兜底拦截。
第三张:奖励发放记录表
记录已经发放出去的奖励,用于对账和幂等判断。
CREATE TABLE `reward_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `activity_code` varchar(64) NOT NULL COMMENT '活动编码', `signin_record_id` bigint NOT NULL COMMENT '关联签到记录ID', `reward_type` varchar(32) NOT NULL COMMENT '奖励类型:points/coupon/实物', `reward_value` varchar(64) NOT NULL COMMENT '奖励值:如积分数量或券码', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0已发放 1已使用 2已过期', `expire_time` datetime DEFAULT NULL COMMENT '过期时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_signin_record_reward` (`signin_record_id`, `reward_type`), KEY `idx_user_activity` (`user_id`, `activity_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖励发放记录表';这里的唯一索引解决的是“同一个签到记录不能重复发放同类奖励”的问题,配合应用层幂等校验,可以基本杜绝重复发奖。
3.2 Redis Key 设计与缓存策略
签到业务非常适合用 Redis 做缓存和前置校验,关键 Key 设计如下:
| Key | 类型 | 说明 | 过期时间 |
|---|---|---|---|
signin:done:{userId}:{activityCode}:{date} | String | 当天是否已签到 | 当天下个自然日 |
signin:continue:{userId}:{activityCode} | String | 连续签到天数 | 活动结束 |
signin:reward:lock:{userId}:{activityCode} | String | 发奖分布式锁 | 10 秒 |
signin:rate:{userId}:{activityCode} | String | 用户签到频率限制 | 60 秒 |
以“当天是否已签到”为例,伪代码如下:
String key = "signin:done:" + userId + ":" + activityCode + ":" + LocalDate.now(); Boolean already = redisTemplate.hasKey(key); if (Boolean.TRUE.equals(already)) { throw new BizException("今天已经签到过了"); }不过这里有一个工程经验要强调:缓存只能作为性能优化和前置拦截,不能作为唯一依赖。业务真正的正确性要由数据库的唯一索引兜底。因为 Redis 存在淘汰、宕机、过期时间不一致等可能,如果 Redis 数据丢了,用户可能被允许重复签到,这时数据库唯一索引会抛出DuplicateKeyException,应用层捕获后同样要返回“已签到”的提示。
4. 签到接口核心代码实现
4.1 签到主流程
签到主流程可以分为四个步骤:
- 参数校验和规则校验。
- 检查当天签到状态。
- 写入签到记录。
- 计算连续天数并触发发奖逻辑。
先看 Controller 层。
@RestController @RequestMapping("/api/signin") public class SigninController { @Resource private SigninService signinService; @PostMapping("/do") public Result<SigninResultVO> doSignin(@RequestParam Long userId, @RequestParam String activityCode) { return Result.success(signinService.doSignin(userId, activityCode)); } }Controller 层尽量保持轻薄,只做参数接收和结果封装。业务判断全部下沉到 Service。
接下来是核心的SigninService。
@Service public class SigninService { @Resource private StringRedisTemplate redisTemplate; @Resource private SigninRecordMapper signinRecordMapper; @Resource private RewardService rewardService; @Transactional(rollbackFor = Exception.class) public SigninResultVO doSignin(Long userId, String activityCode) { // 1. 前置规则校验 ActivityRule rule = checkActivityRule(activityCode); // 2. 检查当天是否已签到(缓存前置) String today = LocalDate.now().toString(); String signKey = "signin:done:" + userId + ":" + activityCode + ":" + today; if (Boolean.TRUE.equals(redisTemplate.hasKey(signKey))) { throw new BizException("今天已经签到过了"); } // 3. 查询最后一次签到记录,计算连续天数 SigninRecord lastRecord = signinRecordMapper.selectLastRecord(userId, activityCode); int continueDays = 1; if (lastRecord != null) { LocalDate lastDate = lastRecord.getSignDate(); LocalDate yesterday = LocalDate.now().minusDays(1); if (yesterday.equals(lastDate)) { continueDays = lastRecord.getContinueDays() + 1; } // 如果最后一次签到既不是昨天也不是今天,连续天数从 1 重新计算 } // 4. 插入签到记录,数据库唯一索引兜底 SigninRecord record = new SigninRecord(); record.setUserId(userId); record.setActivityCode(activityCode); record.setSignDate(LocalDate.now()); record.setContinueDays(continueDays); try { signinRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException("今天已经签到过了"); } // 5. 写 Redis 缓存 redisTemplate.opsForValue().set(signKey, "1", Duration.ofHours(24)); // 6. 判断是否需要发奖 List<RewardRule> rewardRules = rule.getRewardRules(); boolean rewardTriggered = false; String rewardValue = null; for (RewardRule rewardRule : rewardRules) { if (continueDays == rewardRule.getThreshold()) { rewardValue = rewardService.sendReward(userId, activityCode, record.getId(), rewardRule); rewardTriggered = true; break; } } SigninResultVO vo = new SigninResultVO(); vo.setContinueDays(continueDays); vo.setRewardTriggered(rewardTriggered); vo.setRewardValue(rewardValue); return vo; } }这段代码里有几个关键的工程细节需要专门说明。
第一,@Transactional只是让签到记录和发奖记录在同一事务里,但发奖如果依赖外部接口(比如调用券码系统),外部调用不建议放在事务里。因为外部网络调用会拉长数据库事务时间,导致连接池被占满。更合理的做法是:先记录奖励流水,状态为“发放中”,再由异步任务或消息队列去真正调外部发奖,最后更新状态。本文为了便于新手理解,先以内置发奖为例。
第二,步骤 3 查询最后一次签到记录时,可以用sign_date倒序查询。MyBatis-Plus 的 QueryWrapper 写法如下:
public SigninRecord selectLastRecord(Long userId, String activityCode) { return signinRecordMapper.selectOne(new LambdaQueryWrapper<SigninRecord>() .eq(SigninRecord::getUserId, userId) .eq(SigninRecord::getActivityCode, activityCode) .orderByDesc(SigninRecord::getSignDate) .last("limit 1")); }第三,Redis 的签到 Key 过期时间设置为 24 小时,但这里有一个小缺陷:如果用户在第 1 天的 23:59 签到,第 2 天 00:01 再来签到,此时旧 Key 可能还没过期,会出现误拦截。解决方法是把过期时间设置为“到当天 23:59:59 的剩余秒数”,而不是固定 24 小时。改造写法如下:
long seconds = Duration.between(LocalDateTime.now(), LocalDate.now().plusDays(1).atStartOfDay()).getSeconds(); redisTemplate.opsForValue().set(signKey, "1", Duration.ofSeconds(seconds));4.2 连续签到天数计算的边界问题
连续签到天数是最容易出 bug 的地方。常见错误是把“当前连续天数”直接缓存到 Redis,但连续天数的计算必须基于最后一次签到日期,而不是简单累加。
上面代码的逻辑是:
- 如果最后一次签到是昨天,今天签到后连续天数 +1。
- 如果最后一次签到是今天,说明重复签到,应该被拦截。
- 如果最后一次签到是前天或更早,说明中断,连续天数重置为 1。
举个例子:
用户 1 月 1 日签到,连续天数 1 用户 1 月 2 日签到,连续天数 2 用户 1 月 3 日未签到 用户 1 月 4 日签到,此时最后一次是 1 月 2 日,不是昨天,连续天数重置为 1这个逻辑看起来简单,但一旦引入跨天任务、服务器时区不一致或LocalDate获取方式不当,就很容易计算出错。生产环境中保持服务器时区统一非常关键。
4.3 发奖逻辑与幂等设计
奖励发放是整个活动中风险最高的环节,因为涉及真金白银。下面以“积分发放”为例实现RewardService。
@Service public class RewardService { @Resource private RewardRecordMapper rewardRecordMapper; @Resource private StringRedisTemplate redisTemplate; public String sendReward(Long userId, String activityCode, Long signinRecordId, RewardRule rewardRule) { // 1. 幂等校验:同一个签到记录不能重复发奖 RewardRecord exist = rewardRecordMapper.selectOne(new LambdaQueryWrapper<RewardRecord>() .eq(RewardRecord::getSigninRecordId, signinRecordId) .eq(RewardRecord::getRewardType, rewardRule.getRewardType()) .last("limit 1")); if (exist != null) { return exist.getRewardValue(); } // 2. 分布式锁,防止并发重复发奖 String lockKey = "signin:reward:lock:" + userId + ":" + activityCode; boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10))); if (!locked) { throw new BizException("发奖处理中,请稍后重试"); } try { // 3. 再次幂等检查 exist = rewardRecordMapper.selectOne(new LambdaQueryWrapper<RewardRecord>() .eq(RewardRecord::getSigninRecordId, signinRecordId) .eq(RewardRecord::getRewardType, rewardRule.getRewardType()) .last("limit 1")); if (exist != null) { return exist.getRewardValue(); } // 4. 模拟调用积分系统发奖 String pointsValue = rewardRule.getRewardValue(); boolean success = sendPointsToUser(userId, Integer.parseInt(pointsValue)); if (!success) { throw new BizException("发奖失败,请联系客服"); } // 5. 记录奖励流水 RewardRecord record = new RewardRecord(); record.setUserId(userId); record.setActivityCode(activityCode); record.setSigninRecordId(signinRecordId); record.setRewardType(rewardRule.getRewardType()); record.setRewardValue(pointsValue); record.setStatus(0); rewardRecordMapper.insert(record); return pointsValue; } finally { redisTemplate.delete(lockKey); } } private boolean sendPointsToUser(Long userId, int points) { // 这里对接内部积分服务,本文省略具体实现 return true; } }这段代码体现了两层幂等:第一层在进入发奖前通过reward_record表的唯一索引和应用层查询判断;第二层通过 Redis 分布式锁限制同一用户同一活动的并发发奖。即便缓存锁因为网络异常没有生效,数据库唯一索引仍然会拦截重复流水。
需要特别强调:锁的粒度是用户维度。这里不能用全局锁,否则所有用户签到都会被串行化,接口性能会严重下降。用户维度锁只影响同一个用户同时发起多次请求的场景,对整体吞吐影响不大。
4.4 接口响应设计
签到接口的返回结果建议统一封装,前端根据rewardTriggered字段决定是否弹出“恭喜获得奖励”的提示。
public class SigninResultVO { private Integer continueDays; private Boolean rewardTriggered; private String rewardValue; private Integer totalRewardCount; }对应 JSON 输出:
{ "code": 200, "message": "success", "data": { "continueDays": 7, "rewardTriggered": true, "rewardValue": "50", "totalRewardCount": 3 } }统一响应体Result可以简单设计为:
public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(int code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }统一响应结构的好处是前端不需要针对不同接口单独解析,后端新增字段也不会破坏旧版本兼容性。
5. 接口防刷与风控策略
5.1 为什么签到接口容易被刷
签到活动的奖励是免费领取的,这决定了它天然会被黑产盯上。常见的刷取方式包括:
- 同一设备注册多个账号,每天批量签到。
- 抓包分析签到接口,用脚本模拟请求。
- 通过改机工具伪造设备信息,绕过设备维度限制。
- 使用代理 IP 池,绕过 IP 频率限制。
要完全阻止黑产几乎不可能,但可以通过多维度风控把损失降到可接受范围。
5.2 应用层防刷策略
第一层:单用户单日签到次数限制
这是基础中的基础。除了数据库唯一索引外,可以在业务逻辑里提前判断。
if (redisTemplate.hasKey(signKey)) { throw new BizException("今天已经签到过了"); }第二层:接口级限流
单独一个用户短时间高频请求签到接口,可以用 Redis 计数器限流。比如限制同一用户每分钟最多请求 5 次签到接口。
public boolean checkRateLimit(Long userId, String activityCode) { String rateKey = "signin:rate:" + userId + ":" + activityCode; Long count = redisTemplate.opsForValue().increment(rateKey); if (count != null && count == 1) { redisTemplate.expire(rateKey, Duration.ofMinutes(1)); } return count != null && count <= 5; }这个方案是最简单的固定窗口限流,能够满足签到场景。如果对精确度要求更高,可以使用 Redis 的滑动窗口脚本或引入 Guava RateLimiter,但需要根据 QPS 和资源情况权衡。
第三层:设备维度限制
移动端 App 可以上报设备 ID,后端用设备 ID 做维度限制。比如同一设备 ID 最多绑定 3 个用户账号,超过后拦截签到请求。
String deviceKey = "signin:device:" + deviceId + ":" + LocalDate.now(); Long userCount = redisTemplate.opsForHash().size(deviceKey); if (userCount >= 3) { throw new BizException("该设备今日签到账号数量已达上限"); } // 签到成功后 redisTemplate.opsForHash().put(deviceKey, String.valueOf(userId), "1");第四层:IP 维度限制
同一 IP 每天签到用户数也要做限制,防止黑产在同一个出口 IP 下批量签到。
String ipKey = "signin:ip:" + ip + ":" + LocalDate.now(); Long count = redisTemplate.opsForValue().increment(ipKey); if (count != null && count == 1) { redisTemplate.expire(ipKey, Duration.ofDays(1)); } if (count > 50) { throw new BizException("当前网络环境签到人数过多,请稍后再试"); }这里的阈值 50 只是示例,实际需要根据活动规模和正常用户密度动态调整。
5.3 风控策略的工程落点
在实际项目中,风控不应该散落在签到业务代码里,建议独立出RiskControlService,专门负责各类风控判断。这样后续接入专业风控系统时,只需要切换实现,不需要改动签到主流程。
public interface RiskControlService { void checkSigninRisk(SigninRequest request); }签到接口先调风控,再走业务逻辑:
@PostMapping("/do") public Result<SigninResultVO> doSignin(@RequestBody SigninRequest request) { riskControlService.checkSigninRisk(request); return Result.success(signinService.doSignin(request.getUserId(), request.getActivityCode())); }如果活动上线初期风控策略还不完善,可以先把风控做成可配置、可降级的开关。比如:
signin: risk-control: enabled: true device-limit: 3 ip-limit: 50 rate-limit: 5运营可以根据实时数据调整阈值,不需要每次改代码。
6. 常见问题与排查思路
6.1 签到接口并发重复请求问题
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 同一用户同一天签到成功两次 | 应用层判断存在时间差,缓存和数据库都没有兜底 | 数据库添加唯一索引,捕获 DuplicateKeyException |
| 接口偶尔报“已签到”,但实际没有签到记录 | Redis 缓存误写或过期时间设置问题 | 检查 Redis Key 生命周期,必要时主动删除缓存重试 |
| 发奖流水重复,一份签到记录触发两次奖励 | 发奖逻辑没有做幂等或事务边界错误 | 奖励表加唯一索引,发奖前后双重校验,使用分布式锁 |
| 签到成功后页面仍然显示未签到 | 缓存和数据库状态不一致 | 先查缓存,缓存为空时回源数据库并重建缓存 |
这里最值得反复强调的是:唯一索引是正确性的最后一道防线,任何“先查后插”的逻辑都存在并发窗口,只有数据库约束才能彻底防住。
6.2 常见报错与排查清单
报错 1:DuplicateKeyException
说明有重复签到或重复发奖,本质上是业务预期内的情况,捕获后转成友好提示即可,不需要打印完整堆栈。
catch (DuplicateKeyException e) { log.warn("duplicate signin: userId={}, activityCode={}", userId, activityCode); throw new BizException("今天已经签到过了"); }报错 2:Redis 连接超时
签到高峰期 Redis 连接被占满,可能是因为连接池配置过小或慢查询较多。
spring: redis: lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000ms报错 3:事务失效
doSignin方法必须通过 Spring 代理调用,@Transactional才会生效。如果在同一个类内部通过this.doSignin()调用,事务会失效。确保 Controller 调用 Service 的公共方法,方法访问级别为public。
6.3 活动上线前排查清单
以下问题在活动上线前逐一确认,能显著降低事故概率:
- [ ] 签到记录表和奖励记录表的唯一索引是否已创建?
- [ ] Redis 签到 Key 过期时间是否设置为当天 23:59:59?
- [ ] 发奖外部接口超时时间和重试机制是否确认?
- [ ] 同一用户重复点签到的并发场景是否压测过?
- [ ] 设备维度、IP 维度限制阈值是否合理,是否可以动态调整?
- [ ] 活动结束后,签到入口和发奖定时任务是否会自动关闭?
- [ ] 奖励发放失败是否有对账和补发流程?
- [ ] 是否对签到接口配置了监控大盘和异常告警?
7. 工程落地最佳实践
7.1 发奖异步化
上面的示例中,发奖是同步执行的。如果奖励类型简单(如积分),同步没问题。但如果奖励是优惠券、兑换码、实物奖品,外部接口响应可能很慢,建议把发奖改为异步。
推荐方案是:签到成功后,把“用户 ID + 签到记录 ID + 奖励规则”封装成消息发送到消息队列,由独立的消费者服务处理发奖逻辑。这样签到接口的响应时间可以控制在 100ms 以内,同时发奖失败可以基于消息重试达到最终一致。
如果团队暂时没有消息队列,也可以用 Spring 的@Async配合本地表实现异步发奖,但要注意进程重启可能导致任务丢失,需要定时扫描补偿。
7.2 配置中心管理动态阈值
风控阈值、奖励配置、活动开关,这些内容不应该随代码发版。建议引入 Apollo 或 Nacos 配置中心,把活动规则和风控参数做成动态配置。配置变更实时生效,不需要重启服务。
7.3 可观测性建设
签到活动上线期间,需要重点关注以下指标:
| 指标 | 监控方式 |
|---|---|
| 签到接口 QPS 和响应时间 | Prometheus + Grafana |
| 签到成功率和重复签到拦截率 | 业务日志统计 |
| 发奖成功率和失败原因分布 | 日志关键字 + 告警 |
| Redis 命中率和连接数 | Redis 监控面板 |
| 数据库慢查询 | 慢日志收集 |
7.4 权限与操作安全
如果签到活动涉及奖励金额,数据库密码、Redis 密码、云服务器密钥都要按照最少权限原则管理,避免研发人员本地直连生产库。奖励发放的流水表应该只允许程序账号写入,人工补偿操作必须走审批流程并保留操作日志。
7.5 灰度发布与回滚
新签到活动上线建议先进行小流量灰度。可以在活动规则表里增加一个user_group字段,只对指定的测试用户或低风险用户分组开放。如果出现异常,直接把活动状态置为禁用即可,不需要紧急回滚代码。
8. 总结与下一步学习路线
签到领奖系统的核心并不在于“签到”这个动作本身,而在于如何保证“领取奖励”这个行为在高并发、恶意刷取、网络重试等复杂环境下仍然正确、可靠、不超发。本文从业务规则建模、数据表设计、缓存策略、核心签到流程、发奖幂等、风控防刷、常见问题排查等维度,完整演示了一个可落地的工程方案。
如果继续深入,建议重点学习以下几块内容:
- Redis 分布式锁的底层原理和 Redisson 锁的看门狗机制。
- 消息队列在最终一致性场景中的应用,比如 RocketMQ 的事务消息。
- 大数据量下签到明细表的冷热分离和归档策略。
- 专业的设备指纹和风控评分系统如何与业务系统对接。
对于正在设计新活动系统的团队,优先把“唯一索引 + 幂等 + 分布式锁 + 限流”这套组合落地,再逐步补充风控和监控能力。希望这篇文章对你有帮助,可以收藏备用,后续做类似活动时直接对照排错。