背景
工作中有涉及在内存中进行数据筛选计算,进行一番搜索后,我选择使用 AviatorScript 来实现,这里探索一下这个方案,并测试一下它的性能。
实现
准备数据
写个函数用于创建数据:
java
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个字段:
java
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());
});
}
运行结果如下:
228ms,在这个数据量下,速度还算能够接受。
方式2:使用 seq 库一次计算
Aviator 还提供了类似java的Stream的能力,描述如下:
刚刚我们是在 Stream 的filter中调用 Aviator 的,但seq库提供的能力可以让我们一次性把整个list整个塞进去,将全部数据操作都给 Aviator 进行,这样少了外部到Aviator的交互,会不会更快呢?
我们添加代码:
java
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());
});
这里注意表达式要做一些修改:
python
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)
运行结果:
要同样数据集测试两个方式才有对比性,所以我将它们放在了一起,再跑一遍,从输入日志可以看出,这种方式耗时要多541ms,
内存消耗比较
我们使用 IDEA 的 Profiler 测试一下:
方式1使用了大概 53 MB,是原数据的2倍,还行。
而方式2,好家伙, 473MB! 真吓到我了!
后面我仔细一想,可能是因为原数据只有100条,后续数据都是引用,才造成这么大的区别,所以我将数据换成真正100w条实体对象,再次测试:
区别并不大,前者逐条处理,使用的内存自然要小很多,这也在情理之中。
其中有意思的是,这时测试的耗时要多了不少(跟Profiler无关,单独运行),这个我只能盲猜它内部有一些缓存机制,有大拿知道的话希望能解答一下:
完整代码
java
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,相信也不会有多少人选择在内存中做计算了吧?