构建千万级用户的高并发抽奖系统架构
1. 高并发抽奖系统的核心挑战想象一下双11零点秒杀场景数百万用户同时点击立即抽奖按钮系统要在毫秒级完成库存检查、概率计算、结果返回等一系列操作。这不是简单的技术堆砌而是一场对系统架构的极限考验。我曾在某电商平台负责春节红包活动的技术保障峰值QPS达到120万。当时踩过的坑让我深刻理解到高并发抽奖系统需要解决三个致命问题第一是库存超卖。当100个用户同时抽中最后一个iPhone时系统必须保证只有1人真正中奖。我们采用RedisLua脚本实现的原子计数器配合分布式锁双重保障误差率控制在0.001%以内。第二是概率失真。在流量洪峰下简单的随机算法会导致热门奖品集中被前1%的用户抽走。后来我们引入时间因子和用户权重确保活动全程的中奖分布符合预期。第三是雪崩效应。某次活动因为奖品查询接口没有缓存直接打垮数据库。现在我们的架构里Redis作为第一道防线本地缓存作为第二道最后才是数据库。2. 分布式锁的实战方案2.1 为什么需要分布式锁当用户A在华北节点检查库存时用户B在华南节点也看到了同一个库存值。如果没有锁机制两人都会认为自己抽中了最后一份奖品。这就是典型的并发写问题。我对比过三种主流方案Zookeeper强一致性最好但性能只能到3000TPSRedis单节点5万TPS但主从切换可能丢锁ETCD折中方案2万TPS左右最终选择Redisson实现的Redis锁关键在这段配置RLock lock redissonClient.getLock(lottery:activityId:prizeId); // 等待3秒持有10秒避免死锁 boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS);2.2 锁的粒度控制早期我们把整个活动ID作为锁key结果并发骤降到500TPS。后来拆分为活动ID奖品ID用户ID分段三级锁活动级锁控制总参与人数奖品级锁控制单品库存用户段锁将用户ID哈希分片避免热点实测显示这种分层锁设计能将并发能力提升8倍。比如某次活动锁竞争从每秒15万次降到2万次。3. 缓存策略的黄金组合3.1 多级缓存架构我们的缓存设计像洋葱一样分层本地缓存Caffeine存储用户最近抽奖记录命中率约40%Redis集群存储活动规则和实时库存响应时间2ms数据库缓存MySQL查询缓存配合读写分离特别重要的是库存预热。活动开始前1小时通过定时任务将数据加载到Redisdef preheat_inventory(activity_id): prizes get_prizes_from_db(activity_id) for prize in prizes: redis.set(fstock:{prize.id}, prize.quantity) redis.expire(fstock:{prize.id}, 86400) # 24小时过期3.2 缓存更新策略遇到过最棘手的缓存一致性问题某用户中奖后奖品已发但缓存未更新。现在的解决方案是先更新数据库再删除缓存不是更新通过消息队列异步重试对于特别敏感的数据比如剩余奖品数我们采用Redis的WATCHMULTI命令实现原子操作local stock tonumber(redis.call(GET, KEYS[1])) if stock 0 then redis.call(DECR, KEYS[1]) return 1 end return 04. 异步化设计的艺术4.1 消息队列选型对比过Kafka和RabbitMQ的实测数据Kafka吞吐量20万/s但延迟在50ms左右RabbitMQ吞吐量5万/s延迟可控制在5ms内最终选择RabbitMQ处理奖品发放关键配置spring: rabbitmq: listener: simple: prefetch: 50 # 每个消费者最多预取50条 concurrency: 10 # 10个并发线程4.2 事务消息方案中奖记录要保证100%不丢失我们实现了本地消息表将消息和业务数据放在同一个事务后台任务扫描未发送消息消息状态变更采用乐观锁Transactional public void saveRecordAndMessage(LotteryRecord record, Message message) { recordMapper.insert(record); message.setStatus(0); // 待发送 messageMapper.insert(message); }5. 数据库的生存之道5.1 分库分表策略抽奖记录表采用活动ID用户ID后两位分片。例如用户ID 123456 参加活动 888分片键计算888_56 → 路由到第56个分片配合MyCat中间件单表数据量始终控制在2000万以内。5.2 索引优化实战通过EXPLAIN分析发现联合索引的顺序对性能影响巨大。最优方案是ALTER TABLE lottery_record ADD INDEX idx_activity_user (activity_id, user_id, create_time);某次优化后查询速度从1200ms降到28ms。关键是要让索引覆盖WHERE和ORDER BY子句。6. 限流与熔断机制6.1 分布式限流采用令牌桶算法通过Redis实现集群限流public boolean tryAcquire(String key, int permits, int rate) { String script local current redis.call(get, KEYS[1])\n if current and tonumber(current) tonumber(ARGV[1]) then\n return 0\n else\n redis.call(incrby, KEYS[1], 1)\n redis.call(expire, KEYS[1], ARGV[2])\n return 1\n end; return redisTemplate.execute( new DefaultRedisScript(script, Boolean.class), Collections.singletonList(key), permits, rate); }6.2 降级策略当库存服务不可用时自动切换本地缓存模式读取最后一次同步的库存快照标记为估算模式每隔30秒尝试恢复连接7. 监控体系的搭建7.1 指标埋点通过Micrometer暴露关键指标lottery_requests_total总请求量lottery_duration_seconds耗时分布lottery_inventory实时库存Grafana看板配置示例SELECT rate(lottery_requests_total[1m]) as qps, histogram_quantile(0.95, sum(rate(lottery_duration_seconds_bucket[1m])) by (le)) as p95 FROM metrics WHERE activity_id8887.2 全链路追踪通过SkyWalking追踪一次抽奖请求API网关 → 抽奖服务 → 库存服务每个环节的耗时和状态异常请求的完整上下文某次故障排查中这个体系帮我们定位到是Redis连接池耗尽问题。8. 安全防护体系8.1 防刷策略基于用户行为的防御矩阵设备指纹识别请求频率分析短时密集请求拦截中奖模式检测如连续中高价值奖品def check_risk(user_id): if redis.get(fblock:{user_id}): raise RiskException(用户被封禁) count redis.incr(freq:{user_id}) if count 100: # 每分钟100次以上 redis.setex(fblock:{user_id}, 3600, 1) raise RiskException(请求过于频繁)8.2 数据加密中奖结果采用非对称加密前端生成RSA密钥对公钥传给服务端服务端用公钥加密中奖信息前端用私钥解密防止中间人篡改中奖结果。9. 容灾与演练9.1 混沌工程实践每月进行一次故障演练随机杀死一个Redis节点模拟数据库500错误网络延迟增加100ms通过这种主动制造故障的方式我们发现了多个单点问题。9.2 数据恢复方案采用全量备份binlog增量策略每天凌晨全量备份binlog实时上传到OSS可恢复到任意时间点实测恢复1TB数据用时18分钟RTO控制在30分钟内。10. 性能压测经验10.1 测试方案设计采用阶梯式加压初始1000用户每分钟增加20%持续到系统出现错误或响应超时然后保持峰值30分钟关键指标错误率0.1%P99延迟500ms资源利用率70%10.2 真实案例某次618活动前压测发现当并发达到8万时MySQL连接池耗尽原因是抽奖记录表缺少索引优化后支撑了15万并发压测报告要包含这些关键数据最大吞吐量资源瓶颈点系统临界值构建千万级并发抽奖系统就像打造一艘太空飞船每个部件都要在极端条件下可靠工作。经过多次大促的锤炼我们的系统现在可以做到在100万QPS压力下平均响应时间稳定在23ms库存准确率99.9999%。这背后是无数个深夜的调优和上百次失败的经验积累。