业务流程

以微信红包为例,分为如下流程:

  1. 发红包(包红包);
  2. 抢红包;
  3. 拆红包(开红包)。

发红包

设置金额和份数,完成支付,红包详情入库,为红包生成一个唯一ID。

红包拆分

拆分时机

红包金额拆分分为实时拆分提前拆分,实时拆分对系统性能和拆分算法要求较高,在拆分时既要保证线程安全,又要保证并发效率,开发难度较大。而且拆分过程中要一直保证后续待拆分红包的金额不能为0,不容易做到拆分的红包金额服从正态分布规律。

大型红包系统一般选择提前拆分,从而降低系统复杂度。

拆分算法

随机数

我们假设有一个100元的红包。第一个人可以在0.01元到100元之间,随机地分配到一定金额。如果我们把第一个人抽到的所有可能的金额都计算在内,并取个平均值,那么他平均能获得50元。这50元在数学上还有个形象的名字,叫作数学期望。既然是“期望”,就总会有落空的时候,也不排除会有意外的惊喜。因此,第一个人抽到的金额可能不足50元,也可能大于50元。

第二个人就只能在0.01到剩下的金额之间随机了,显然,第二个人平均能获得的金额比第一个人小,而且越到后面的人,这个平均金额越小。

也就是说,越在前面抢的人越有优势,因此,这个算法非常不公平。

二倍均值法

二倍均值法拆分的金额比较平均,属于“雨露均沾”类型。

假设红包总金额是a,红包个数为p,每个红包的最低金额是0.01元。
那么每次抢到的红包金额的范围在 [0.01, (a / p) * 2] 之间。

算法代码如下:

public class DoubleAVG {

    /**
     * 拆分金额
     * @param amount 总金额,单位:元
     * @param part 份数
     * @return
     */
    public static float[] compute(int amount, int part) {
        // 红包最低金额0.01元,乘以100以后就可以全部用整型计算,不用担心精度丢失的问题
        int amt = amount * 100;
        float[] amounts = new float[part];
        Random random = new Random();
        int maxIndex = part - 1;
        for (int i = 0; i < maxIndex; i++) {
            // 随机出来的值的范围[0, 金额 / 份数 * 2), 所以需要加1
            int packetAmount = random.nextInt(amt / part << 1) + 1;
            amounts[i] = packetAmount / 100f; // 转换为元
            amt -= packetAmount; // 剩余金额
            --part; // 剩余份数
        }
        // 最后一个红包直接赋予剩余金额
        amounts[maxIndex] = amt / 100f;

        return amounts;
    }

    public static void main(String[] args) {
        testCase();
    }

    public static void testCase() {
        int amount = 100;
        int part = 20;
        float[] amounts = compute(amount, part);
        for (float amt : amounts)
            System.out.println(amt);
    }
    
}

抢红包

抢红包是整个流程中并发最高的阶段,对系统的性能要求最高。在此阶段,至少要做如下业务校验:

  • 红包还有没有剩余,有剩余才能抢;
  • 已经抢过的人不能重复抢。

不能重复抢可以通过客户端交互界面限制,但是碰到直接调接口的“高端玩家”就没办法了,所以还是需要后端校验。这些请求如果全部放到数据库,可能会把数据库搞宕机,所以在这里我们需要限流。

限流

分布式锁

只有抢到锁才能抢红包,说白了就是让请求串行,但是遇到高并发,会导致大量请求排队,可能会耗尽线程资源,可以设置抢锁的等待超时时间和分段锁机制来缓解这种状况。抢到锁的请求,在通过业务校验之后,需要修改数据库里的数据(或是插入一条数据,或是修改一个标识位),表示抢到了红包。

Redis限流

设置了超时时间的分布式锁在高并发场景下会导致大量请求超时,用户体验非常不友好。相比之下,redis限流更适合这种场景。

在发红包阶段,以红包ID生成count_key(比如固定前缀 + 红包ID),红包份数作为value保存到redis,抢红包时,执行lua脚本(保证原子性),lua脚本的逻辑如下:

  1. 先判断count_key存不存在,不存在则说明红包不存在或已抢完,直接返回没抢到;
  2. 若count_key存在,接着判断份数是否等于零,若等于零,说明红包已经抢完,直接返回没抢到;
  3. 若份数大于零,接着判断该用户是否已经抢过红包,可以通过redis的set数据结构来判断,同样固定前缀+红包id作为key,记为repeat_key,用户id作为value保存到set。若用户已经抢过红包,则直接返回;
  4. 若用户还没抢过红包,则将红包份数扣减一,同时将用户id保存到set,返回抢红包成功的响应给客户端。

redis的数据全部保存在内存中,查询效率非常高,所以能支撑大量的并发,为了应对高并发,redis还可以部署集群。

拆红包

拆红包阶段要做的事情大概如下:

  1. 修改红包状态(比如标记为已抢);
  2. 打款;
  3. 记录红包流水。

这个阶段要严格保证数据准确性和完整性,太高的并发会增加系统出故障的几率。

好在,我们在抢红包阶段已经过滤了大部分无效请求,能到这个阶段的都是已经抢到了红包的。在调拆红包接口时,我们需要通过上个阶段提到的set来判断用户是否有资格拆这个红包,这一步也能过滤掉非法请求。

如何分配金额

消息队列

发红包阶段,新建一个消息队列,队列名称和红包id对应,将拆分的金额存入到消息队列,拆红包时按照先来后到的顺序从队列poll一个金额。这种方案有些异常场景需要考虑,比如poll到金额后还没来得及处理服务就挂了,导致金额丢失,通过消费成功后ack能解决这个问题。

哈希算法

发红包阶段,新建一张临时表,表名和红包id对应,将拆分的金额存入到表中,每个金额都有一个自增id(从1开始),假如拆分出了100个金额,那么id就是1到100,然后hash(用户id)来获取金额id从而决定金额,最简单的就是用户id % 份数。这种方式简单可靠。

并发修改的效率问题

尽量不要在每个拆红包的请求中都去修改红包记录的状态(比如扣减金额),因为红包记录只有一条,只能串行的去执行,当拆分出大量红包时(比如春晚红包,几百万个),会严重影响拆红包的效率。我们可以选择去修改金额表的状态,每一个金额记录都是单独的行,不会受数据库行锁限制而串行。

异步处理

异步处理加快响应速度,不用等所有业务逻辑都完成了再返回,可以将比较耗时的任务放进消息队列异步执行,只要将任务成功的放进了可靠的消息队列就可以响应成功了。比如记录红包流水等可以异步的执行。

其他引申问题

  • 红包id生成引出分布式系统生成唯一id的方式;
  • redis set引出redis数据结构
  • redis集群和主备
  • redis数据持久化
  • 数据库分库分表
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐