支撑10W用户同时抢的红包算法设计

作为技术负责人,我们曾面临 “支撑 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

技术团队的头像技术团队注册会员

相关推荐

关注我们
关注我们
购买服务
购买服务
返回顶部