基于Redis Set轻松实现高并发抽奖系统(Spring Boot实战详解)
想从零手搓一个能扛住高并发的抽奖系统?Redis 的 Set(集合)数据结构,可以说是最趁手的工具之一。它的去重特性跟 Java 里的 HashSet 如出一辙——无论用户手速多快、疯狂点击多少次参与,奖池里永远只有他一个名字,重复报名的问题天然被化解。更关键的是,随机弹出元素的时间复杂度仅为 O(1),哪怕海量用户同时开抽,系统也能稳稳接住。

用 Set 实现抽奖的核心逻辑非常轻量,熟练拿捏下面三个命令就够了:
SADD key member1 member2 ...—— 把参与者扔进奖池,自动去重。SPOP key count—— 从奖池里随机抽人,并且直接移除。这非常适合“一等奖”“二等奖”这类不允许重复中奖的高并发抽奖场景。SRANDMEMBER key count—— 随机抽人,但不挪走元素。适合“阳光普照奖”“参与奖”这种允许重复中奖的玩法。
核心代码实现与接口设计
下面用 Java (Spring Boot) + Redis 把抽奖系统落地。先看 Controller 层,定义了加入奖池、抽大奖(不放回)、抽阳光奖(可放回)三个接口。
1. Controller 层:定义抽奖接口
package com.example.redissetrandomget.lottery;
import org.springframework.web.bind.annotation.*;
import ja va.util.List;
@RestController
@RequestMapping("/api/lottery")
public class LotteryController {
private final LotteryService lotteryService;
public LotteryController(LotteryService lotteryService) {
this.lotteryService = lotteryService;
}
// 加入抽奖者(支持批量)
@RequestMapping(path = "/add", method = {RequestMethod.GET, RequestMethod.POST})
public String add(@RequestParam String activityId, @RequestParam String[] userIds) {
lotteryService.addParticipants(activityId, userIds);
long remainCount = lotteryService.getRemainCount(activityId);
return "成功加入奖池!当前奖池总人数:" + remainCount;
}
// 抽核心大奖(抽完即踢出奖池,绝对不重复中奖)
@GetMapping("/drawGrand")
public List drawGrand(@RequestParam String activityId, @RequestParam long count) {
return lotteryService.drawGrandPrize(activityId, count);
}
// 抽幸运参与奖(抽完保留在奖池,下次还有机会)
@GetMapping("/drawSunshine")
public List drawSunshine(@RequestParam String activityId, @RequestParam long count) {
return lotteryService.drawSunshinePrize(activityId, count);
}
// 查询奖池剩余人数
@GetMapping("/remain")
public long remain(@RequestParam String activityId) {
return lotteryService.getRemainCount(activityId);
}
}
2. Service 层:封装 Redis 操作
Service 层负责跟 Redis 交互,顺带做了参数校验和用户 ID 的清洗,保证数据干净可靠。
package com.example.redissetrandomget.lottery;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import ja va.util.Arrays;
import ja va.util.List;
@Service
public class LotteryService {
private static final String LOTTERY_KEY_PREFIX = "lottery:activity:";
private final StringRedisTemplate redisTemplate;
public LotteryService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void addParticipants(String activityId, String... userIds) {
redisTemplate.opsForSet().add(buildKey(activityId), normalizeUserIds(userIds));
}
// 使用 pop:随机抽取并移除(适用于大奖)
public List drawGrandPrize(String activityId, long count) {
validateCount(count);
List winners = redisTemplate.opsForSet().pop(buildKey(activityId), count);
return winners != null ? winners : List.of();
}
// 使用 randomMembers:随机抽取但不移除(适用于阳光普照奖)
public List drawSunshinePrize(String activityId, long count) {
validateCount(count);
List winners = redisTemplate.opsForSet().randomMembers(buildKey(activityId), count);
return winners != null ? winners : List.of();
}
public long getRemainCount(String activityId) {
Long size = redisTemplate.opsForSet().size(buildKey(activityId));
return size != null ? size : 0L;
}
public void joinLottery(String activityId, String... userIds) {
addParticipants(activityId, userIds);
}
public List drawWithoutRepeat(String activityId, long count) {
return drawGrandPrize(activityId, count);
}
public List drawWithRepeat(String activityId, long count) {
return drawSunshinePrize(activityId, count);
}
public long participantCount(String activityId) {
return getRemainCount(activityId);
}
// --- 私有辅助方法 ---
private void validateCount(long count) {
Assert.isTrue(count > 0, "抽奖人数必须大于 0");
}
private String buildKey(String activityId) {
Assert.hasText(activityId, "活动 ID 不能为空");
return LOTTERY_KEY_PREFIX + activityId.trim();
}
private String[] normalizeUserIds(String[] userIds) {
Assert.notEmpty(userIds, "用户列表不能为空");
String[] normalizedUserIds = Arrays.stream(userIds)
.filter(StringUtils::hasText)
.map(String::trim)
.distinct()
.toArray(String[]::new);
Assert.notEmpty(normalizedUserIds, "过滤后没有合法的用户 ID");
return normalizedUserIds;
}
}
接口测试与验证
代码就绪,咱们模拟一次真实的抽奖流程。先向活动 2026 的奖池中加入 5 名测试用户。用 SCARD lottery:activity:2026 命令验证一下,确认 5 人已经成功入场。
测试一:抽取大奖(不放回)
先试试抽取 2 名一等奖。调用 drawGrand 接口:
GET https://localhost:8080/api/lottery/drawGrand?activityId=2026&count=2
接口返回了 3 号和 5 号用户。由于用的是 SPOP,这两位已经被从奖池中移走,后续抽奖绝不会再出现他们的名字。
HTTP/1.1 200 Content-Type: application/json Date: Fri, 13 Mar 2026 08:54:20 GMT [ "3", "5" ]
测试二:抽取幸运参与奖(可放回)
接下来抽取 2 名阳光普照奖。调用 drawSunshine 接口:
GET https://localhost:8080/api/lottery/drawSunshine?activityId=2026&count=2
从返回结果可以看到,2 号用户被抽中了两次!这正是 SRANDMEMBER 的魔力——只随机抽取,不删除元素,所以同一个用户在同一轮或不同轮次中都有可能重复中奖。
HTTP/1.1 200 Content-Type: application/json Date: Fri, 13 Mar 2026 08:56:28 GMT [ "2", "2" ]
总结
一套简单的抽奖系统,借助 Redis Set 的两个命令——SPOP 和 SRANDMEMBER,就能区分“大奖不放回”和“阳光普照可重复”两种核心场景。代码量不大,性能却足够应对高并发。如果你正在设计类似的抽奖活动,不妨直接拿来用。
