Java高并发抽奖系统设计与实现:从原理到生产落地
1. 项目概述:为什么一个“简易”抽奖系统值得花时间深挖
Java编程实现简易抽奖系统——这标题看着平平无奇,像极了大学期末课程设计里被反复抄了八百遍的“学生成绩管理系统”。但我在某电商公司做后台支撑那会儿,亲眼见过一个没加锁、没校验、没日志的“简易抽奖”接口,在双十一大促零点被并发请求冲垮,导致三万张优惠券被重复发放,技术团队通宵回滚数据、财务连夜核算损失。后来我们复盘发现,问题根本不在“功能多复杂”,而在于对“简易”的误判:把业务逻辑的轻量级,错当成技术实现的低门槛。
这个项目真正的价值,从来不是“抽中谁”,而是如何在高并发、强一致性、可追溯、易扩展四个约束下,用最朴素的Java语法完成一次可靠的动作。它覆盖了JVM内存模型(ThreadLocal与共享变量)、集合线程安全(ArrayList vs CopyOnWriteArrayList vs ConcurrentLinkedQueue)、随机算法本质(Math.random()的伪随机缺陷 vs SecureRandom的熵池开销)、事务边界控制(数据库行锁 vs 应用层分布式锁)、甚至日志埋点设计(谁在什么时间抽了几次,中奖率是否偏离理论值)——全是Java工程师日常踩坑的高频场景。
关键词里没提“高并发”“幂等性”“可观测性”,但这些才是藏在“简易”二字背后的硬核需求。适合三类人直接拿去用:刚学完集合和多线程的在校生,用来串联知识树;中小公司需要快速上线营销活动的后端同学,能直接改配置就上线;还有面试前突击系统设计的求职者——把抽奖讲透,秒懂秒杀、抢红包、库存扣减的本质。我试过把这套代码拆解成5个版本:单机无锁版、synchronized版、ReentrantLock版、Redis分布式锁版、分段随机预生成版,每版压测QPS从800掉到3200再拉到12000,数据比任何PPT都诚实。
2. 整体架构设计与方案选型逻辑
2.1 为什么拒绝“一行代码搞定”的诱惑
网上很多教程教这么写:
List<String> users = getAllUsers(); Collections.shuffle(users); return users.get(0);看起来干净利落,但实际部署时会暴雷。我拿某社区APP的真实用户表做过测试:当getAllUsers()返回12万条记录时,Collections.shuffle()内部调用Random.nextInt(),而Random实例是共享的,多线程竞争seed字段会导致CAS失败自旋,CPU占用瞬间飙到95%。更致命的是,shuffle操作把全部用户加载进JVM堆内存,GC压力陡增,年轻代频繁Minor GC,服务响应延迟从50ms跳到2秒以上。
所以第一原则:永远不把抽奖逻辑和数据查询耦合在一起。抽奖的核心是“从N个候选者中按规则选出M个”,而不是“把N个候选者全拉过来再挑”。这就引出两个关键设计决策:
- 候选池预热机制:提前将符合资格的用户ID存入Redis Set或本地缓存,抽奖时只操作ID集合,避免查库+加载全量对象;
- 结果生成与业务解耦:中奖结果先落库/发MQ,再异步通知用户,防止抽奖接口因短信/邮件发送超时而阻塞。
2.2 四种实现路径的实测对比
我们对比了四种主流方案,压测环境为4核8G云服务器,JDK 17,MySQL 8.0,Redis 7.0:
| 方案 | 核心技术点 | 单机QPS | 数据一致性 | 扩展性 | 适用场景 |
|---|---|---|---|---|---|
| 纯内存List+Random | ArrayList + Math.random() | 18,200 | 弱(无持久化) | 差(无法水平扩展) | 内部测试、Demo演示 |
| 数据库行锁+SELECT FOR UPDATE | MySQL间隙锁+事务隔离 | 3,600 | 强(ACID保障) | 中(需分库分表) | 中小流量、强一致性要求 |
| Redis原子操作+Lua脚本 | SPOP + EVAL原子执行 | 12,500 | 中(最终一致性) | 强(Redis集群) | 高并发、允许短时重复中奖 |
| 本地缓存+分布式锁 | Caffeine + Redisson Lock | 8,900 | 强(锁保证) | 中(锁粒度影响) | 混合场景、需精确控制中奖数 |
提示:别迷信“分布式锁最安全”。我们曾用Redisson Lock实现抽奖,结果因网络抖动导致锁过期,同一用户被重复中奖两次。后来改成“锁+版本号校验”双保险,才彻底解决。
最终选定Redis原子操作+Lua脚本作为主方案,理由很实在:
- 性能碾压:Lua在Redis单线程内执行,无网络往返开销,SPOP命令O(1)时间复杂度;
- 天然幂等:SPOP移除并返回元素,同一ID不可能被抽中两次;
- 运维友好:不用改数据库结构,DBA不用半夜接告警电话;
- 降级容易:Redis挂了?切到本地ConcurrentLinkedQueue缓存池,只是失去分布式能力,不影响核心流程。
2.3 关键设计取舍:为什么不用Spring Boot Starter?
很多同学一上来就想集成Spring Boot,加@Async、@Transactional、RedisTemplate。但我在某支付平台做风控系统时发现,过度依赖框架抽象反而掩盖问题。比如@Transactional默认传播行为是REQUIRED,如果抽奖方法里调用另一个Service,事务会扩大到整个调用链,锁表时间变长;又比如RedisTemplate.opsForSet().pop()底层调用的是SRANDMEMBER,不是SPOP,无法保证元素不重复——这种细节框架文档里不会标红警告。
所以本项目坚持最小依赖原则:仅用JDK 17原生API + Jedis 4.0(轻量、直连、可控)。所有异常处理、重试逻辑、监控埋点都手动编写,虽然代码量多30%,但每个字节都清楚在做什么。比如连接Redis失败时,我们不直接抛RuntimeException,而是捕获JedisConnectionException后,自动切换到本地内存队列,并记录redis_fallback_count指标,方便后续扩容决策。
3. 核心细节解析与实操要点
3.1 候选池构建:资格校验必须前置
抽奖不是“所有人一起摇号”,而是“先筛人,再摇号”。常见错误是把资格判断(如用户等级≥3、近30天登录≥5次、未中过奖)放在抽奖方法里实时查库,这会导致每次抽奖都触发多次SQL查询,QPS上不去。
正确做法是资格预计算+缓存更新。我们设计了一个独立的资格同步服务,每天凌晨扫描用户表,生成符合资格的用户ID集合,存入Redis Set:
# Redis key设计:lottery:pool:20241025:active_users # TTL设为25小时,确保跨天平滑过渡 SADD lottery:pool:20241025:active_users "u_1001" "u_1002" ... EXPIRE lottery:pool:20241025:active_users 90000关键细节:
- 日期分片:key里带日期,避免单key过大。实测单个Set超过50万成员时,SPOP性能开始下降;
- TTL略大于24小时:防止凌晨任务延迟导致key过期,新老池子无缝衔接;
- 增量更新:用户等级变更时,通过MQ异步触发
SADD/SREM,不阻塞主流程。
注意:千万别用
KEYS lottery:pool:*查所有池子!这会阻塞Redis主线程。改用SCAN 0 MATCH lottery:pool:* COUNT 1000分批扫描。
3.2 随机算法选择:Math.random()的陷阱与SecureRandom的代价
Java里生成随机数有三个常用类:Math.random()、java.util.Random、java.security.SecureRandom。很多人以为“越安全越好”,直接上SecureRandom,结果压测时发现QPS掉了一半。
原因在于熵源消耗:
Math.random()基于java.util.Random单例,用系统时间+纳秒计数器做种子,速度快但可预测;SecureRandom默认使用/dev/random(Linux),会等待足够熵值,高并发时可能阻塞;- 我们实测在云服务器上,SecureRandom.getInstance("SHA1PRNG")比Random慢3.2倍。
解决方案是分场景选型:
- 前端展示抽奖动画:用Math.random()完全够用,用户根本感知不到伪随机;
- 后端中奖逻辑:用
ThreadLocal<Random>隔离线程,每个线程持有一个Random实例,避免竞争seed; - 金融级抽奖(如彩票):才启用SecureRandom,且预生成1000个随机数缓存,用完再批量生成。
// 线程安全的Random工具类 public class LotteryRandom { private static final ThreadLocal<Random> RANDOM_THREAD_LOCAL = ThreadLocal.withInitial(() -> new Random(System.nanoTime())); public static int nextInt(int bound) { return RANDOM_THREAD_LOCAL.get().nextInt(bound); } }3.3 中奖结果持久化:为什么必须用“先写库,再发MQ”
抽奖成功后,要记录中奖用户、奖品、时间。常见错误是“查Redis拿到ID → 查用户表 → 插入中奖记录 → 发送MQ通知”,这存在数据不一致风险:如果插入数据库成功,但MQ发送失败,用户收不到通知,以为没中奖。
正确顺序是:
- Redis SPOP获取用户ID(原子操作,保证不重复);
- 插入中奖记录到MySQL(主键为user_id+lottery_id,唯一索引防重);
- 发送MQ消息(异步,失败可重试);
- 更新Redis中奖标记(可选,用于前端实时查询)。
关键设计:
- 数据库唯一索引:
ALTER TABLE lottery_result ADD UNIQUE KEY uk_user_lottery (user_id, lottery_id); - MQ消息体包含完整上下文:user_id、prize_id、lottery_time、trace_id,方便排查;
- 本地事务表+定时任务补偿:如果MQ持续失败,定时任务扫描
lottery_result表中status=0(待通知)的记录,重新投递。
4. 实操过程与核心环节实现
4.1 环境准备与依赖配置
项目采用Maven管理,pom.xml精简到极致,只保留必要依赖:
<dependencies> <!-- JDK 17+ 原生支持 --> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.0.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.7</version> </dependency> </dependencies>注意:不引入Spring、MyBatis等框架,避免隐式依赖冲突。Jedis 4.0支持Redis 7.0的新特性(如ACL权限控制),比老版本更安全。
Jedis连接池配置(jedis.properties):
# 连接池最大空闲数,根据服务器内存调整 redis.pool.maxIdle=20 # 连接池最小空闲数,保持长连接减少握手开销 redis.pool.minIdle=5 # 获取连接最大等待时间(毫秒),超时抛异常而非死等 redis.pool.maxWaitMillis=2000 # 连接超时时间(毫秒) redis.timeout=1000 # Redis地址,生产环境建议用哨兵或集群模式 redis.host=127.0.0.1 redis.port=6379 redis.password=4.2 核心抽奖逻辑实现(含完整代码)
以下是经过生产验证的核心类,重点看drawPrize()方法的三重防护:
public class SimpleLottery { private final JedisPool jedisPool; private final DataSource dataSource; // HikariCP数据源 public SimpleLottery(JedisPool jedisPool, DataSource dataSource) { this.jedisPool = jedisPool; this.dataSource = dataSource; } /** * 抽奖主方法 - 三重防护:1. Redis原子操作 2. DB唯一索引 3. 本地缓存兜底 * @param lotteryId 活动ID * @param prizeId 奖品ID * @return 中奖用户ID,null表示未中奖 */ public String drawPrize(String lotteryId, String prizeId) { String poolKey = buildPoolKey(lotteryId); String resultKey = buildResultKey(lotteryId); // 第一重:Redis原子操作获取用户ID String userId = popUserIdFromRedis(poolKey); if (userId == null) { log.warn("Lottery pool empty, lotteryId={}", lotteryId); return null; } // 第二重:数据库插入,唯一索引防重 boolean insertSuccess = insertLotteryResult(userId, lotteryId, prizeId); if (!insertSuccess) { // 插入失败说明已中奖,把ID还回Redis池(避免浪费) try (Jedis jedis = jedisPool.getResource()) { jedis.sadd(poolKey, userId); } log.info("User already won, returned to pool, userId={}", userId); return null; } // 第三重:异步发MQ(此处简化为打印日志) sendNotificationAsync(userId, prizeId); log.info("Lottery success, userId={}, lotteryId={}, prizeId={}", userId, lotteryId, prizeId); return userId; } private String popUserIdFromRedis(String poolKey) { try (Jedis jedis = jedisPool.getResource()) { // Lua脚本保证SPOP原子性,同时返回元素和剩余数量 String script = "local user = redis.call('SPOP', KEYS[1]) " + "local count = redis.call('SCARD', KEYS[1]) " + "return {user, count}"; Object result = jedis.eval(script, Collections.singletonList(poolKey), Collections.emptyList()); List<Object> list = (List<Object>) result; return list.get(0) == null ? null : (String) list.get(0); } catch (JedisConnectionException e) { log.error("Redis connection failed, fallback to local cache", e); return popFromLocalCache(); // 本地ConcurrentLinkedQueue兜底 } } private boolean insertLotteryResult(String userId, String lotteryId, String prizeId) { String sql = "INSERT INTO lottery_result (user_id, lottery_id, prize_id, create_time) " + "VALUES (?, ?, ?, NOW())"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, userId); ps.setString(2, lotteryId); ps.setString(3, prizeId); return ps.executeUpdate() > 0; } catch (SQLIntegrityConstraintViolationException e) { // 唯一索引冲突,说明已中奖 return false; } catch (SQLException e) { log.error("Insert lottery result failed", e); return false; } } private void sendNotificationAsync(String userId, String prizeId) { // 生产环境替换为RocketMQ/Kafka发送 CompletableFuture.runAsync(() -> { log.info("Send notification to user={}, prize={}", userId, prizeId); // 模拟发送短信/站内信 }); } private String buildPoolKey(String lotteryId) { String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); return "lottery:pool:" + date + ":" + lotteryId; } private String buildResultKey(String lotteryId) { return "lottery:result:" + lotteryId; } }4.3 数据库建表与索引优化
MySQL建表语句必须包含唯一索引+分区策略:
CREATE TABLE `lottery_result` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` varchar(32) NOT NULL COMMENT '用户ID', `lottery_id` varchar(32) NOT NULL COMMENT '活动ID', `prize_id` varchar(32) NOT NULL COMMENT '奖品ID', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), -- 关键!唯一索引防重 UNIQUE KEY `uk_user_lottery` (`user_id`,`lottery_id`), -- 按时间分区,提升历史数据查询效率 KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci PARTITION BY RANGE (TO_DAYS(create_time)) ( PARTITION p202410 VALUES LESS THAN (TO_DAYS('2024-11-01')), PARTITION p202411 VALUES LESS THAN (TO_DAYS('2024-12-01')), PARTITION p_future VALUES LESS THAN MAXVALUE );实操心得:分区键必须是整数类型,
TO_DAYS()把日期转为数字,比YEAR()更精细。我们曾用YEAR(create_time)分区,结果2024年12月31日的数据全挤在p2024分区,导致查询变慢。
4.4 压测与性能调优实录
用JMeter模拟1000线程并发抽奖,初始配置下QPS仅2100,CPU使用率85%。通过三次调优提升至12500 QPS:
第一次:Jedis连接池参数优化
- 问题:
maxWaitMillis=100太小,线程排队等待; - 调整:
maxWaitMillis=2000,maxIdle=50,minIdle=20; - 效果:QPS升至4800,平均响应时间从120ms降至45ms。
第二次:Redis Key设计重构
- 问题:原key为
lottery:pool:${lotteryId},所有活动共用一个池,SPOP竞争激烈; - 调整:改为
lottery:pool:${date}:${lotteryId},按日期+活动ID分片; - 效果:QPS升至8900,Redis CPU从95%降至65%。
第三次:数据库批量插入
- 问题:单条INSERT,网络往返开销大;
- 调整:中奖结果先存本地List,每100条批量INSERT(需关闭自动提交);
- 效果:QPS稳定在12500,MySQL写入延迟从8ms降至1.2ms。
// 批量插入优化片段 public void batchInsert(List<LotteryResult> results) { String sql = "INSERT INTO lottery_result (user_id, lottery_id, prize_id, create_time) " + "VALUES (?, ?, ?, NOW())"; try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(sql)) { for (LotteryResult r : results) { ps.setString(1, r.getUserId()); ps.setString(2, r.getLotteryId()); ps.setString(3, r.getPrizeId()); ps.addBatch(); } ps.executeBatch(); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } } }5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 抽奖接口响应时间突增到2秒以上 | Redis连接池耗尽 | redis-cli info clients | grep connected_clients | 增加maxIdle,检查是否有连接泄漏(未close Jedis) |
| 同一用户多次中奖 | 数据库唯一索引未生效 | SHOW CREATE TABLE lottery_result | 确认索引类型是UNIQUE,且字段顺序与代码中INSERT顺序一致 |
| Redis中奖池突然变空 | 资格同步服务中断 | redis-cli keys "lottery:pool:*" | 检查同步服务日志,手动触发补全脚本 |
| MySQL CPU飙升到100% | lottery_result表未分区,全表扫描 | EXPLAIN SELECT * FROM lottery_result WHERE create_time > '2024-10-01' | 按时间分区,添加idx_create_time索引 |
| 中奖通知延迟超5分钟 | MQ消费者堆积 | rocketmq-tools consumerProgress -g group_name -t topic_name | 增加消费者实例,检查消费逻辑是否有IO阻塞 |
5.2 独家避坑技巧
技巧1:用Redis Pipeline替代多次SPOP
当需要一次抽10个用户时,别写10次SPOP,用Pipeline批量执行:
// 错误:10次网络往返 for (int i = 0; i < 10; i++) { jedis.spop(poolKey); } // 正确:1次往返,10次操作 Pipeline pipeline = jedis.pipelined(); for (int i = 0; i < 10; i++) { pipeline.spop(poolKey); } List<Object> results = pipeline.syncAndReturnAll();技巧2:中奖率监控必须用HyperLogLog
统计“今日参与抽奖人数”不能用SCARD(会随SPOP减少),而要用HLL:
// 用户参与时 jedis.pfadd("lottery:hll:20241025", userId); // 查询UV long uv = jedis.pfcount("lottery:hll:20241025"); // O(1)时间复杂度技巧3:本地缓存兜底的“熔断开关”
当Redis不可用时,本地ConcurrentLinkedQueue可能被刷爆内存。加一层熔断:
private final AtomicBoolean cacheFull = new AtomicBoolean(false); private final Queue<String> localCache = new ConcurrentLinkedQueue<>(); public String popFromLocalCache() { if (cacheFull.get()) return null; String id = localCache.poll(); if (id == null && localCache.size() > 10000) { cacheFull.set(true); // 触发熔断 } return id; }5.3 线上事故复盘:一次“幽灵中奖”的根因分析
某次上线后,监控发现中奖率比预期高12%。排查过程如下:
- 查日志:
grep "Lottery success" app.log \| wc -l统计中奖次数,确认异常; - 比对Redis池大小:
redis-cli scard "lottery:pool:20241025:promo_a"返回10000,但数据库中奖记录有11200条; - 抓包分析:用tcpdump抓Jedis通信,发现大量
SPOP返回(nil),但代码里没处理null,直接进入插入逻辑; - 定位Bug:
popUserIdFromRedis()方法中,jedis.eval()返回的List可能为{null, 0},但代码list.get(0) == null ? null : (String) list.get(0)没判空,强制转型报错,被try-catch吞掉,返回null后继续执行插入; - 修复:增加
if (list.get(0) == null) return null;显式判空。
踩过的坑:永远不要相信“不可能为空”的假设。线上环境里,网络抖动、Redis主从切换、Lua脚本语法错误,都会让看似稳定的接口返回意外值。所有外部依赖返回值,必须做空指针校验。
6. 扩展性设计与后续演进方向
6.1 从“简易”到“企业级”的三步升级
当前方案满足日活10万级应用,若要支撑千万级,需三步演进:
第一步:Redis集群化
- 将单节点Redis升级为Redis Cluster;
- Key设计改为
lottery:pool:{20241025}:promo_a,{}内内容决定slot,确保同一活动的所有key落在同一分片; - 使用JedisCluster替代JedisPool,自动路由。
第二步:中奖策略插件化
- 抽象
PrizeStrategy接口,支持多种算法:RandomStrategy:均匀随机;WeightedStrategy:按用户等级加权;QuotaStrategy:按奖品库存动态调整概率;
- 通过SPI机制加载,不停机切换策略。
第三步:全链路压测与混沌工程
- 用ChaosBlade注入Redis延迟、MySQL慢SQL、网络丢包;
- 监控
lottery_fallback_count(降级次数)、redis_spop_null_rate(空返回率)等黄金指标; - 设置告警阈值:空返回率>5%立即告警,>15%自动熔断。
6.2 安全加固要点
抽奖系统是黑产重点攻击目标,必须加固:
- 防刷机制:
- 用户维度:单IP 1小时内最多抽奖5次(Redis INCR + EXPIRE);
- 设备维度:校验设备指纹(UA+屏幕分辨率+字体列表哈希);
- 防篡改:
- 前端传参
lotteryId必须服务端校验有效性,禁止直接拼SQL; - 中奖结果返回时,附带HMAC签名,防止客户端伪造中奖信息;
- 前端传参
- 审计日志:
- 记录所有抽奖请求的
traceId、userId、ip、ua、responseTime; - 日志接入ELK,设置“1分钟内同一IP抽奖>10次”告警规则。
- 记录所有抽奖请求的
最后分享一个小技巧:在drawPrize()方法开头加一行log.debug("Draw start, traceId={}", MDC.get("traceId"));,配合SLF4J的MDC机制,所有日志自动带上链路ID。线上排查时,用grep "traceId=abc123"就能串起整个调用链,比翻十页日志高效十倍。这个系统跑在生产环境两年,没出过一次资损事故,靠的不是多炫酷的技术,而是对每一行代码背后“为什么”的死磕。