怎么抢红包才能手气最佳

439 阅读4分钟

PK创意闹新春,我正在参加「春节创意投稿大赛」,详情请看:春节创意投稿大赛

参加这个活动,既然是闹新春,第一个想到的就是红包。恰好在之前的工作中,也在我们的社交APP中主导过红包功能的服务端设计和编码,趁着这个活动总结一下。

既然是做红包的功能,第一个想到的一定是微信红包,应该绝大多数国内社交APP增加红包功能都是在微信红包之后。当时我们要做这个功能的原因确实也是见识到微信红包功能在春节的爆发,所以想要快速跟进。有这么一个成功的案例,自然而然会想到想搞清楚微信红包的设计,我们照葫芦画瓢好了。不过开始做这个功能的时候,为了不让自己陷入思维定式,我们还是自己先凭直觉做一些大概的设计,遇到难点了,再去查询资料寻找解法,或者直接参考微信红包的方法。

回到业务场景的描述:用户A在群里发红包,可以指定总金额amount和总人数num;然后群里其他用户可以抢红包,最多让前num个可以抢到,后面的人再点开要提示“来晚一步”之类的;需要保证num个用户每个人至少抢到0.01,且num个人金额的和跟amount是严格相等的;红包24小时有效,超过时间未抢完的部分需要退回。

根据上面的业务场景,我们主要拆解了2个技术问题:

  • 红包金额分配算法
  • 并发场景下保证最多只有前num个人可以抢到

红包金额分配算法

完全平分

不用过多介绍,直接总金额/总人数,精确到分位,除不尽的向下取整

完全随机

因为总金额是确定的,所以完全随机实际上是计算每个人抢到的红包的比例。这里给出一种我们找到的一种理解和实现比较方便的方法:对于num个人,每人都生成一个0-1000的随机数,记为r[0]~r[num],sum=n=0numrn\sum_{n=0}^{num}{r_n},那么这num人分配到的比例为r[0]/sum ~ r[num]/sum。顺便提一下,因为java中的随机是假随机,所以可以使用红包实体的id作为随机种子,保证每个红包的随机种子都是不一样的。
java版本的参考代码:

private static BigDecimal[] distribute(Long luckyMoneyId, BigDecimal amount, int num) {
    BigDecimal[] vals = new BigDecimal[num];
    Random random = new Random(luckyMoneyId);
    int[] r = new int[num];
    int sum = 0;
    for (int i = 0; i < num; i++) {
        r[i] = random.nextInt(1000);
        sum += r[i];
    }
    BigDecimal sumDecimal = new BigDecimal(sum);
    BigDecimal temp = BigDecimal.ZERO;
    for (int i = 0; i < num; i++) {
        if (i < num - 1) {
            vals[i] = amount.multiply(new BigDecimal(r[i]).divide(sumDecimal, 4, RoundingMode.FLOOR)).setScale(2,
                    RoundingMode.FLOOR);
            temp = temp.add(vals[i]);
        } else {
            vals[i] = amount.subtract(temp);
        }
    }
    return vals;
}
相同数学期望的随机

相对于完全随机算法结果随机出来的结果可能差距很大,相同数学期望的随机在结果波动性上会小很多。那如何保证每个人的抢到金额的数学期望值相同呢?我们可以反过来考虑,如果每个人抢到的红包的数学期望相同,那么当前的人抢到的金额就不能从当前剩余总金额去随机,而要用当前平均金额的2倍去随机。
从正面描述就是,当前红包剩余金额为amount,剩余数量为num,那么当前人抢到的红包范围应该是(0,amount/num*2)。
java版本的参考代码:

private static BigDecimal[] distribute(Long luckyMoneyId, BigDecimal amount, int num) {
    BigDecimal[] vals = new BigDecimal[num];
    Random random = new Random(luckyMoneyId);
    BigDecimal amountLeft = amount;
    int numLeft = num;
    for (int i = 0; i < num; i++) {
        if (i < num - 1) {
            vals[i] = distribute(random, amountLeft, numLeft);
            amountLeft = amount.subtract(vals[i]);
            numLeft--;
        } else {
            vals[i] = amountLeft;
        }
    }
    return vals;
}

private static BigDecimal distribute(Random r, BigDecimal amount, int num) {
    return amount.divide(new BigDecimal(num), 4, RoundingMode.FLOOR).multiply(new BigDecimal(2)).multiply(new BigDecimal(r.nextDouble())).setScale(2, RoundingMode.FLOOR);
}
其他

当然,有些问题对于上面集中算法是通用的,解决方案也可以通用,就没有在上面的算法说明里面一一体现出来:

  • 问题:怎么保证至少0.01 解法:如果随机分配的算法不能指定至少0.01,我们可以先给每个人先预分配0.01,那么问题转化成amount-0.01*num分给num个人的问题
  • 问题:怎么保证num个人金额的和跟amount是严格相等 解法:最后一个人不使用任何规则,直接使用剩余的总金额

到底选择那种分配的算法,我们还是从业务场景出发,我们先思考红包金额分配算法需要满足的需求:

  • 有一定的随机性
  • 不要有明显的规律

需求1很好理解,完全平分的话,抢红包的乐趣少了很多,所以必须有一定的随机性。需求2,无论是先抢的抢的多还是后面的抢的多,都会让人挑选时段去抢,而不是尽量快速去抢,业务肯定不希望有这样的规律。所以综合来看,我们选用了相同数学期望的随机。后来结合知乎的一些问答和其他网站的一些信息,这个算法应该比较接近微信红包算法的效果。

并发场景下保证最多只有前num个人可以抢到

这个问题,本质跟电商场景下超卖问题是一样的。所以,最先想到的解法,自然是参考了电商有限库存下防止超卖的解决方案,使用redis做分布式锁。网上有很多这方面的总结,我就不重复了。
我们当时想了一些另外的方法,解决问题的算法有多个,有时候为了效率,我们用空间换时间,有时候因为严格限制空间,我们用时间去换空间。如果用redis的锁方案,我们可以在抢红包的时候先尝试获取锁,获取成功再去用上面的相同数学期望的随机去计算金额。我们换了一种思路,在发红包的当时就计算好了每个顺位抢到的金额,用list存储到redis里面,抢红包的时候,执行redis的lpop命令获取当前线程抢到红包的金额,因为命令是原子的,所以只可能前num个会执行成功。 image.png

这样做的缺点是,实现存储好的金额list可能会比较大,不过考虑到红包的时效是24小时,过期就可以清理,而且业务初期1天发出的红包也不会太多,我们用1W个红包,每个红包10个人可以抢到测算,redis内存消耗也不超过百M,评估下来系统完全可以接受。