业务简介:顾名思义,新人拼团是由新用户发起的拼团,如果拼团成功,系统会自动奖励新用户一张满 15.1 元减 15 的平台优惠券。
这相当于是无门槛优惠了。每个用户仅有一次机会。新人拼团活动的最大目的主要是为了拉新。
新用户判断标准:是否有支付成功的订单 ? 不是新用户 : 是新用户。
当前问题:由于像这种优惠力度较大的活动很容易被羊毛党、黑产盯上。因此,我们完善了订单风控系统,让黑产无处遁形!
然而由于需要同步调用风控系统,导致整个下单接口的的 QPS、TPS 的指标皆有下降,从性能的角度来看,【新人拼团下单接口】无法满足性能指标要求。因此 CTO 指名点姓让我带头冲锋……冲啊!
问题分析
风控系统的判断一般分为两种:在线同步分析和离线异步分析。在实际业务中,这两者都是必要的。
在线同步分析可以在下单入口处就拦截掉风险,而离线异步分析可以提供更加全面的风险判断基础数据和风险监控能力。
最近我们对在线同步这块的风控规则进行了加强和优化,导致整个新人拼团下单接口的执行链路更长,从而导致 TPS 和 QPS 这两个关键指标下降。
解决思路
要提升性能,最简单粗暴的方法是加服务器!然而,无脑加服务器无法展示出一个出色的程序员的能力。CTO 说了,要加服务器可以,买服务器的钱从我工资里面扣……
在测试环境中,我们简单的通过使用 StopWatch 来简单分析,伪代码如下:
@Transactional(rollbackFor = Exception.class)
public CollageOrderResponseVO colleageOrder(CollageOrderRequestVO request) {
StopWatch stopWatch = new StopWatch();
stopWatch.start("调用风控系统接口");
// 调用风控系统接口, http调用方式
stopWatch.stop();
stopWatch.start("获取拼团活动信息"); //
// 获取拼团活动基本信息. 查询缓存
stopWatch.stop();
stopWatch.start("获取用户基本信息");
// 获取用户基本信息。http调用用户服务
stopWatch.stop();
stopWatch.start("判断是否是新用户");
// 判断是否是新用户。 查询订单数据库
stopWatch.stop();
stopWatch.start("生成订单并入库");
// 生成订单并入库
stopWatch.stop();
// 打印task报告
stopWatch.prettyPrint();
// 发布订单创建成功事件并构建响应数据
return new CollageOrderResponseVO();
}
执行结果如下:
StopWatch '新人拼团订单StopWatch': running time = 1195896800 ns
---------------------------------------------
ns % Task name
---------------------------------------------
014385000 021% 调用风控系统接口
010481800 010% 获取拼团活动信息
013989200 015% 获取用户基本信息
028314600 030% 判断是否是新用户
028726200 024% 生成订单并入库
在测试环境整个接口的执行时间在 1.2s 左右。其中最耗时的步骤是【判断是否是新用户】逻辑。
这是我们重点优化的地方(实际上,也只能针对这点进行优化,因为其他步骤逻辑基本上无优化空间了)。
确定方案
在这个接口中,【判断是否是新用户】的标准是是用户是否有支付成功的订单。因此开发人员想当然的根据用户 ID 去订单数据库中查询。
我们的订单主库的配置如下:
这配置还算豪华吧。然而随着业务的积累,订单主库的数据早就突破了千万级别了,虽然会定时迁移数据,然而订单量突破千万大关的周期越来越短……(分库分表方案是时候提上议程了,此次场景暂不讨论分库分表的内容)而用户 ID 虽然是索引,但毕竟不是唯一索引。因此查询效率相比于其他逻辑要更耗时。
通过简单分析可以知道,其实只需要知道这个用户是否有支付成功的订单,至于支付成功了几单我们并不关心。
因此此场景显然适合使用 Redis 的 BitMap 数据结构来解决。在支付成功方法的逻辑中,我们简单加一行代码来设置 BitMap:
// 说明:key表示用户是否存在支付成功的订单标记
// userId是long类型
String key = "order:f:paysucc";
redisTemplate.opsForValue().setBit(key, userId, true);
通过这一番改造,在下单时【判断是否是新用户】的核心代码就不需要查库了,而是改为:Boolean paySuccFlag = redisTemplate.opsForValue().getBit(key, userId);
if (paySuccFlag != null && paySuccFlag) {
// 不是新用户,业务异常
}
修改之后,在测试环境的测试结果如下:StopWatch '新人拼团订单StopWatch': running time = 82207200 ns
---------------------------------------------
ns % Task name
---------------------------------------------
014113100 017% 调用风控系统接口
010193800 012% 获取拼团活动信息
013965900 017% 获取用户基本信息
014532800 018% 判断是否是新用户
029401600 036% 生成订单并入库
测试环境下单时间变成了 0.82s,主要性能损耗在生成订单入库步骤,这里涉及到事务和数据库插入数据,因此是合理的。接口响应时长缩短了 31%!相比生产环境的性能效果更明显……接着舞!
晴天霹雳
这次的优化效果十分明显,想着 CTO 该给我加点绩效了吧,不然我工资要被扣完了呀~
一边这样想着,一边准备生产环境灰度发布。发完版之后,准备来个葛优躺好好休息一下,等着测试妹子验证完就下班走人。
然而在我躺下不到 1 分钟的时间,测试妹子过来紧张的跟我说:“接口报错了,你快看看!”What?
当我打开日志一看,立马傻眼了。报错日志如下:
io.lettuce.core.RedisCommandExecutionException: ERR bit offset is not an integer or out of range
at io.lettuce.core.ExceptionFactory.createExecutionException(ExceptionFactory.java:135) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.ExceptionFactory.createExecutionException(ExceptionFactory.java:108) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.AsyncCommand.completeResult(AsyncCommand.java:120) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.AsyncCommand.complete(AsyncCommand.java:111) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.CommandHandler.complete(CommandHandler.java:654) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at io.lettuce.core.protocol.CommandHandler.decode(CommandHandler.java:614) ~[lettuce-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
…………
bit offset is not an integer or out of range。这个错误提示已经很明显:我们的 offset 参数 out of range。
为什么会这样呢?我不禁开始思索起来:Redis BitMap 的底层数据结构实际上是 String 类型,Redis 对于 String 类型有最大值限制不得超过 512M,即 2^32 次方 byte…………我靠!!!https://mp.weixin.qq.com/s/xn7Tjj37ikwq1_W3JG65wQ