资讯详情

资讯详情

Java高并发抽奖系统:MySQL事务+Redis限流+加权算法实战

简介这是一套开箱即用的Java抽奖系统实战项目面向Java初学者与SpringBoot开发者解决活动运营中大转盘抽奖功能从设计到落地的全流程问题。资源包含完整前后端源码、MySQL数据库脚本、Redis缓存配置及核心抽奖算法实现覆盖用户参与、奖品预热、概率控制、中奖记录与结果通知等关键业务环节。压缩包共169个文件含49个Java业务类如LotteryServiceImpl、LotteryController、RewardContext、52个编译后class文件、32张界面与奖品图示png/jpg、8个MyBatis映射XML及2个SQL建表脚本整体仅2.01MB轻量易部署。已有2924人学习下载提供清晰的模块划分抽奖服务、事件监听、Redis初始化、DTO与实体定义和可直接运行的SpringBoot工程结构特别适合理解SpringEvent事件驱动机制、Redis预热奖品、动态权重抽奖算法等进阶实践。1. 这不是个“转盘动效”Demo而是一套可上线的抽奖业务闭环Java后端控制权、MySQL事务防重抽、Redis原子计数限流、前端Vue/Thymeleaf双模渲染连数据库初始化脚本都打包进schema.sql——新手照着README.md改3个配置就能跑通老手直接抠出DrawService.java里的加权轮询布隆过滤器组合算法复用到自己项目你见过太多“Java大转盘”教程前端画个Canvas转盘点击触发一个Math.random()后台只返回{code:0,prize:谢谢参与}。这种代码连测试环境都过不了——并发100人点一次奖品发超3倍用户刷新页面重抽库存没扣就又中了iPhone管理员想查谁中了什么奖日志里只有时间戳和IP。真正的抽奖系统核心不在转盘动画而在奖品池状态一致性、抽取过程不可逆、结果可审计、风控可配置。本项目把这四点全落到代码里MySQL用SELECT ... FOR UPDATE锁库存行Redis用INCREXPIRE做单用户当日抽次数限制算法层封装了「动态权重调整」接口运营后台调用/admin/prize/update-weight就能实时生效前后端分离部署时用JWT透传用户ID防伪造。它不教你怎么画指针旋转而是告诉你当第5001个用户点击“开始”时DrawServiceImpl里第37行的ReentrantLock如何与第89行的Transactional(rollbackFor Exception.class)协同确保“扣库存→写中奖记录→发MQ通知→更新Redis计数”这四步要么全成要么全滚。适合需要快速交付营销活动、又不愿在抽奖逻辑上埋雷的Java后端或全栈工程师。2. 用Spring Boot MyBatis-Plus在本地跑通最小可运行抽奖服务从JDK17环境配置到一键启动的完整链路2.1 环境准备JDK17 Maven3.8.6 MySQL8.0.33 Redis6.2Docker一键拉起提示本项目明确要求JDK17因使用了switch表达式Java14和Record类Java14定义PrizeResult若用JDK8编译会报class file has wrong version 61.0错误。不要试图降级JDK版本否则DrawAlgorithm.java中Map.ofEntries()语法会编译失败。先确认Java环境java -version # 输出必须为 openjdk version 17.0.1 2021-10-19 # 若未安装去官网下载JDK17配置JAVA_HOME指向jdk-17.x.x目录MySQL和Redis用Docker启动避免本地环境冲突# 启动MySQL挂载数据卷并初始化编码 docker run -d --name lottery-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDlottery123 \ -v $(pwd)/mysql-data:/var/lib/mysql \ -e MYSQL_COLLATION_SERVERutf8mb4_unicode_ci \ -e MYSQL_CHARACTER_SET_SERVERutf8mb4 \ mysql:8.0.33 # 启动Redis仅需基础功能无需持久化 docker run -d --name lottery-redis \ -p 6379:6379 \ redis:6.2-alpine验证服务连通性# 测试MySQL连接用mysql-client容器 docker run -it --rm --network host mysql:8.0.33 mysql -h127.0.0.1 -P3306 -uroot -plottery123 -e SELECT VERSION(); # 应输出 8.0.33 # 测试Redis连接 redis-cli -h 127.0.0.1 -p 6379 ping # 应返回 PONG2.2 数据库初始化执行schema.sql创建表结构与初始奖品池项目根目录下的schema.sql包含三张核心表prize_pool奖品池、user_draw_log用户抽奖日志、prize_config奖品配置。执行前需手动创建数据库-- 在MySQL中执行 CREATE DATABASE IF NOT EXISTS lottery_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE lottery_db;然后导入SQL脚本注意路径替换mysql -h127.0.0.1 -P3306 -uroot -plottery123 lottery_db schema.sqlschema.sql关键建表语句解析-- prize_pool存储每个奖品当前剩余库存status1表示启用 CREATE TABLE prize_pool ( id bigint NOT NULL AUTO_INCREMENT, prize_code varchar(32) NOT NULL COMMENT 奖品唯一编码如IPHONE15_PRO, prize_name varchar(100) NOT NULL, stock int NOT NULL DEFAULT 0 COMMENT 剩余库存, total_stock int NOT NULL DEFAULT 0 COMMENT 总库存, weight int NOT NULL DEFAULT 100 COMMENT 中奖权重越大越易中, status tinyint NOT NULL DEFAULT 1 COMMENT 1启用0禁用, PRIMARY KEY (id), UNIQUE KEY uk_prize_code (prize_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- user_draw_log每次抽奖必写入含用户ID、奖品编码、时间戳、IP CREATE TABLE user_draw_log ( id bigint NOT NULL AUTO_INCREMENT, user_id varchar(64) NOT NULL COMMENT 用户唯一标识, prize_code varchar(32) DEFAULT NULL, draw_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, ip_address varchar(45) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1成功0失败, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_draw_time (draw_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意prize_pool.stock字段是乐观锁实现的基础。每次扣减库存时SQL条件为WHERE id ? AND stock 0若影响行数为0则说明库存已售罄直接返回“未中奖”。这比单纯查库存再update更安全避免ABA问题。2.3 后端启动修改application.yml三处配置mvn spring-boot:run即可访问打开src/main/resources/application.yml修改以下三项配置项原值修改为说明spring.datasource.urljdbc:mysql://localhost:3306/lottery_dbjdbc:mysql://127.0.0.1:3306/lottery_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue显式指定时区避免datetime字段存取错乱spring.redis.hostlocalhost127.0.0.1Docker网络下用127.0.0.1而非localhostlottery.draw.limit-per-day3按需设为5或10单用户每日最大抽奖次数由Redis计数器控制保存后执行mvn clean compile spring-boot:run启动成功标志Started LotteryApplication in 5.234 seconds (JVM running for 6.121)此时访问http://localhost:8080/swagger-ui.html可看到API文档核心接口POST /api/draw用户抽奖入口需传userId参数GET /api/prize/list获取当前启用奖品列表供前端渲染转盘GET /api/log/user/{userId}查询某用户历史中奖记录2.4 前端运行Vue3版本用Vite启动Thymeleaf版本直接访问http://localhost:8080项目提供两种前端方案Vue3版本推荐进入frontend-vue目录cd frontend-vue npm install npm run dev自动打开http://localhost:5173页面调用后端/api/prize/list获取奖品点击“开始抽奖”调用/api/draw。Thymeleaf版本零依赖无需额外启动直接访问http://localhost:8080/index.html。后端Controller已配置GetMapping(/index.html)返回Thymeleaf模板所有JS/CSS内联在HTML中适合内网快速演示。提示Vue版本中src/composables/useDraw.js封装了防抖节流逻辑——用户连续点击时第二次请求会在Promise.race()中被AbortController中断避免重复提交。这是前端第一道防线但不能替代后端幂等校验。3. 抽奖算法深度解析加权随机轮询Weighted Random Selection与布隆过滤器Bloom Filter的工业级组合应用3.1 为什么不用Math.random()——传统随机算法在高并发下的致命缺陷常见误区用Random.nextInt(totalWeight)生成随机数遍历奖品池累加权重直到匹配。伪代码如下int random new Random().nextInt(totalWeight); int sum 0; for (Prize prize : prizeList) { sum prize.getWeight(); if (random sum) return prize; }此算法在单机低并发下可行但存在三大硬伤权重更新不实时totalWeight需全局缓存运营修改某奖品权重后缓存未失效会导致旧权重持续生效无库存感知即使prize.getStock() 0只要权重0仍可能被选中需额外if(stock0)判断但并发下库存可能被其他请求扣减性能瓶颈奖品池达1000时每次抽奖需O(n)遍历QPS超500即CPU打满。本项目采用预计算缓存穿透防护双策略彻底规避上述问题。3.2 加权轮询算法实现WeightedPrizeSelector.java中的二分查找优化核心类com.lottery.algorithm.WeightedPrizeSelector将权重转化为累积分布数组CDF用二分查找替代线性遍历// 初始化阶段构建CDF数组按prize_code排序保证稳定性 private void buildCdfArray() { ListPrize activePrizes prizeMapper.selectActivePrizes(); // SQL: WHERE status1 AND stock0 this.cdfArray new long[activePrizes.size()]; long sum 0; for (int i 0; i activePrizes.size(); i) { sum activePrizes.get(i).getWeight(); this.cdfArray[i] sum; // cdfArray[i] weight[0]...weight[i] } this.totalWeight sum; } // 抽奖时生成随机数在CDF数组中二分查找 public Prize selectPrize() { if (cdfArray.length 0) return null; long random ThreadLocalRandom.current().nextLong(totalWeight); // [0, totalWeight) int index binarySearch(cdfArray, random); // 返回首个 random 的索引 return prizeList.get(index); } private int binarySearch(long[] arr, long target) { int left 0, right arr.length - 1; while (left right) { int mid left (right - left) / 2; if (arr[mid] target) { left mid 1; } else { right mid; } } return left; }参数说明binarySearch返回的是累积权重首次超过随机数的位置对应奖品在prizeList中的索引。时间复杂度从O(n)降至O(log n)1000个奖品时查找仅需10次比较。3.3 布隆过滤器拦截无效抽奖BloomFilterDrawGuard.java防止恶意刷奖即使算法精准用户仍可能通过脚本高频请求/api/draw。本项目在DrawService.draw()方法开头插入布隆过滤器校验Component public class BloomFilterDrawGuard { // 使用Redis Bitmap实现布隆过滤器k3哈希函数 private static final int HASH_COUNT 3; private static final String BLOOM_KEY_PREFIX lottery:bloom:; public boolean mightAllow(String userId, String dateStr) { String key BLOOM_KEY_PREFIX dateStr; // 每日独立布隆位图 long hash1 murmur3Hash(userId 1) % 10000000; long hash2 murmur3Hash(userId 2) % 10000000; long hash3 murmur3Hash(userId 3) % 10000000; // 检查三个位是否全为1 Boolean bit1 redisTemplate.opsForValue().getBit(key, hash1); Boolean bit2 redisTemplate.opsForValue().getBit(key, hash2); Boolean bit3 redisTemplate.opsForValue().getBit(key, hash3); return bit1 ! null bit1 bit2 ! null bit2 bit3 ! null bit3; } public void add(String userId, String dateStr) { String key BLOOM_KEY_PREFIX dateStr; long hash1 murmur3Hash(userId 1) % 10000000; long hash2 murmur3Hash(userId 2) % 10000000; long hash3 murmur3Hash(userId 3) % 10000000; redisTemplate.opsForValue().setBit(key, hash1, true); redisTemplate.opsForValue().setBit(key, hash2, true); redisTemplate.opsForValue().setBit(key, hash3, true); } }逻辑说明每日为每个用户生成3个哈希位写入Redis Bitmap。当用户当日抽奖次数已达上限如3次后续请求在mightAllow()中返回false直接拒绝。布隆过滤器有误判率约0.1%但绝无漏判——即被拒绝的用户100%已超限被放行的用户可能未超限需后续Redis计数器二次校验。这种设计牺牲极小精度换取极高性能10万QPS下Redis压力几乎为0。3.4 算法组合调用链DrawServiceImpl.draw()中的七层校验真实抽奖流程不是单点算法而是多层防护链Transactional(rollbackFor Exception.class) public DrawResult draw(String userId) { // 1. 布隆过滤器初筛毫秒级 if (!bloomFilterGuard.mightAllow(userId, today)) { return DrawResult.fail(今日抽奖次数已用完); } // 2. Redis计数器精确校验原子操作 String countKey lottery:count: userId : today; Long currentCount redisTemplate.opsForValue().increment(countKey, 1); if (currentCount drawLimitPerDay) { return DrawResult.fail(今日抽奖次数已用完); } redisTemplate.expire(countKey, Duration.ofDays(1)); // 自动过期 // 3. 从MySQL读取当前可用奖品带FOR UPDATE锁 ListPrize prizes prizeMapper.selectActivePrizesForUpdate(); // 4. 加权随机选择O(log n) Prize selectedPrize weightedPrizeSelector.selectPrize(prizes); // 5. 扣减库存UPDATE ... WHERE stock 0影响行数0则失败 int updated prizeMapper.decreaseStock(selectedPrize.getId()); if (updated 0) { // 库存不足回退Redis计数器 redisTemplate.opsForValue().decrement(countKey, 1); return DrawResult.fail(奖品已被抢光); } // 6. 写入中奖日志主键自增强一致性 DrawLog log new DrawLog(); log.setUserId(userId); log.setPrizeCode(selectedPrize.getPrizeCode()); log.setIpAddress(getClientIp()); drawLogMapper.insert(log); // 7. 发送MQ通知异步解耦此处省略RocketMQ代码 sendDrawSuccessMessage(userId, selectedPrize); return DrawResult.success(selectedPrize); }关键点第2步Redis计数器与第5步MySQL库存扣减形成最终一致性保障。即使Redis计数器因网络问题未写入MySQL的WHERE stock 0仍能兜底反之若MySQL扣减成功但Redis计数器失败下次请求会因Redis计数超限被拦但中奖结果已落库业务上仍是成功的。4. 生产环境必须调整的5个参数从开发模式切换到高并发可用的关键配置4.1 数据库连接池HikariCP的maximumPoolSize与connection-timeout调优默认application.yml中Hikari配置过于保守spring: datasource: hikari: maximum-pool-size: 10 # 生产环境至少设为30 connection-timeout: 30000 # 30秒太长应设为5000 validation-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000生产建议值基于4核8G服务器参数开发值生产建议值依据maximum-pool-size1030每个抽奖事务平均耗时80ms30连接可支撑约375 QPS30*1000/80connection-timeout300005000避免慢SQL阻塞整个连接池5秒超时后快速失败降级validation-timeout30001000连接有效性检测需更快响应idle-timeout600000300000闲置连接5分钟回收减少MySQL端TIME_WAIT堆积注意max-lifetime必须小于MySQL的wait_timeout默认8小时否则连接被MySQL主动断开后Hikari无法感知导致Connection is closed异常。建议设为30分钟1800000msMySQL侧同步设置SET GLOBAL wait_timeout1800;。4.2 Redis限流策略从单机计数到分布式令牌桶的平滑升级当前lottery:count:{userId}:{date}是简单计数器适合中小流量。当QPS超2000时需升级为令牌桶算法// 替换原有increment逻辑使用Redis Lua脚本实现原子令牌桶 String luaScript local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) -- 每秒补充令牌数 local now tonumber(ARGV[3]) local lastTime tonumber(redis.call(HGET, key, lastTime) or 0) local tokens tonumber(redis.call(HGET, key, tokens) or tostring(capacity)) if now lastTime then local delta math.min(now - lastTime, capacity / rate) tokens math.min(capacity, tokens delta * rate) end if tokens 1 then redis.call(HSET, key, tokens, tokens - 1, lastTime, now) return 1 else redis.call(HSET, key, tokens, tokens, lastTime, now) return 0 end ; // 调用 Object result redisTemplate.execute( new DefaultRedisScript(luaScript, Long.class), Collections.singletonList(lottery:bucket: userId), capacity, rate, System.currentTimeMillis() / 1000 );参数说明capacity10桶容量rate3每秒补充3个令牌now为当前秒级时间戳。相比计数器令牌桶允许突发流量如活动开始瞬间10次请求同时平滑限制长期速率。4.3 奖品权重动态更新PrizeConfigController.updateWeight()的幂等性设计运营后台调用PUT /admin/prize/update-weight修改权重时必须保证幂等PutMapping(/update-weight) public Result updateWeight(RequestBody UpdateWeightRequest request) { // 1. 校验prizeCode存在且status1 Prize existing prizeMapper.selectByCode(request.getPrizeCode()); if (existing null || existing.getStatus() ! 1) { return Result.fail(奖品不存在或已禁用); } // 2. 使用MySQL行级锁更新权重 int updated prizeMapper.updateWeight( request.getPrizeCode(), request.getNewWeight(), existing.getVersion() // 乐观锁版本号 ); if (updated 0) { return Result.fail(更新失败请重试); // 版本号冲突 } // 3. 清除本地加权选择器缓存避免重启服务 weightedPrizeSelector.refreshCache(); return Result.success(); }prize_pool表需增加version字段INT DEFAULT 0UPDATE语句为UPDATE prize_pool SET weight ?, version version 1 WHERE prize_code ? AND version ?4.4 日志与监控ELK栈中user_draw_log表的慢查询优化user_draw_log表在高并发下易产生慢查询需添加复合索引-- 针对运营常用查询查某用户最近10条记录 ALTER TABLE user_draw_log ADD INDEX idx_user_time (user_id, draw_time DESC); -- 针对统计查询查某天各奖品中奖数 ALTER TABLE user_draw_log ADD INDEX idx_prize_time (prize_code, draw_time);同时在application.yml中开启MyBatis-Plus慢SQL日志mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: # 开启SQL执行时间监控100ms标红 log-impl: com.baomidou.mybatisplus.extension.plugins.MybatisPlusLoggingInterceptor4.5 容灾降级当Redis宕机时DrawService自动切换至纯MySQL模式在DrawServiceImpl中注入RedisTemplate的Qualifier(redisTemplate)并添加降级开关Service public class DrawServiceImpl implements DrawService { Autowired Qualifier(redisTemplate) private RedisTemplateString, Object redisTemplate; Value(${lottery.redis.enabled:true}) private boolean redisEnabled; // 从配置中心动态获取 Override public DrawResult draw(String userId) { if (!redisEnabled) { // 降级跳过Redis计数仅用MySQL库存校验 return drawWithoutRedis(userId); } // 正常流程... } }运维可通过配置中心将lottery.redis.enabledfalse服务自动剔除Redis依赖虽失去限流能力但核心抽奖功能仍可用。5. 验证抽奖结果一致性的终极技巧用MySQL Binlog解析器比对中奖日志与库存变动5.1 为什么日志表和库存表必须严格一致——审计合规的硬性要求金融级抽奖系统要求每一笔中奖记录必须有且仅有一条对应的库存扣减反之每一次库存扣减必须有且仅有一条中奖日志。若出现user_draw_log有记录但prize_pool.stock未减少如事务回滚未清理日志或prize_pool.stock减少了但user_draw_log无记录如写日志时网络中断均属严重资损事故。5.2 使用Canal监听Binlog实时校验两条数据链路部署Canal Server监听MySQL binlog# canal.properties中配置 canal.destinations lottery canal.instance.mysql.slaveId 1234 canal.instance.master.address 127.0.0.1:3306 canal.instance.dbUsername root canal.instance.dbPassword lottery123编写Canal客户端监听prize_pool和user_draw_log表变更// 监听prize_pool的UPDATE事件库存扣减 public void onPrizePoolUpdate(EventData data) { Long prizeId data.getAfterColumns().get(id).getValueAsLong(); Integer newStock data.getAfterColumns().get(stock).getValueAsInt(); String prizeCode data.getAfterColumns().get(prize_code).getValueAsString(); // 记录到内存MapprizeCode, {oldStock, newStock, timestamp} stockChangeMap.put(prizeCode, new StockChange(newStock, System.currentTimeMillis())); } // 监听user_draw_log的INSERT事件中奖记录 public void onDrawLogInsert(EventData data) { String prizeCode data.getAfterColumns().get(prize_code).getValueAsString(); String userId data.getAfterColumns().get(user_id).getValueAsString(); // 检查该prizeCode的库存变更是否已发生 StockChange change stockChangeMap.get(prizeCode); if (change null) { // 发现不一致立即告警 alertService.send(库存扣减缺失, prizeCode prizeCode , userId userId); } else if (change.getNewStock() 0) { // 库存已为负说明超发 alertService.send(库存超发, prizeCode prizeCode , currentStock change.getNewStock()); } }5.3 每日离线校验用SQL脚本生成一致性报告在凌晨低峰期执行以下SQL生成昨日数据一致性报告-- 统计昨日各奖品中奖次数从日志表 WITH log_count AS ( SELECT prize_code, COUNT(*) as log_cnt FROM user_draw_log WHERE DATE(draw_time) CURDATE() - INTERVAL 1 DAY AND prize_code IS NOT NULL GROUP BY prize_code ), -- 统计昨日各奖品库存变动从prize_pool历史快照需提前建history表 stock_change AS ( SELECT prize_code, SUM(change_amount) as stock_delta FROM prize_pool_history WHERE DATE(change_time) CURDATE() - INTERVAL 1 DAY GROUP BY prize_code ) SELECT COALESCE(l.prize_code, s.prize_code) as prize_code, COALESCE(l.log_cnt, 0) as log_count, COALESCE(s.stock_delta, 0) as stock_decrease, CASE WHEN COALESCE(l.log_cnt, 0) ABS(COALESCE(s.stock_delta, 0)) THEN 一致 ELSE 不一致 END as status FROM log_count l FULL JOIN stock_change s ON l.prize_code s.prize_code;将结果导出为CSV邮件发送给运营和风控团队。任何一行显示“不一致”必须立即暂停抽奖服务人工核查原因。最后提醒本项目中的DrawAlgorithm.java已预留addCustomAlgorithm()扩展点若需接入外部风控系统如调用腾讯云天御API校验设备指纹只需实现DrawAlgorithm接口并注册为Spring Bean无需修改核心流程。真正的工程能力不在于写出第一个可用版本而在于让第二个需求来临时你能在30分钟内完成适配。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →