ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java后端霸王餐返利计算通用算法与性能优化实战

Java后端霸王餐返利计算通用算法与性能优化实战 先说点题外话。我在外卖省钱类APP的后端团队待了三年多每天打交道最多的就是“霸王餐”和返利计算。所谓霸王餐说白了就是通过返利、补贴、免单等形式让用户以极低的成本甚至零成本吃上一顿外卖平台靠订单流水和用户留存赚钱。这个业务模式上线简单但真正难的是后端计算那部分返利比例多变、参与人层级复杂、订单并发量大、还有各种退款售后场景稍不留神就会算错账或者拖垮数据库。这篇文章我把自己在Java后端实现霸王餐返利计算的通用算法和优化思路完整梳理一遍里面有核心模型、关键代码片段、实测过的性能优化方向也有我踩过的一些坑。不管你是刚接触外卖返利业务的新人还是已经在做电商级返现系统的开发这篇文章应该能给你一些可以直接落地的参考。1. 需求拆解与算法设计思路1.1 霸王餐返利的业务模型先明确一下霸王餐返利的业务模型。整个链路里涉及的核心角色就三个用户、平台、商家。用户下单后平台按一定规则把订单金额的一部分作为返利发给用户或者发给该用户的邀请人。外卖省钱APP最常见的三种玩法自购返用户自己下单平台按活动比例返一笔钱到用户余额。邀请返A邀请B注册B下单成功后A获得一笔奖励这就是典型的拉新返利。多级分销返A邀请BB邀请CC下单后A和B都能拿到不同比例的分成。这类模式在外卖领域通常最多做两级一方面合规考虑另一方面返利层级太深也没意义。所以说到底霸王餐返利就是一个“根据订单结果按照预置规则计算出应该给哪个用户返多少钱”的分布式计算问题。这也决定了它天然适合用异步任务来处理用户订单完成的事件发生了并不需要立刻在主流程里同步算出返利而是可以丢到消息队列里后台慢慢算。做这个业务的第一原则就是不能算错账宁可晚算不能多算。钱的问题出一次用户信任就崩了。所以算法设计上我会特别重视幂等、状态机、对账这些方面。1.2 为什么需要通用算法而不是写死每个活动早期的版本运营每出一个新活动后端就要跟着改一遍返利计算代码。比如A活动是“满30返8”B活动是“新用户返50%但封顶20”C活动是“邀请回归用户返15”。每个活动的规则都不一样用if-else来判断活动类型代码会变得又臭又长而且上线一个新活动要重新走一遍发布流程效率太低。后来我们把返利规则彻底抽象成配置化。通用算法的核心思路就是把“返给谁”和“返多少”拆开返给谁由用户邀请关系和参与活动时的角色快照决定。返多少由订单金额、活动比例、封顶金额、用户等级系数共同决定。有了这种拆法新增一个活动就不再改Java代码只需要在后台配置规则表插入一条记录就行。这也是标题里说的“通用算法”的含义用一套计算引擎承载所有返利规则。这套思路在今天来看已经不算稀奇但在初期确实解决了团队的一个重要痛点。而且它对后面的性能优化帮助也很大因为计算逻辑收敛到了同一个引擎里做缓存和批处理都方便。1.3 算法选型图遍历、规则引擎还是查表确定要做一个通用算法之后就要选实现方式。我实际调研并对比过三种方案第一种是图遍历。把整个用户邀请关系看成一棵树从下单用户节点向上找祖先节点然后逐层累积返利。这个方案一听就很“算法”面试官也喜欢问但在实际业务里并不可取。大多数外卖返利最多两级用图遍历属于杀鸡用牛刀而且深度不确定还会带来递归栈溢出和查询性能问题。第二种是规则引擎。用Drools、Aviator这类框架把返利规则写成配置表达式后端只负责执行。优势是规则表达能力极强运营想怎么写就怎么写但缺点也很明显规则引擎本身就有一层性能损耗而且表达式写得太灵活反而让运维和排查问题的成本变高了。我个人的建议是只有返利规则需要频繁上线、且规则之间存在交叉叠加的业务才值得引入规则引擎大部分外卖返利场景用查表就够了。第三种是查表法也是我最终采用的方案。通过把账户关系表、活动配置表、订单表、返利流水表组织起来用有限的几次SQL查询和内存计算完成返利结果没有任何递归和动态规则。这套方案的优势是逻辑清晰、调试方便、性能容易预估。劣势是规则特别花哨的时候表达力稍微弱一点但运营可以在配置表里加字段解决。所以我的最终选择是在查表法的基础上保留一个可扩展的“计算插件点”遇到极其特殊的活动再单独写实现类走策略模式挂载进去。2. 核心数据模型与表结构设计2.1 返利计算最核心的几条数据链路返利计算离不开一套完整的数据模型支撑。我从服务器端角度给你梳理一下核心的表结构。用户账户表。它记录每个用户的余额信息。这个表必须和用户主表分离原因很简单用户主表的写频率和账户表的写频率完全不同账户表每笔返利都要更新如果耦合在一起大促期间很容易变成热点行更新。账户表字段大概是这样CREATE TABLE user_account ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 用户ID, balance decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 可用余额, total_income decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 累计返利收入, total_expense decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 累计提现消费, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;返利订单流水表。这里每个参与返利的订单都会生成一条流水记录同时记录了返利状态。流水表是整个返利系统的对账依据没有流水的返利都是耍流氓。CREATE TABLE rebate_order_record ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id varchar(64) NOT NULL COMMENT 平台订单号, user_id bigint(20) NOT NULL COMMENT 获得返利的用户ID, source_user_id bigint(20) NOT NULL COMMENT 下单用户ID, activity_id bigint(20) NOT NULL COMMENT 活动ID, rebate_type tinyint(4) NOT NULL COMMENT 返利类型1自购返2邀请返, order_amount decimal(10,2) NOT NULL COMMENT 订单实付金额, rebate_amount decimal(10,2) NOT NULL COMMENT 返利金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待生效 1已生效 2已冻结 3已失效 4已提现, settle_time datetime DEFAULT NULL COMMENT 结算时间, PRIMARY KEY (id), UNIQUE KEY uk_order_user_type (order_id, user_id, rebate_type), KEY idx_user_status (user_id, status), KEY idx_activity_status (activity_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用户邀请关系表。记录每个用户的上级邀请人。这个表在注册时写入之后基本不变。返利活动配置表。通用算法能“通用”起来核心就是这张表。我把返利的规则字段都放到了配置里包括返利比例、封顶金额、参与城市、用户等级要求、活动时间范围等。CREATE TABLE rebate_activity_config ( id bigint(20) NOT NULL AUTO_INCREMENT, activity_name varchar(128) NOT NULL, ratio decimal(10,4) NOT NULL COMMENT 返利比例如0.1000表示10%, max_rebate_amount decimal(10,2) DEFAULT NULL COMMENT 单笔封顶金额, min_order_amount decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 参与活动最低订单金额, user_level_limit int(11) NOT NULL DEFAULT 0 COMMENT 用户等级要求0不限, city_ids varchar(255) DEFAULT NULL COMMENT 城市白名单空则不限, start_time datetime NOT NULL, end_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这几张表配合起来就能支撑绝大部分外卖霸王餐返利计算。我们先把这些基础打牢后面算法才有地方落地。2.2 数据一致性为什么一定要幂等设计返利计算是典型的异步场景订单完成的消息从MQ过来消费者拉起来开始计算。这里面有一个最容易踩的坑——消息重复消费。我确实遇到过线上MQ重复投递同一个订单被结算了两次返利的情况用户余额直接翻倍最终靠人工账单才追回来。从那以后幂等就是我的硬性要求。幂等的实现方式很直接利用rebate_order_record表上建的唯一索引uk_order_user_type。每次计算返利前先尝试插入一条status0的流水记录如果插入成功说明这个订单的返利还没算过继续后面的逻辑如果插入报主键冲突说明已经算过了直接跳过。这样就把并发控制提前到了数据库层面比用分布式锁要简单且可靠得多。我当时对比过两种方案分布式锁在极端情况下还是可能出现锁超时或者锁误删唯一索引这张底牌只要数据库不宕机几乎无懈可击。2.3 账户余额更新的并发控制返利计算最终要对用户余额做增加操作这在高并发下很容易造成超发问题。举个具体例子用户同时有三笔订单结算每笔返10元如果三个线程同时读到余额是100元各自加10后再写回最后余额可能是110元而不是130元。解决这个问题的方案有两种。简单方案是直接用数据库原子更新UPDATE user_account SET balance balance #{amount}, version version 1 WHERE user_id #{userId}这条SQL本身自带行锁多个线程并发执行时会排队不会出现丢失更新。复杂方案是使用RedisLua脚本先做预扣减和预增加再异步同步到数据库。这种方案性能更高但实现复杂度也更高。我建议绝大多数场景先用数据库原子更新等真的出现账户表热点写瓶颈了再做Redis层优化也不迟。3. Java实现返利计算的核心逻辑3.1 计算引擎的整体流程返利计算引擎我用Spring Boot实现整体代码结构分成四层入口层监听MQ消息接收订单完成事件。服务层调用返利计算服务完成规则匹配和结果计算。数据层操作订单、用户、活动、账户等表。插件层通过策略模式支持不同返利类型的扩展。一条订单进来后整体流程是这样的根据订单号查出订单详情判断是否满足参与返利的基本条件状态为已完成、实付金额大于0。查询当前订单参与了哪些返利活动。这里通过活动配置表中的时间范围、城市白名单、订单金额门槛进行过滤。对命中的每个活动分别计算自购返和邀请返金额。写入返利记录流水。批量更新或异步更新用户账户余额。这个流程写起来不算复杂但每一步都有坑下面我把关键代码展开讲。3.2 返利类型策略模式的设计外卖返利的规则差异主要体现在返利类型上。自购返是给下单人自己返邀请返是给上级返未来可能还有团队返。这些差异如果用一个大方法写switch-case会越来越长所以我用了策略模式。首先定义一个策略接口public interface RebateStrategy { // 返回当前策略支持的返利类型 Integer supportType(); // 执行返利计算返回返利结果列表 ListRebateResult calculate(RebateContext context); }然后自购返策略和邀请返策略都实现这个接口。自购返策略的逻辑最简单就是校验后按比例计算Component public class SelfRebateStrategy implements RebateStrategy { Override public Integer supportType() { return RebateTypeEnum.SELF.getCode(); } Override public ListRebateResult calculate(RebateContext context) { RebateActivityConfig config context.getConfig(); BigDecimal rebateAmount context.getOrderAmount() .multiply(config.getRatio()) .setScale(2, RoundingMode.HALF_UP); // 封顶校验 if (config.getMaxRebateAmount() ! null rebateAmount.compareTo(config.getMaxRebateAmount()) 0) { rebateAmount config.getMaxRebateAmount(); } return Collections.singletonList(new RebateResult(context.getUserId(), rebateAmount, RebateTypeEnum.SELF.getCode())); } }邀请返策略稍复杂一点要先查上级用户再计算Component public class InviteRebateStrategy implements RebateStrategy { Override public Integer supportType() { return RebateTypeEnum.INVITE.getCode(); } Override public ListRebateResult calculate(RebateContext context) { Long inviterId userRelationService.getInviterId(context.getUserId()); if (inviterId null) { return Collections.emptyList(); } RebateActivityConfig config context.getConfig(); BigDecimal rebateAmount context.getOrderAmount() .multiply(config.getRatio()) .setScale(2, RoundingMode.HALF_UP); if (config.getMaxRebateAmount() ! null rebateAmount.compareTo(config.getMaxRebateAmount()) 0) { rebateAmount config.getMaxRebateAmount(); } return Collections.singletonList(new RebateResult(inviterId, rebateAmount, RebateTypeEnum.INVITE.getCode())); } }策略的装配我用了一个Map在Spring初始化时把所有策略Bean按类型放进去Component public class RebateStrategyFactory implements InitializingBean { Autowired private ListRebateStrategy strategyList; private MapInteger, RebateStrategy strategyMap new HashMap(); Override public void afterPropertiesSet() { for (RebateStrategy strategy : strategyList) { strategyMap.put(strategy.supportType(), strategy); } } public RebateStrategy getStrategy(Integer type) { return strategyMap.get(type); } }这样新增一种返利类型时只需要新加一个Bean完全不用改动已有的计算流程。算是在通用性和可维护性之间找到了一个平衡点。3.3 返利上限校验与金额分摊霸王餐返利还有一个容易被忽略的点就是同一个订单可能参与了多个活动而多个活动的返利是可以叠加的。这种情况下就会出现一个订单的返利总额超过订单实付金额的极端情况也就是平台亏钱了。为了避免这种情况我在计算引擎最后加了一个总返利上限校验。思路是先通过策略计算出所有返利结果然后把所有结果的金额加起来如果超过订单金额的一定比例比如60%则按比例等比缩减每个返利金额。BigDecimal totalRebate results.stream() .map(RebateResult::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal maxAllowed orderAmount.multiply(new BigDecimal(0.6)); if (totalRebate.compareTo(maxAllowed) 0) { // 等比缩减 BigDecimal rate maxAllowed.divide(totalRebate, 6, RoundingMode.HALF_UP); results.forEach(result - result.setAmount( result.getAmount().multiply(rate).setScale(2, RoundingMode.HALF_DOWN) )); }这里用HALF_DOWN做舍入是为了让缩减后的总金额不会因为四舍五入再次超过上限。这类细节不实际跑线上数据很难发现但恰恰是这些细节决定了系统算出来的钱准不准。3.4 基于Redis缓存邀请关系邀请关系的查询是整个计算链路中最常见的查询。用户下单后系统需要查他的上级是谁这个数据读多写少非常适合用缓存。我用Redis做了两级缓存key是rebate:inviter:{userId}value是上级userId过期时间设置为一周。这里要注意两个问题。一个是缓存穿透如果某个用户根本没有上级Redis里没值数据库也查不到每次都要去数据库走一遍空的查询。解决办法是用空值缓存把不存在的邀请关系也缓存一个特殊标记比如-1。另一个问题是缓存更新用户被邀请关系一般不会变化但万一错了需要修正修改数据库后必须同步删除缓存保证下次查询能拿到最新数据。用Redis缓存之后批量计算返利时的性能提升非常明显。后面我会详细说这块的批量化改造。3.5 状态机设计返利从待生效到可提现返利金额不会立刻进入可提现余额而是先进入“待生效”状态。原因很简单外卖订单可能存在退款、取消、投诉等情况。如果订单刚完成就把返利发进用户余额用户转眼就退款了平台就白白损失了这笔返利。我设计的返利状态机包含四个状态0 待生效订单完成但还在售后期暂不发放。1 已生效售后期过去返利进入账户余额。2 已冻结订单发生售后或退款返利被冻结等待人工核对。3 已失效确定订单退款/取消返利作废。订单完成事件写入流水时初始状态是“待生效”。平台会有一个定时任务每天扫描超过售后期通常是7天且状态为“待生效”的返利流水把它们批量更新为“已生效”同时给用户账户增加余额。这样设计有个好处延迟结算把售后退款的风险从计算环节剥离出来了。计算引擎只负责在订单完成时算出返利金额后续的状态变更全部由定时任务处理职责清晰也方便排查问题。4. 性能优化让大促下的返利计算不再拖后腿4.1 批量化改造从单条处理到分片批量最初的返利计算实现是一条订单一条订单地同步处理逻辑上是没问题但性能很差。我印象很深的一次压测订单量峰值达到每秒800笔返利消费者集群的处理能力直接跟不上消息堆积越来越严重最后只好临时扩容。后来我做的第一个优化就是批量化。把原本一条条处理订单的方式改成从MQ里批量拉取消息一次处理100条订单。处理过程中查订单信息、查邀请关系、写返利流水都尽量用批量查询替代循环单查。以邀请关系查询为例最开始的代码是for (OrderDTO order : batchOrders) { Long inviterId redisTemplate.opsForValue().get(rebate:inviter: order.getUserId()); // 省略其他逻辑 }改成批量查询后利用Redis的mGet一次性把所有userId对应的inviterId都查出来ListString keys new ArrayList(); for (OrderDTO order : batchOrders) { keys.add(rebate:inviter: order.getUserId()); } ListString inviterIds redisTemplate.opsForValue().multiGet(keys);同样是查100个userId原来要100次网络往返现在只需要1次。这个改动让整个消费者的吞吐量直接提升了三倍多。4.2 并行度优化CompletableFuture的合理使用批量化之后处理器的速度瓶颈从网络IO转移到了数据库写入。返利流水表要插入上百条记录账户表还要做金额更新这些写操作是有顺序依赖的先写流水确认成功后再更新账户余额。我试过在两个环节之间用CompletableFuture并行处理效果不稳定后来想明白了并行度并不是越高越好当数据库连接池已经被占满时增加线程只会增加等待不会提高吞吐量。最终我采用了固定大小的线程池线程数设为数据库连接池的一半左右让并行写入和数据库承受能力匹配。这里分享一个比较实用的调参经验。返利计算任务属于IO密集型任务线程数CPU核心数*2通常不够但也不是越大越好。我实测下来8核的机器部署消费者线程池设为16到24左右效果最好再往上加数据库连接池会先成为瓶颈。4.3 数据库慢SQL排查与索引优化返利计算的SQL并不复杂但还是会出现慢查询。最常见的就是统计用户返利总额时对整个流水表做分组聚合SELECT user_id, SUM(rebate_amount) FROM rebate_order_record WHERE status 1 GROUP BY user_id这条SQL看起来简单但当流水表数据量过千万时全表扫描加临时表排序耗时能到十几秒。我第一次跑这条SQL的时候以为数据库卡死了。排查慢SQL我习惯先在执行计划上看一眼EXPLAIN SELECT user_id, SUM(rebate_amount) FROM rebate_order_record WHERE status 1 GROUP BY user_id如果type列是ALL说明走了全表扫描index列空的那就要加索引。status字段加上普通索引后查询能缩小扫描范围但status字段区分度不高优化效果有限。更合理的做法是不要实时算这种统计而是这张表加一个total_rebate字段在每笔返利生效时累加维护。换句话说能提前算的不要留到查询时才算。这也是我在优化慢SQL时反复强调的思路数据库不是计算引擎别把所有计算都丢给数据库。4.4 热点账户更新的性能优化资金账户表在高并发下有一个经典问题热点用户。比如某个大V用户一下子邀请了几万用户这些用户同时下单后产生的返利都要更新这个大V的账户余额。所有更新操作都集中到一行记录上数据库行锁竞争非常严重甚至可能拖垮整个账户服务。针对这个场景我采用的方案是合并更新。具体做法是把一分钟内对同一个用户的多次返利先缓存在Redis里用INCRBY命令累加金额然后由一个定时任务分批把Redis中的累计值合并更新到数据库。// 返利发生时只累加Redis中的待入账金额 String key rebate:pending: userId; redisTemplate.opsForValue().increment(key, rebateAmount); // 定时任务每10秒扫描这些key统一入账这个方案把高并发的热点行更新转换成了Redis的原子累加数据库压力瞬间降下来了。但也要注意Redis宕机丢数据的风险所以Redis中的累计数据只作为一个加速层数据库中以流水为准如果发现Redis数据丢失可以通过流水表重新计算补偿。4.5 MQ削峰填谷避免返利风暴大促期间订单完成的峰值流量特别猛。拿我们平台的数据来说常规情况下每秒几百条订单消息但大促开始后的前十分钟订单完成事件可以达到每秒几千条。如果让返利计算消费者实时处理这些消息再弹性的服务也扛不住。解决方案是老生常谈的削峰填谷利用RocketMQ的消费能力让下游按照自己的实际处理速度去消费消费不过来的消息先堆积在MQ里。消费者端控制消费速率的关键参数是并发线程数以及是否开启消费限流。同时我针对不同优先级的返利消息设计了两个Topic。一个是实时性要求较高的“会员专属返利”必须尽快到账另一个是普通活动返利可以延迟一点。两个Topic分配不同数量的消费者实例保证高优业务不被普通业务拖累。5. 通用算法在项目中的落地经验与踩坑记录5.1 线上一次算错账的事故复盘有一次线上事故让我印象特别深刻。运营配置了一个“邀请新用户下单返20元”的活动结果新用户定义出错把老用户也包含进去了导致大量老用户邀请关系也被计算了返利。当天晚上对账就发现返利金额异常紧急下线活动后靠人工脚本把错误返利从用户余额里扣回来。这件事之后我对配置上线增加了一个前置校验流程运营配置完活动规则后必须在测试环境用专门的模拟订单跑一遍返利计算确认结果无误才能发布到生产环境。同时在代码层面对活动配置增加了参数合法性校验比如返利比例不能超过0.5封顶金额不能超过订单金额等。这些硬校验保证了运营操作失误也不会造成资金损失。5.2 数据回流与补偿机制返利计算还有一个常见问题定时任务跑挂了怎么办。比如延迟结算任务本来每天凌晨扫描待生效订单结果当天服务器负载过高任务执行到一半就停了第二天发现部分订单的返利没有入账。针对这个场景我实现的方案是给定时任务增加“断点续跑”能力。每个批次都记录一个游标任务重启后从游标继续处理。游标的实现很简单在任务表里记录上一次处理到的最大的流水ID下次从这里接着查。同时补偿任务要具备天然幂等性也就是“同一条返利流水被处理了两次也不会重复发放”。因为返利流水表有唯一索引更新余额之前会检查流水状态只有状态为“待生效”的流水才会被更新为“已生效”所以重复执行是安全的。5.3 规则配置的灰度发布活动配置改动看起来只是后台表更新但如果是返利比例、封顶金额这些直接影响资金的关键配置我会建议做灰度发布。灰度发布的实现方式是在活动配置表加一个status字段配置先写入待审核状态Redis缓存不更新。审核通过后只对部分城市或者部分用户ID段开放观察几个小时后没问题再全量开放。这里有两个问题需要额外注意一是配置缓存更新必须和数据库变更保持一致最简单的方式是先更新数据库、再删除Redis缓存让流量重新加载新配置二是如果配置有问题要有快速回滚机制把旧配置重新发布一次即可。5.4 Java代码层面的常见性能坑返利计算中Java代码本身也有几个性能坑我顺手总结一下不要在大循环里做字符串拼接用StringBuilder或者直接拼接SQL参数。不要在循环里调用远程RPC哪怕是本地缓存查询也要批量。不要随意new线程线程池统一管理。不要用BigDecimal做除法时忽略精度指定MathContext。不要在事务里做远程调用或者耗时操作事务尽量短小。其中最后一条我尤其在意。返利计算服务的数据库事务经常要写入流水、更新账户如果事务范围太大把Redis操作、MQ发送都包进去数据库连接会长时间被占用大促时连接池直接被打满。我后来把事务拆成了最小粒度只在写流水和更新账户余额时开启事务发送MQ和更新缓存都挪到事务外面。5.5 针对Java面试的常见问题延伸写到这里顺便延伸一句这篇文章里的很多点其实也是Java后端面试中会被问到的高频题。比如策略模式怎么用在业务里、MQ消费幂等怎么保证、分布式场景下如何避免超发、慢SQL怎么排查和优化、缓存穿透和热点更新怎么处理。这些都不是死记硬背的八股文而是真正从霸王餐返利这个业务场景里演化出来的实战问题。面试官问“你项目里遇到过哪些技术挑战”的时候你把返利计算的幂等设计、热点账户合并更新、批量结算优化这些真实案例讲清楚比背一百道面试题都管用。这也是我写这篇文章想传达的东西很多面试题其实就藏在真实业务里把业务做好那些题自然就通了。6. 通用返利计算算法的扩展与演化方向6.1 从两级返利到规则引擎的演进外卖霸王餐业务目前的两级返利用查表法完全够用但如果是做电商分销、社交电商这类业务返利层级可能达到三级甚至更多而且规则复杂多变查表法就会显得吃力。我个人的建议是如果返利层级不超过两级就不要引入规则引擎保持简单的查表逻辑这会让团队的上手成本降到最低。只有当出现以下信号时才考虑引入规则引擎返利规则一月一变甚至一周多变运营频繁提交需求。同一笔订单参与多个活动且活动间有叠加或互斥关系。不同城市、不同用户等级、不同商家返利比例都不一样。在规则引擎选型上Aviator表达式比Drools轻量很多适合后端嵌入。不过规则引擎的学习成本也不低引入之前一定要想清楚。6.2 实时返利与离线批量计算的结合返利计算还可以按场景拆分为实时计算和离线计算两条链路。实时计算负责对时间敏感度高的场景比如用户下单成功后在APP内马上能看到“预计返利XX元”这个场景不允许延迟太久。离线计算则负责大促期间的批量补算、对账、风控审计等用Spark或者Flink跑都可以。两套链路的计算结果会汇总到同一张返利流水表通过source字段区分是实时链路还是离线链路写入的。对账时两张链路算出的结果应该完全一致如果不一致就说明有一方逻辑出了问题。这个设计我虽然没有在项目里完全铺开但确实是个比较稳定的架构方向。实时链路保证用户体验离线链路保证数据正确性。6.3 基于用户行为的动态返利系数现在的外卖省钱APP已经不再满足于固定比例返利开始尝试根据用户行为做动态返利。比如低频用户返利比例提高高频用户降低或者某个用户连续七天没下单平台给他发一个“回归专享返利”活动。通用算法要做这个扩展核心思路是在计算引擎中插入一个“动态系数计算器”。计算器输入是用户历史行为特征输出是一个0.5到2.0之间的系数再把系数乘到基础返利比例上。BigDecimal finalRatio config.getRatio() .multiply(userBehaviorService.getDynamicFactor(userId)) .setScale(4, RoundingMode.HALF_UP);动态系数的计算可以实时查用户标签也可以提前在用户画像服务里算好缓存。我建议用后者因为实时算太消耗资源而且动态系数本身不需要精确到分钟级别。这样扩展之后的通用算法就能覆盖绝大多数返利营销玩法了从固定比例到动态系数本质上还是“配置计算引擎”这套框架在起作用。我在实际做这个项目的过程中最大的体会是返利计算这种资金相关业务技术难度不在算法本身有多难而在细致和稳定。通用算法要考虑各种边界条件性能优化要反复压测验证数据一致性更是一刻都不能松懈。哪怕你今天只是负责其中一个小模块也要把订单、活动、账户、流水这条完整的数据链路吃透因为任何一个环节出错最后都是用户余额对不上账的大事故。希望这篇文章能帮你少踩一些我当年踩过的坑把霸王餐返利这个业务做得又稳又快。
返回列表