掘金 后端 ( ) • 2024-04-22 09:59

背景

工作中有涉及在内存中进行数据筛选计算,进行一番搜索后,我选择使用 AviatorScript 来实现,这里探索一下这个方案,并测试一下它的性能。

实现

准备数据

写个函数用于创建数据:

private static List<Map<String, Object>> buildData(int cnt) {
    List<Map<String, Object>> base = new ArrayList<>();
    for (int i = 0; i < 100; i++) {
        Map<String, Object> item = new HashMap<String, Object>();
        for (int j = 0; j < 26; j++) {
            item.put((char) ('a' + j) + "", Math.random() * 100);
        }
        base.add(item);
    }
    if (cnt <= 100) {
        return base;
    }
    List<Map<String, Object>> res = new ArrayList<>(cnt);
    for (int i = 0; i < cnt / 100; i++) {
        res.addAll(base);
    }
    return res;
}

方式1:预编译后逐条执行

因为我们要处理的是大量的数据,简单的直接执行表达式自然是不可取的,我们直接略过,翻看文档时,我发现Aviator提供了预先编译表达式的方式(即:先编译表达式, 返回一个编译的结果, 然后传入不同的env来复用编译结果),如此一来能省去很多重复工作。

我们测试100w条数据,每个数据26个字段,表达式中用到了8个字段:

public static void main(String[] args) {
    List<Map<String, Object>> list = buildData(100*10000);
    System.out.println(list.size());
    String expression = "a>10 && b < 80 && a - b > 10 || e>10 && f < 80 && g - h > 10";
    // 预编译方式
    Expression compiledExp = AviatorEvaluator.compile(expression);
    DurationUtil.duration("预编译方式", () -> {
        List<Map<String, Object>> res = list.stream()
                .filter(map -> compiledExp.execute(map).equals(true))
                .toList();
        System.out.println("筛选后大小:"+res.size());
    });

}

运行结果如下:

image.png

228ms,在这个数据量下,速度还算能够接受。

方式2:使用 seq 库一次计算

Aviator 还提供了类似java的Stream的能力,描述如下: image.png

刚刚我们是在 Stream 的filter中调用 Aviator 的,但seq库提供的能力可以让我们一次性把整个list整个塞进去,将全部数据操作都给 Aviator 进行,这样少了外部到Aviator的交互,会不会更快呢?

我们添加代码:

Map<String, Object> env = new HashMap<>();
env.put("list",list);
String seqExp = "filter(list, lambda(m) ->m.a > 10 && m.b < 80 && m.a - m.b > 10 || m.e>10 && m.f < 80 && m.g - m.h > 10 end)";
// seq 方式
Expression seqCompiledExp = AviatorEvaluator.compile(seqExp);
DurationUtil.duration("seq 方式", () -> {
    List<Map<String, Object>> res =(List<Map<String, Object>>) seqCompiledExp.execute(env);
    System.out.println("筛选后大小:"+res.size());
});

这里注意表达式要做一些修改:

filter(list, lambda(m) ->m.a > 10 && m.b < 80 && m.a - m.b > 10 || m.e>10 && m.f < 80 && m.g - m.h > 10 end)

运行结果:

image.png

要同样数据集测试两个方式才有对比性,所以我将它们放在了一起,再跑一遍,从输入日志可以看出,这种方式耗时要多541ms,

内存消耗比较

我们使用 IDEA 的 Profiler 测试一下:

image.png 方式1使用了大概 53 MB,是原数据的2倍,还行。

而方式2,好家伙, 473MB! 真吓到我了!0084F7AC.gif

后面我仔细一想,可能是因为原数据只有100条,后续数据都是引用,才造成这么大的区别,所以我将数据换成真正100w条实体对象,再次测试:

image.png 区别并不大,前者逐条处理,使用的内存自然要小很多,这也在情理之中。

其中有意思的是,这时测试的耗时要多了不少(跟Profiler无关,单独运行),这个我只能盲猜它内部有一些缓存机制,有大拿知道的话希望能解答一下:

image.png

完整代码

public class AviatorTest {
    public static void main(String[] args) {
        List<Map<String, Object>> list = buildData(100*10000);
        System.out.println("数据大小:"+list.size());
        String expression = "a>10 && b < 80 && a - b > 10 || e>10 && f < 80 && g - h > 10";
        // 预编译方式
        Expression compiledExp = AviatorEvaluator.compile(expression);
        DurationUtil.duration("预编译方式", () -> {
            List<Map<String, Object>> res = list.stream()
                    .filter(map -> compiledExp.execute(map).equals(true))
                    .toList();
            System.out.println("筛选后大小:"+res.size());
        });
        System.out.println("==================================");
        Map<String, Object> env = new HashMap<>();
        env.put("list",list);
        String seqExp = "filter(list, lambda(m) ->m.a > 10 && m.b < 80 && m.a - m.b > 10 || m.e>10 && m.f < 80 && m.g - m.h > 10 end)";
        // seq 方式
        Expression seqCompiledExp = AviatorEvaluator.compile(seqExp);
        DurationUtil.duration("seq 方式", () -> {
            List<Map<String, Object>> res =(List<Map<String, Object>>) seqCompiledExp.execute(env);
            System.out.println("筛选后大小:"+res.size());
        });

    }

    private static List<Map<String, Object>> buildData(int cnt) {
        List<Map<String, Object>> base = new ArrayList<>();
        for (int i = 0; i < 10000; i++) {
            Map<String, Object> item = new HashMap<String, Object>();
//            item.put("seq", i);
            for (int j = 0; j < 26; j++) {
                item.put((char) ('a' + j) + "", Math.random() * 100);
            }
            base.add(item);
        }
        if (cnt <= 10000) {
            return base;
        }
//        base.addAll(buildData(cnt-10000));
//        return base;
        List<Map<String, Object>> res = new ArrayList<>(cnt);
        for (int i = 0; i < cnt / 10000; i++) {
            res.addAll(base);
        }
        return res;
    }
}

结论

经过上面的验证,逐条去过滤数据似乎要比一次性注入所有数据快的多,个人猜测可能是大量数据传输和解析造成的。

因此,在100w行以内的数据,使用 Aviator 处理,再开线程做一些优化,其性能还是可以接受的,但还是有OOM风险,最好严格控制数据量;至于超过了100w,相信也不会有多少人选择在内存中做计算了吧?

参考

AviatorScript 文档 (yuque.com)