砍价业务总结
前言
产品经理: 有个新需求,要做一个类似拼多多砍价的小程序
我: 排几天工期啊?
产品经理:一个星期天够了吧?
我:(jack这么高傲的技术愤青) NoNoNo,3天就行了
具体业务背景:
第三方某银行为了推广自己的商城APP拉新用户,计划推出一个砍价小程序。
APP老用户可以发起不同优惠券面值砍价任务拉新用户进行助力,新用户助力后可以获取APP固定面值优惠券一张
老用户砍价成功后可以获取一张相应任务的面值优惠券。
活动期间一个老用户只能发起一个砍价任务
业务需要支持任务数量限制、奖品库存限制、用户领取限制、任务有效期、优惠券领取失败可以再次发起领取 ..........
需求分析
砍价金额算法
既然是一个砍价业务,必然涉及到一个场景:这个用户应该砍多少钱呢?由于砍价的总金额是确定的,所以每个用户砍多少钱可以以剩余总金额为区 间使用随机数来确定。那这个剩余总金额如何获得呢?难不成每个砍价请求寄来都要查表把砍过的金额重新计算一遍计算得出剩余金额?这种方式肯定是不行的:首先是性能较差,其次最后一个用户的砍价金额等于剩余金额,如果查库计算剩余金额容易出现并发问题。所以这种方案肯定是行不通的。
jack陷入深思,喝了桌子上昨天加班买的可乐冷静片刻便开始在网上查找拼多多砍价架构的设计,翻了半天也没发现有人讲明白的。Jack开始慌张,合上电脑冲了杯咖啡,准备挑灯夜战。
砍价金额逻辑无外乎需要解决并发、性能问题。仔细分析后设计出了新的逻辑:当用户发起砍价任务时,请求是可以确定砍价总金额以及完成砍价任务的总人数(笔者项目的砍价金额对应的砍价人数是支持配置的),所以当用户发起砍价任务时根据砍价总金额和砍价总人数直接生成好每个人的随机金额放入Redis中,相当于一个萝卜一个坑。当用户来砍价时直接从Redis中的队列中取随机金额即可。有效解决了并发、性能问题(对于笔者目前的项目)。
想法确定后开始实现砍价算法。感觉和拼多多的砍价比还差点意思。jack依稀记得拼多多发起砍价任务的人砍的金额都特别多,基本都是第一刀砍总金额的90%左右。“嗯,这个必须得有,好让用户有欲望去拉好友去砍价”。10分钟后jack手撸了第一版随机砍价金额算法,然后开始测试。测试很多测试用例后发现:第一个人的砍价比率太高的话,剩下金额不够分给剩下的用户,幸亏jack心细测试的比较全面,避免了生产故障。经过仔细分析撸出了最终的砍价算法:
import cn.common.exceptions.BusinessException;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* <p>
* 随机生成砍价金额工具类. 算法逻辑如下:
* 1. 第一个人砍价支持砍价比率,其他人则根据配置的最小砍价金额与剩余总金额进行随机数生成
* (每次生成一个随机金额后,首要重新计算剩余金额,公式为 : 剩余金额 = 剩余金额 - (剩余人数 * 每人最小砍价金额),保证预留每个人最少的砍价金额).
* 2.生成完所有随机数后进行总和校验
* 3.除第一个人金额外,将其他随机金额随机打乱。目的是: 随着剩余金额越来越小,随机数区间也越来越小。这就导致靠后的随机金额比较均匀不够随机,所以将顺序打乱
* 注意 : 当第一个人的砍价金额比率太大时,其他人的随机区间会很小,所以会导致其他人的随机金额趋于平缓,后期会再出一版算法优化
* 该问题。目前该算法以经过百万人次使用。
* </p>
*
* @author chengxiaonan (jackcheng1117@163.com)
*/
@Slf4j
public class RandomBargainAmountUtil {
/**
* 单位 :分
*/
private static final int CENT_MONEY_UNIT = 100;
/**
*
* @param money 砍价任务总金额
* @param count 砍价任务总人数
* @param firstPersonCutRateMin 第一个砍价占总金额最小比率
* @param firstPersonCutRateMax 第一个砍价占总金额最大比率
* @param minCutPrice 除第一人外,其他人最小砍价金额 (单位:分)
* @return
*/
public static List<String> random(final int money, final int count, final double firstPersonCutRateMin,
final double firstPersonCutRateMax, final int minCutPrice) {
log.info("开始生成随机砍价金额 money:{};count:{};firstPersonCutRateMin:{};firstPersonCutRateMax:{};minCutPrice:{}",
money, count, firstPersonCutRateMin, firstPersonCutRateMax, minCutPrice);
if (money <= 0 || minCutPrice <= 0 || count <= 0) {
log.error("随机金额算法参数校验失败: money:{};minCutPrice:{};count:{}", money, minCutPrice, count);
throw new BusinessException("", "随机金额算法参数校验失败");
}
//保存随机金额
List<String> list = new ArrayList<>();
//总金额,单位 : 元
int totalMoney = money;
//将单位: 元 转为 : 分
int totalMoneyWithFen = money * CENT_MONEY_UNIT;
try {
//计算第一个人的最小、最大金额的区间
int minPrice = (int) (totalMoneyWithFen * firstPersonCutRateMin);
int maxPrice = (int) (totalMoneyWithFen * firstPersonCutRateMax);
//校验随机数量
if ((count - 1) > ((totalMoneyWithFen - maxPrice) / minCutPrice)) {
log.error("随机金额算法总金额不足以生成指定随机金额数量 money:{};firstPersonCutRateMin:{};" +
"firstPersonCutRateMax:{};minCutPrice:{};count:{}", money, firstPersonCutRateMin, firstPersonCutRateMax, minCutPrice, count);
throw new BusinessException("", "随机金额算法总金额不足以生成指定随机金额数量");
}
//随机生成第一个人的随机金额
int remainMoney = randomPrice(totalMoneyWithFen, minPrice, maxPrice, list);
//生成其他人随机金额
execute(list, remainMoney, count, minCutPrice);
//校验 : 计算生成的总金额是否等于砍价总金额
if (list.size() != count) {
log.error("随机生成砍价金额数量与设置不符 list:{};count:{}", list, count);
throw new BusinessException("", "随机生成砍价金额数量与设置不符");
}
BigDecimal sum = BigDecimal.ZERO;
for (String item : list) {
sum = sum.add(new BigDecimal(item));
}
if (sum.compareTo(new BigDecimal(String.valueOf(totalMoney))) != 0) {
log.error("随机生成砍价金额与总价不符; list:{};money:{};count:{};minCutPrice:{}", list, money, count, minCutPrice);
throw new BusinessException("", "随机生成砍价金额与总价不符");
}
//除第一个的随机金额外,将其他人的随机打乱顺序
String firstPrice = list.remove(0);
Collections.shuffle(list);
list.add(0, firstPrice);
return list;
} catch (Exception e) {
log.error("随机砍价金额生成异常 msg:{}", e.getMessage(), e);
return null;
}
}
/**
* 随机生成砍价金额
* @param list 用于保存生成的砍价金额
* @param money 砍价总金额 (单位 : 分)
* @param count 随机金额数量
* @param minCutPrice 最小砍价金额
*/
private static void execute(List<String> list, int money, int count, int minCutPrice) {
try {
while (list.size() < count) {
if (money <= 0) {
break;
}
//生成随机金额
money = apply(list, money, count, minCutPrice);
if (list.size() == count) {
break;
}
}
} catch (Exception e) {
log.error("生成随机砍价金额[RandomBargainAmountUtil#execute]异常 list:{};money:{};count:{};minCutPrice:{}",
list, money, count, minCutPrice, e);
return;
}
}
private static int apply(List<String> list, int money, int count, int minCutPrice) {
//最后一个人补齐砍价总金额
if (list.size() == count - 1) {
list.add(String.valueOf((double) money / CENT_MONEY_UNIT));
return 0;
}
//剩余金额 = 剩余金额 - (剩余人数 * 每人最小砍价金额),保证预留每个人最少的砍价金额
int remainNum = count - list.size();
int minRemainPrice = minCutPrice * remainNum;
//校验剩余金额是否满足剩余人数可以获得最小砍价金额
if (money < minRemainPrice) {
log.error("生成随机砍价金额[RandomBargainAmountUtil#execute]异常:剩余金额不满足剩余人数最小金额 " +
"list:{};money:{};count:{};minCutPrice:{}", list, money, count, minCutPrice);
throw new BusinessException("", "生成随机砍价金额[RandomBargainAmountUtil#execute]异常:剩余金额不满足剩余人数最小金额");
}
//校验剩余金额是否等于最小砍价金额,如果是则直接取最小砍价金额
if (money == minRemainPrice) {
money -= minCutPrice;
list.add(String.valueOf((double) minCutPrice / CENT_MONEY_UNIT));
}
//校验剩余金额是否大于最小砍价金额,如果是则随机金额
if (money > minRemainPrice) {
int maxPrice = money - (minCutPrice * remainNum);
money = randomPrice(money, minCutPrice, maxPrice, list);
}
return money;
}
//生成随机数
private static int randomPrice(int money, int minPrice, int maxPrice, List<String> list) {
int price = ThreadLocalRandom.current().nextInt(minPrice, maxPrice);
money -= price;
list.add(String.valueOf((double) price / CENT_MONEY_UNIT));
return money;
}
}
目前该算法已经过百万人次使用,正确性是没有问题的。但是该算法有一个缺陷:由于支持配置第一人砍价比率,所以当第一个人比率较大时,其他人的砍价金额随机区间会越来越小。极端情况下会出现后面的人的砍价金额区域平缓都是最小金额并且都是一样的。后期有机会会优化一下该算法:使随机金额具备随机曲线
活动流程设计
设计好砍价算法之后下面就要开始设计活动流程了,通过和第三方的讨论得知他们的用户量很大,而且通过之前为第三方做过的活动的统计数据来看,活动的并发量还是蛮高的。再说了,即使用户量不大,不是还有那么多羊毛党来薅羊毛吗?这可让jack犯了愁,出去抽了支烟,打开画图软件就是一顿猛操作:
算法和设计搞好之后接下来jack就该加班撸代码了。最终效果如下: