当leetcode真题上了生产引发的线上问题

简介: 11月7日上午,支付网关下游HSF请求出现失败,一台额度中心服务器异常。经排查,发现是B算法在处理47笔订单时导致内存溢出(OOM)。该算法用于计算用户可用额度下的最优订单组合,但因递归创建链表占用过多内存而崩溃。为解决此问题,团队紧急将用户流量切换至A算法,并对B算法进行优化。通过分治+回溯和背包算法的对比实验,最终选择根据订单数和金额阈值动态选择算法,确保系统稳定性和性能。此次事件提醒我们,在编程中需充分考虑边界情况并进行性能测试,避免极端情况对系统的影响。

1.问题发现

11.7上午10点半时,正值业务高峰期。预警群里突然报警,支付网关的下游HSF请求出现失败,下游额度中心一台机子出现异常。于是第一时间通知下游同学紧急隔离问题机器,保证请求正常处理不再报警后,下游同学排查问题。

image.png

2.问题排查

下游同学通过grace分析问题机器dump文件时,根据泄漏报表显示的崩溃线程及代码找到我,让我看一下这个问题,我才知道原来刚刚下游的机子异常是因为这段代码,这段代码已经运行三个月了,怎么今天出问题了呢?

image.png

带着疑问,先到线程信息查看崩溃线程的执行情况,发现程序在不断递归,递归的过程在不断创建链表占用内存,最后OOM了。

image.png

这段代码是今年8.8上线的,跟下游同学咨询得知,10月底到11月初大概还出现过三次某个机子异常,于是初步判断是个别极端case进入了算法的盲区。当时上线了AB两种算法,两种算法从8.8~11.03一直是以1:1的灰度比例运行,从11.03开始全部切到了B算法,造成OOM的正是B算法,切换后出问题的概率明显提高了。先不管具体代码是什么问题,第一时间通过diamond将用户全量切到A算法,保证系统稳定。


接下来具体问题具体分析。看case之前不得不先提一下代码的背景,1688的批量收银台在用户进入批量支付时会进行支付咨询,对于开通金融产品的客户,支付咨询时会到金融网关查询该批订单可用金融产品支付的订单条目,得到结果后对客展示。


image.png


而额度中心管控的额度多种多样,其中有一种叫透支额度,是在对客透传的额度之外,用户还可以使用的额度。当用户其他额度都不够支付这一批订单时,会使用到这一部分额度,并且只能使用一次。我们希望在有限的额度里实现用户可支付订单金额总和最大化,B算法要做的也是这个事情。

进一步在线程信息中找到过程参数,定位用户id,先把case拉出来。

image.png

然后通过sls查找具体的请求参数,发现这个用户下了47笔订单,突破了原本30笔的限制。

image.png 

其实批量收银台前端是有30笔的支付上限的,而B算法的执行上限在37笔,这个case的47笔用户是怎么进来的,后面再查。先看看这个case下暴露的代码问题。

image.png

代码如下,该算法输入一组订单,以及该批订单的最高可用额度maxQuota,返回最接近maxQuota的一组订单,实现用户可用额度应用尽用。比如有三笔订单,订单1金额为800元,订单2金额为500元,订单3金额为400元,用户可使用额度为900元时,系统返回订单2、3,用户可用800元时,系统返回订单1。这是一个nums与goal的问题,这个问题在leetcode上是一道真题:


https://leetcode.cn/problems/closest-subsequence-sum/,感兴趣的同学可以看一下。B算法解决这个问题的思路是分治加回溯,将订单均分为左右两部分,依次求出两边的子集,以及每个子集对应的金额和之后,根据金额和对两部分子集分别排序,之后结合两部分集合,通过双指针求出最接近maxQuota的集合。但是跟leetcode不同的在于,题目要求的是最小差值,而我们要求最小差值对应的订单集,因此在回溯求子集的过程中要记录下最接近目标值的一组订单。算法的时间复杂度是O(n/2*(2^(2/n))),空间复杂度原本是O(n),即栈的深度,在这里因为要记录子集,是O(2^(2/n))。


 public static List<BatchPayOrder> findBestCanPayOrders(List<BatchPayOrder> input, long maxQuota) {
        if (input.size() == 1) {
            if (input.get(0).getAmount() <= maxQuota) {
                return input;
            }
        }
        int len = input.size() >> 1;
        List<BatchPayOrder> result = new LinkedList<>();
        List<BatchPayOrder> numsLeft = input.subList(0, len);
        List<BatchPayOrder> numsRight = input.subList(len, input.size());
        List<Temp> resultLeft = subsets(numsLeft);
        List<Temp> resultRight = subsets(numsRight);
        Collections.sort(resultLeft, getComparator());
        Collections.sort(resultRight, getComparator());
        int i1 = 0, n1 = resultLeft.size(), i2 = resultRight.size() - 1;
        long ans = Math.abs(maxQuota);
        while (i1 < n1 && i2 >= 0) {
            long num = resultLeft.get(i1).getSum() + resultRight.get(i2).getSum() - maxQuota;
            if (num > 0) {
                i2--;
            } else if (num < 0) {
                if(-num<ans){
                    result.removeAll(result);
                    result.addAll(resultLeft.get(i1).getSubset());
                    result.addAll(resultRight.get(i2).getSubset());
                }
                ans = Math.min(ans, -num);
                i1++;
            } else {
                result.removeAll(result);
                result.addAll(resultLeft.get(i1).getSubset());
                result.addAll(resultRight.get(i2).getSubset());
                return result;
            }
        }
        return result;
    }

    public static List<Temp> subsets(List<BatchPayOrder> input) {
        List<Temp> result = new LinkedList<>();
        if(input.size()==0){
            return result;
        }
        helper(input,0,new LinkedList<>(),result, 0);
        return result;
    }

    private static void helper(List<BatchPayOrder> input, int index, LinkedList<BatchPayOrder> subset, List<Temp> result, long sum){
        if(input.size()==index){
            Temp temp = new Temp();
            temp.setSubset(new LinkedList<>(subset));
            temp.setSum(sum);
            result.add(temp);
        }else if(index<input.size()){
            helper(input,index+1,subset,result, sum);
            sum += input.get(index).getAmount();
            subset.add(input.get(index));
            helper(input,index+1,subset,result, sum);
            sum -= input.get(index).getAmount();
            subset.removeLast();
        }
  }

    @Data
    public static class Temp{
        private long sum;
        private LinkedList<BatchPayOrder> subset;
    }


在出现问题的case中,该用户选择了47笔订单,对应回溯过程中resultLeft与resultRight会有2^23 = 8388608与2^24 = 16777216种组合,每个子集都需要记录,这也是空间复杂度被打爆的原因。


image.png

3.问题解决

3.1、可选解法

3.1.1、最简算法

为了保证B算法带来的gmv,在当天紧急上线了简单版的多笔算法。简单算法将订单排序后做一次遍历,订单金额小于额度就放进池子里,原则是能用就用。在金融订单贴现产品中用的就是这种方式,简单粗暴。


private static BiFunction<List<BatchPayOrder>, Long, List<Long>> SIMPLEST_MULTI_FUNC = (input, maxQuota) -> {
        List<Long> result = new ArrayList<>();
        final long[] finalMaxQuota = {maxQuota};
        Optional.ofNullable(input).orElse(Collections.emptyList())
            .stream().sorted(Comparator.comparing(BatchPayOrder::getAmount).reversed()).forEach(order -> {
                if (finalMaxQuota[0] >= order.getAmount()) {
                    result.add(order.getOrderId());
                    finalMaxQuota[0] -= order.getAmount();
                }
            }
        );
        return result;
    };


3.1.2、分治+回溯  

之后对B算法的空间复杂度进行优化,思路是把长订单号做一个0~n的简单映射,同时用String替代链表存储子集,改进后的代码如下:


  private static  BiFunction<List<BatchPayOrder>, Long, List<Long>> DIVIDE_AND_TRACE_BACK_FUNC = (input, maxQuota) -> {
        int len = input.size() >> 1;
        Map<Integer, Long> mapping = Maps.newHashMap();
        List<BatchPayOrder> convertInput = convert(input, mapping);
        List<BatchPayOrder> numsLeft = convertInput.subList(0, len);
        List<BatchPayOrder> numsRight = convertInput.subList(len, input.size());
        List<Temp> resultLeft = subsets(numsLeft);
        List<Temp> resultRight = subsets(numsRight);
        resultLeft = resultLeft.stream().sorted(Comparator.comparing(Temp::getSum)).collect(Collectors.toList());
        resultRight = resultRight.stream().sorted(Comparator.comparing(Temp::getSum)).collect(Collectors.toList());
        StringBuilder resultOrdersStr = new StringBuilder();
        int i1 = 0, n1 = resultLeft.size(), i2 = resultRight.size() - 1;
        long ans = Math.abs(maxQuota);
        while (i1 < n1 && i2 >= 0) {
            long num = resultLeft.get(i1).getSum() + resultRight.get(i2).getSum() - maxQuota;
            if (num > 0) {
                i2--;
            } else if (num < 0) {
                if (-num < ans) {
                    resultOrdersStr.delete(0, resultOrdersStr.length());
                    resultOrdersStr.append(resultLeft.get(i1).getChoiceChain());
                    resultOrdersStr.append(resultRight.get(i2).getChoiceChain());
                }
                ans = Math.min(ans, -num);
                i1++;
            } else {
                resultOrdersStr.delete(0, resultOrdersStr.length());
                resultOrdersStr.append(resultLeft.get(i1).getChoiceChain());
                resultOrdersStr.append(resultRight.get(i2).getChoiceChain());
                return getListFromChoiceChain(resultOrdersStr, mapping);
            }
        }
        return getListFromChoiceChain(resultOrdersStr, mapping);
    };

    private static List<BatchPayOrder> convert(List<BatchPayOrder> input, Map<Integer, Long> mapping){
        final Integer[] i = {1};
        return input.stream().map(order->{
            BatchPayOrder batchPayOrder = new BatchPayOrder();
            batchPayOrder.setOrderId((long)i[0]);
            batchPayOrder.setAmount(order.getAmount());
            mapping.put(i[0], order.getOrderId());
            i[0]++;
            return batchPayOrder;
        }).collect(Collectors.toList());
    }

    private static List<Temp> subsets(List<BatchPayOrder> input) {
        List<Temp> result = new LinkedList<>();
        if(input.size()==0){
            return result;
        }
        helper(input,0, new StringBuilder(), result, 0);
        return result;
    }

    private static void helper(List<BatchPayOrder> input, int index, StringBuilder choiceChain, List<Temp> result,
                               long sum) {
        if (input.size() == index) {
            Temp temp = new Temp();
            temp.setChoiceChain(choiceChain.toString());
            temp.setSum(sum);
            result.add(temp);
        } else if (index < input.size()) {
            helper(input, index + 1, choiceChain, result, sum);
            sum += input.get(index).getAmount();
            choiceChain.append("|").append(input.get(index).getOrderId());
            helper(input, index + 1, choiceChain, result, sum);
            sum -= input.get(index).getAmount();
            choiceChain.delete(choiceChain.length()-input.get(index).getOrderId().toString().length()-1, choiceChain.length());
        }
    }

    private static List<Long> getListFromChoiceChain(StringBuilder resultOrdersStr, Map<Integer, Long> mapping) {
        List<Long> result = new ArrayList<>();
        String[] orderIds = resultOrdersStr.toString().split("\\|");
        for (int i = 1; i < orderIds.length; i++) {
            result.add(mapping.get(Integer.valueOf(orderIds[i])));
        }
        return result;
    }

    @Data
    static class Temp{
        private long sum;
        private String choiceChain;
    }


3.1.3、背包算法

后来经小伙伴提醒,这个问题是可以用背包问题解决的,仔细一看,还真是。将maxQuota看成背包大小,订单作为物品,amount[n]表示订单金额数组,不难写出状态转移方程:


if (j >= amount[i]) {
         dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - amount[i]] + amount[i]);
} else {
         dp[i][j] = dp[i - 1][j];
}

其中 i: 1~n ,j: 1-maxquota,算法实现如下,时间复杂度为:n*maxquota,空间复杂度为:n*maxquota , 因为要回溯选择路径,不做状态压缩。算法实现如下:


private static BiFunction<List<BatchPayOrder>, Long, List<Long>> KNAPSACK_FUNC = (input, maxQuota) -> {
        List<Integer> orderAmount = input.stream().map(order -> Integer.valueOf(order.getAmount().toString()))
            .collect(Collectors.toList());
        int[][] dp = new int[orderAmount.size()][Integer.valueOf(maxQuota.toString()) + 1];
        int[] choice = new int[orderAmount.size()];
        for (int j = 1; j <= maxQuota; j++) {
            if (j >= orderAmount.get(0)) {
                dp[0][j] = orderAmount.get(0);
            }
        }
        for (int i = 1; i < orderAmount.size(); i++) {
            for (int j = 1; j <= maxQuota; j++) {
                if (j >= orderAmount.get(i)) {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - orderAmount.get(i)] + orderAmount.get(i));
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        traceOrderIds(input.size(), Integer.valueOf(maxQuota.toString()), orderAmount, choice, dp);
        List<Long> result = Lists.newArrayList();
        for (int i = 0; i < input.size(); i++) {
            if (choice[i] == 1) {
                result.add(input.get(i).getOrderId());
            }
        }
        return result;
    };

    private static void traceOrderIds(int n, int maxQuota, List<Integer> orderAmount, int[] choice, int[][] dp) {
        for (int i = n - 1; i >= 1; i--) {
            if (dp[i][maxQuota] == dp[i - 1][maxQuota])  {
                // 表示没有选择第i笔订单
                choice[i] = 0;
            } else {
                choice[i] = 1;
                maxQuota -= orderAmount.get(i);
            }
        }
        // 订单1单独判断,>0表示选择了
        choice[0] = (dp[0][maxQuota] > 0) ? 1 : 0;
    }


3.2、算法比较与选择

3.2.1、背景数据

写完算法,接下来面临的问题是怎么选择算法。当然用于生产,还是要参照数据进行选择,已知情况如下:


1、接口rt

算法在用户支付链路上使用,整个接口rt<40ms,要求时间复杂度不能过高。


2、历史数据

分析历史数据,我们不难找出80%~90%用户maxQuota跟orderNum。这部分内容出于安全考虑做了模糊化,真实情况属于金额大,订单小。


image.png

3.2.2、对比实验

接下来通过几组性能测试查看分治+回溯与背包算法的效果。根据金额大,订单小的信息,我们假设几个阈值,来看算法性能。


1)设置maxquota为100000,用户平均一个批次下单5笔

执行参数:

金额:100000;  订单数:5;  耗时单位:us

预估时间复杂度:

分治+回溯: 3*2^3 =24;背包:50w

预估空间复杂度:

分治+回溯:  2^3 = 8;背包:50w  

workbench测试:



image.png

2)保持100000不变,提高订单量到20笔

执行参数:

金额:100000;订单数:20;  耗时单位:us

预估时间复杂度:

分治+回溯: 10*2^10 = 10240;背包:200w

预估空间复杂度:

分治+回溯:  2^10 = 1024;背包:200w

workbench测试:


image.png

3)保持100000不变,提高订单量到30笔

执行参数:

金额:100000;订单数:30;  耗时单位:us

预估时间复杂度:

分治+回溯: 15*2^15;背包:300w

预估空间复杂度:

分治+回溯:  2^15;背包:300w

workbench测试:

 image.png

4)保持订单量30笔不变,金额降到50000

执行参数:

金额:50000;订单数:30;  耗时单位:us

预估时间复杂度:

分治+回溯: 15*2^15;背包:150w

预估空间复杂度:

分治+回溯:  2^15;背包:150w

workbench测试:

image.png


3.2.3、最后选择

观察上述实验可知,在80%的用户金额大、订单小的情况下,分治+回溯的表现会比背包好,因为订单量小,降低了时间与空间的复杂度。同时当订单量上来时,分治+回溯显得有些疲惫,而当金额上来时,背包也捉襟见肘了,于是有了以下折衷选择:


  • 订单数<ORDER_THRESHOLD,选择分治+回溯;
  • 订单数>ORDER_THRESHOLD,可用额度<=AMOUNT_THRESHOLD,选择背包;
  • 其他情况选择兜底最简算法;


public static List<Long> executeMultiOverDrawnAlgorithm(List<BatchPayOrder> inputOrder, long maxQuota){
      if (CollectionUtils.isEmpty(inputOrder)) {
          return Collections.emptyList();
      }
      //透支金额满足订单总价值,直接返回
      long needOverDraftAmountSum = inputOrder.stream().mapToLong(order -> order.getAmount()).sum();
      if (needOverDraftAmountSum <= maxQuota) {
          return inputOrder.stream().map(order -> order.getOrderId()).collect(Collectors.toList());
      }
      //订单数<=ORDER_THRESHOLD,使用分治+回溯
      if (inputOrder.size() <= ORDER_THRESHOLD) {
          return DIVIDE_AND_TRACE_BACK_FUNC.apply(inputOrder, maxQuota);
      }
      //订单数大于ORDER_THRESHOLD,额度<=AMOUNT_THRESHOLD使用背包
      if (maxQuota <= AMOUNT_THRESHOLD) {
          return KNAPSACK_FUNC.apply(inputOrder, maxQuota);
      }
      //其他情况使用最简算法
      return SIMPLEST_MULTI_FUNC.apply(inputOrder, maxQuota);
  }

4.问题体会

这是成为练习时长两年半的练习生以来第一次在线上遇到比较有趣的场景,可以跟leetcode算法题联动,也是第一次遇到OOM的问题,值得记录。从中的感受有几点,第一,因为组织架构变动等一些原因,这段跟业务强相关的代码一直被放在下层原子能力层(额度中心),未来寻求迁移方案;第二,在实际编程过程中,一定要充分考虑边界以及做性能测试,避免一些极端情况对系统带来未知影响;第三,算法的探索是无止境的,感谢两位伙伴在背包算法上的提醒。





来源  |  阿里云开发者公众号

作者  |  臻臻


相关文章
|
11月前
|
弹性计算 运维 监控
云资源运维难?阿里云免费工具来帮忙
阿里云推出免费运维工具——云服务诊断,帮助用户提升对云资源的运维效率、降低门槛、减轻负担。其核心功能包括「健康状态」和「诊断」。通过「健康状态」可实时查看云资源是否正常;「诊断」功能则能快速排查网络、配置、安全等问题,并提供修复建议,助您迅速恢复业务。体验评测活动火热进行中,参与即有机会赢取索尼头戴耳机、小米背包等好礼。活动链接:https://developer.aliyun.com/topic/cloud-health。
910 24
|
11月前
|
Arthas Java 应用服务中间件
我的程序突然罢工了|深入探究HSF调用异常,从死锁到活锁的全面分析与解决
本文详细记录了作者在处理HSF调用异常问题的过程中,从初步怀疑死锁到最终发现并解决活锁问题的全过程。
877 49
|
11月前
|
存储 NoSQL 关系型数据库
从大数据到大模型:如何做到“心无桎梏,身无藩篱”
在大数据和大模型的加持下,现代数据技术释放了巨大的技术红利,通过多种数据范式解除了数据的桎梏,使得应用程序达到了“心无桎梏,身无藩篱”的自在境界,那么现代应用有哪些数据范式呢?这正是本文尝试回答的问题。
1649 101
|
12月前
|
测试技术 开发工具 git
写了BUG还想跑——闲鱼异常日志问题自动追踪-定位-分发机制
为了高效地发现、定位和解决预发问题,闲鱼团队研发了一套异常日志问题自动追踪-定位-分发机制。这套机制通过自动化手段,实现了异常日志的定时扫描、精准定位和自动分发,显著降低了开发和测试的成本,提高了问题解决的效率。
529 15
写了BUG还想跑——闲鱼异常日志问题自动追踪-定位-分发机制
|
Java API 开发者
你的应用是不是只有service_stdout.log?
本文记录了logback-spring.xml文件不生效问题的整体排查思路。
|
11月前
|
人工智能 IDE 程序员
GitHub Copilot 免费了!程序员们的福音来了!
《GitHub Copilot 免费了!程序员们的福音来了!》 近日,GitHub 宣布其 AI 编程助手 GitHub Copilot 现在可以免费使用。曾经每月需支付 10 美元订阅费的 Copilot,现在向所有人开放免费版本,这对个人开发者、初学者和小型团队来说是个大好消息。免费版支持 GPT 和 Claude 模型,并提供每月 2000 次代码补全和 50 条聊天消息等核心功能。用户只需注册或登录 GitHub 账户,在 VS Code 中安装扩展并激活免费版即可使用。此外,Visual Studio Code 也完全免费,进一步降低了开发门槛。 除了
12001 7
GitHub Copilot 免费了!程序员们的福音来了!
|
11月前
|
Java 对象存储 开发者
如何找出Java进程占用CPU高的元凶
本文记录了一次Java进程CPU占用率过高的问题和排查思路。
|
存储 Dubbo Java
分布式 RPC 底层原理详解,看这篇就够了!
本文详解分布式RPC的底层原理与系统设计,大厂面试高频,建议收藏。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
分布式 RPC 底层原理详解,看这篇就够了!
|
11月前
|
监控 搜索推荐 API
京东按图搜索京东商品(拍立淘)API接口的开发、应用与收益
京东通过开放商品详情API接口,尤其是按图搜索(拍立淘)API,为开发者、企业和商家提供了创新空间和数据支持。该API基于图像识别技术,允许用户上传图片搜索相似商品,提升购物体验和平台竞争力。开发流程包括注册账号、获取密钥、准备图片、调用API并解析结果。应用场景涵盖电商平台优化、竞品分析、个性化推荐等,为企业带来显著收益,如增加销售额、提高利润空间和优化用户体验。未来,随着数字化转型的深入,该API的应用前景将更加广阔。
499 1
|
JSON Java fastjson
Java日志通关(五) - 最佳实践
作者日常在与其他同学合作时,经常发现不合理的日志配置以及五花八门的日志记录方式,后续作者打算在团队内做一次Java日志的分享,本文是整理出的系列文章第五篇。