作为技术负责人,我们曾面临 “支撑 10W 用户同时抢红包” 的核心诉求 —— 既要保证金额分配公平、不超发不重复领,又要让响应速度快到毫秒级,不出现卡顿或系统雪崩。
经过多轮技术选型与压测验证,我们最终落地了一套基于 “二倍均值法 + Redis+Lua” 的高并发红包方案,成功抗住峰值压力。
以下是完整的设计思路、落地细节及避坑指南:
一、需求拆解
在动手开发前,我们先明确了业务核心需求与潜在技术风险,避免盲目选型:
1. 必须满足的核心诉求
- 公平性:每人至少领到 0.01 元,无空包、无 “一人独吞” 的极端情况,金额随机且均衡;
- 一致性:总发放金额严格等于设定值,绝不超发,同一用户不能重复领取;
- 高性能:10W 并发请求下,响应时间控制在毫秒级,成功率≥99.9%,无系统过载。
2. 最棘手的技术挑战
- 并发冲突:高并发下 “多人抢同一红包”,如何避免 “查库存→算金额→扣库存” 中途被打断,导致超发或重复领?
- 性能瓶颈:传统数据库(如 MySQL)读写速度慢,根本扛不住 10W 级并发请求,如何转移压力?
- 精度问题:金额用浮点数计算易出现误差(如 0.01 元重复累加后变成 0.029999999 元),如何规避?
二、核心算法选型
经过对比 “预分配随机法”“线性拆分法” 等方案,我们最终选择 “二倍均值法”—— 它不仅计算逻辑简单、无性能损耗,还能完美平衡公平性与随机性,也是微信红包的核心算法。
1. 算法原理
每次用户抢红包时,动态计算可领取金额范围:[0.01元, 剩余金额/剩余人数×2],最后一位用户直接拿走所有剩余金额。
举个例子:100 元分给 10 人
- 第 1 人可领范围是
[0.01, 20],期望值 10 元(和平均值一致); - 若第 1 人领了 15 元,剩余 85 元分给 9 人,第 2 人范围变成
[0.01, 18.89],依然保证期望值均衡。
2. 为什么选它?(团队选型决策依据)
- 公平性:每个人领取金额的期望值都是 “总金额 / 总人数”,不会出现极端差距;
- 高效性:仅需基础算术运算,无循环、无复杂逻辑,支持实时计算(不用提前算好所有金额);
- 随机性:金额范围随剩余人数动态调整,既有趣味性,又不会离谱(比如 10 人抢 100 元,没人能一次领走 50 元以上)。
3. 核心代码实现(Java,规避浮点数精度坑)
/**
* 二倍均值法金额分配(单位:分,避免浮点数精度问题)
* @param remainAmount 剩余金额(分)
* @param remainCount 剩余领取人数
* @return 本次领取金额(分)
*/
private int calculateAmount(int remainAmount, int remainCount) {
// 最后一人直接领取剩余全部
if (remainCount == 1) {
return remainAmount;
}
// 最大可领取金额 = 剩余金额/剩余人数 × 2(向下取整)
int maxAmount = remainAmount / remainCount * 2;
// 随机生成1~maxAmount之间的金额(保证至少1分)
return ThreadLocalRandom.current().nextInt(1, maxAmount + 1);
}
避坑提醒:我们特意将金额单位转为 “分” 存储计算,彻底解决浮点数精度问题(比如 0.01 元 = 1 分,所有运算都是整数,无误差)。
三、高并发架构设计
仅靠 Java 代码无法支撑 10W 并发 —— 传统方案中,“查剩余金额→算金额→扣库存” 是三步独立操作,高并发下极易出现冲突。我们的核心思路是:将并发压力转移到 Redis,用 Lua 脚本保证操作原子性。
1. 架构选型逻辑(一张表看懂为什么这么选)
| 技术组件 | 选型原因 | 核心作用 |
|---|---|---|
| Redis | 单线程模型(天然原子性)、内存读写(微秒级响应)、支持 Lua 脚本 | 承接高并发请求,存储红包核心数据(剩余金额、剩余人数),相当于 “高并发收银台” |
| Lua 脚本 | 能将多个 Redis 命令打包执行,中间不被打断 | 把 “查库存→算金额→扣库存→记领取状态” 做成 “一站式操作”,避免并发冲突 |
| StringRedisTemplate | 适配 Redis 哈希 / 集合类型操作,支持 Lua 脚本执行 | 对接 Redis,实现数据读写与脚本调用 |
2. 核心流程可视化
(1)发红包流程
用户发起发红包请求 → 生成唯一红包ID(UUID)→ 总金额转分存储 → 存入Redis哈希结构(剩余金额、剩余人数)→ 设置24小时过期时间 → 返回红包ID
(2)抢红包流程
用户发起抢红包请求 → Redis校验是否已领取(Set存储已领用户ID)→ 执行Lua脚本原子操作(查库存→算金额→扣库存→记状态)→ 领取成功返回金额/失败返回错误码 → 异步入库
3. 完整代码实现
(1)红包服务类(Java)
@Service
public class RedPacketService {
@Autowired
private StringRedisTemplate redisTemplate;
// 抢红包核心Lua脚本:保证原子性操作(一步完成所有关键逻辑)
private static final String GRAB_RED_PACKET_LUA = """
local redPacketKey = KEYS[1]
local grabbedUserKey = KEYS[2]
local userId = ARGV[1]
-- 1. 校验用户是否已领取(避免重复领)
if redis.call('sismember', grabbedUserKey, userId) == 1 then
return -2 -- 错误码:重复领取
end
-- 2. 校验红包是否还有剩余(避免超发)
local remainAmount = tonumber(redis.call('hget', redPacketKey, 'remainAmount'))
local remainCount = tonumber(redis.call('hget', redPacketKey, 'remainCount'))
if remainAmount <= 0 or remainCount <= 0 then
return -1 -- 错误码:红包已抢完
end
-- 3. 二倍均值法计算领取金额
local amount = 0
if remainCount == 1 then
amount = remainAmount -- 最后一人领完剩余金额
else
local maxAmount = math.floor(remainAmount / remainCount * 2)
amount = math.random(1, maxAmount)
end
-- 4. 扣减红包库存(剩余金额、剩余人数)
redis.call('hset', redPacketKey, 'remainAmount', remainAmount - amount)
redis.call('hset', redPacketKey, 'remainCount', remainCount - 1)
-- 5. 记录用户领取状态(存入Set,支持快速校验)
redis.call('sadd', grabbedUserKey, userId)
return amount
""";
/**
* 发红包
* @param totalAmount 总金额(元)
* @param totalCount 总人数
* @return 红包ID(用于抢红包)
*/
public String sendRedPacket(BigDecimal totalAmount, int totalCount) {
// 1. 金额转分,规避浮点数精度问题
int amountInCent = totalAmount.multiply(new BigDecimal("100")).intValue();
// 2. 生成唯一红包ID(去横杠,更简洁)
String packetId = UUID.randomUUID().toString().replace("-", "");
// 3. 定义Redis键(便于区分和管理)
String redPacketKey = "red_packet:" + packetId;
String grabbedUserKey = "red_packet:grabbed:" + packetId;
// 4. 存入Redis:剩余金额、剩余人数
Map<String, String> redPacketInfo = new HashMap<>();
redPacketInfo.put("remainAmount", String.valueOf(amountInCent));
redPacketInfo.put("remainCount", String.valueOf(totalCount));
redisTemplate.opsForHash().putAll(redPacketKey, redPacketInfo);
// 5. 设置过期时间:红包24小时有效,领取记录保留7天
redisTemplate.expire(redPacketKey, 24, TimeUnit.HOURS);
redisTemplate.expire(grabbedUserKey, 7, TimeUnit.DAYS);
return packetId;
}
/**
* 抢红包
* @param packetId 红包ID
* @param userId 用户ID(唯一标识,避免重复领)
* @return 领取金额(分),-1:红包已抢完,-2:重复领取
*/
public int grabRedPacket(String packetId, String userId) {
String redPacketKey = "red_packet:" + packetId;
String grabbedUserKey = "red_packet:grabbed:" + packetId;
// 执行Lua脚本(核心:原子操作,避免并发冲突)
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(GRAB_RED_PACKET_LUA);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Arrays.asList(redPacketKey, grabbedUserKey),
userId
);
return result != null ? result.intValue() : -1;
}
}
四、压测验证
为确保方案靠谱,我们用 JMeter 模拟真实场景压测,看看10W 并发下的真实表现:
1. 压测环境
- 服务器配置:8 核 16G,Redis 单机(6.2.6 版本);
- 压测参数:10W 并发用户,循环 1 次,抢同一个 1000 元、1000 人份的红包。
2. 压测结果(核心指标)
| 指标 | 结果 | 是否达标 |
|---|---|---|
| 平均响应时间 | 86ms | 是(目标≤100ms) |
| 95% 响应时间 | 152ms | 是(目标≤200ms) |
| 成功率 | 99.98% | 是(目标≥99.9%) |
| 异常情况 | 无超发、无重复领取、无空包 | 是 |
3. 压测避坑:我们遇到的 2 个问题及解决方案
- 坑点 1:Redis 单机连接数耗尽(报错 “too many connections”)解决方案:调整 Redis 连接池参数(maxTotal=2000,maxIdle=500,minIdle=100),避免连接泄露;
- 坑点 2:部分用户恶意刷请求(单 IP 每秒发起 50 + 次抢红包)解决方案:网关层添加限流策略(单 IP 每秒最多 10 次请求),配合 Redis Set 的去重机制,拦截恶意请求。
五、进阶优化
当前方案已满足 10W 并发,但为应对春节、双 11 等亿级流量场景,我们规划了 3 个优化方向:
1. Redis 集群扩容(突破单机瓶颈)
- 方案:采用 Redis Cluster 分片部署,按红包 ID 哈希分片(比如红包 ID 首字符为 0-9、a-f,对应 16 个分片),分散单机压力;
- 高可用:配置主从复制 + 哨兵模式,某台 Redis 宕机后,从节点自动切换为主节点,不影响服务。
2. 预分配模式适配(超大红包场景)
针对 “10 万元、1 万人份” 的超大红包,实时计算可能有轻微延迟,可切换为预分配模式:
// 预分配方案:发红包时提前算好所有金额,存入Redis List
public void preAllocateRedPacket(String packetId, int totalAmount, int totalCount) {
List<Integer> amounts = splitRedPacket(totalAmount, totalCount); // 二倍均值法预计算所有金额
String key = "red_packet:list:" + packetId;
// 存入Redis List,抢红包时直接LPOP获取
redisTemplate.opsForList().rightPushAll(key, amounts.stream().map(String::valueOf).toArray(String[]::new));
redisTemplate.expire(key, 24, TimeUnit.HOURS);
}
// 预分配模式抢红包
public int grabPreAllocatedRedPacket(String packetId, String userId) {
String key = "red_packet:list:" + packetId;
String grabbedUserKey = "red_packet:grabbed:" + packetId;
// 先校验是否已领,再弹出金额
if (redisTemplate.opsForSet().isMember(grabbedUserKey, userId)) {
return -2;
}
String amount = redisTemplate.opsForList().leftPop(key);
if (amount != null) {
redisTemplate.opsForSet().add(grabbedUserKey, userId);
return Integer.parseInt(amount);
}
return -1;
}
3. 异步入库 + 异步通知(提升响应速度)
- 异步入库:抢红包成功后,通过 RabbitMQ 将领取记录(红包 ID、用户 ID、金额)异步写入 MySQL,避免数据库读写阻塞主流程;
- 异步通知:通过短信 / APP 推送异步告知用户 “领取成功”,不用等通知发送完再返回结果,提升响应速度。
4. 监控告警(及时发现问题)
接入 Prometheus+Grafana,监控核心指标:
- 红包领取 QPS、响应时间、错误率;
- Redis 连接数、内存使用率、分片负载;
- 设置告警阈值(如响应时间 > 500ms、错误率 > 0.1%),通过钉钉 / 企业微信实时推送告警,快速排查问题。
六、核心是平衡
这套红包算法方案的落地,让我们深刻体会到:高并发系统的设计不是 “堆技术”,而是 “平衡”—— 平衡性能与一致性、平衡当前需求与未来扩展、平衡开发效率与系统稳定性。
核心经验分享
- 简单的算法往往更靠谱:二倍均值法没有复杂逻辑,却能完美解决公平性与高效性问题,避免过度设计;
- 把压力交给合适的组件:Redis 天生适合扛高并发,Lua 脚本解决原子性问题,不用硬扛数据库;
- 实战比理论重要:压测中遇到的连接数耗尽、恶意刷请求等问题,都是理论设计阶段没想到的,必须落地验证。
以上分享仅供参考,欢迎交流。
声明:来自技术团队,仅代表创作者观点。链接:http://eyangzhen.com/5103.html