Es 索引查询排序分析

文章目录

    • 概要
    • 一、Es数据存储
        • 1.1、_source
        • 1.2、stored fields
    • 二、Doc values
        • 2.1、FieldCache
        • 2.2、DocValues
    • 三、Fielddata
    • 四、Index sorting
    • 五、小结
    • 六、参考

概要

倒排索引 优势在于快速的查找到包含特定关键词的所有文档,但是排序,过滤、聚合等操作并不是倒排索引所擅长的。

假设文档有两个字段,一个被分词的字段name,一个日期date。现在要查出包含花朵的所有文档中的按日期正序的前10个,如果仅仅有倒排索引该如何实现的呢?
答:在name字段的倒排索引上找到包含关键词花朵的所有文档ID,然后获取date排序字段的值,根据date值实时排序,取前10个。
这种办法数据量少的时候还可以,如果索引有百万级以上的数据,就很低效了。显然,MySQL、MongoDB等数据库按字段建立索引进行预排序更高效。

可以看到只有倒排索引的话,排序是非常耗时的。Es维护组投入了大量时间和精力来优化排序查询,随着版本更新,在Es6基本完善了这一块工作,分三部分:

  1. Docvalues
  2. Fielddata
  3. index sorting

Es对搜索与排序的区别解释很到位:

Search needs to answer the question “Which documents contain this term?”, while sorting and aggregations need to answer a different question: “What is the value of this field for this document?”.

一、Es数据存储

Es是基于Lucene引擎的,在Lucene中索引文档时,原始字段信息经过分词、转换处理后形成倒排索引,而原始内容本身并不直接保留。因此,Lucene为了检索时能够获取到字段的原始值而设计了stored fields。另外Es自己追了一个_source字段,保存了用户插入文档的字段值。

1.1、_source

Es将文档内容以json形式存储在一个叫_source的字段中,其默认是开启的。在Lucene层面,_source仅仅是一个普通字段,当开启时,我们插入一个文档,Es会自动把文档内容序列化,赋值给_source字段,组成新的文档结构,再交由Lucene处理,大致流程如下:

Es _source写入过程
可以看到,在Lucene层面_source字段和其他字段并无特别之处。我们一般按如下方式调整_source字段:

PUT my-index
{"mappings": {"_source": {"enabled": true,  #是否开启_source字段,默认开启"includes": [ #开启_source字段是包含哪些字段,支持正则"*.count","meta.*"],"excludes": [ #开启_source字段是排除哪些字段,支持正则"meta.description","meta.other.*"]}}
}

PS:一般情况下我们都是开启_source字段的,因为关闭_source后update、update_by_query、reindex等API将无法正常使用。

1.2、stored fields

stored fields是Lucene引擎自带的特性,在Es层面可通过store属性来控制,默认不开启。

PUT my-index
{"mappings": {"properties": {"name": { "type":  "text"  #默认关闭name字段的stored field},"age": { "type":  "long","store": true   #开启age字段的stored field}}}
}

那么如果即开启_source字段,某个字段的stored fields也开启,那么这个字段值会被存储两次,所以字段stored fields属性一般不开启,如果开启最好通过_source的excludes属性排除该字段即可。

演示如下:

[root@test ~]# curl -XPUT http://127.0.0.1:9200/test -H  'Content-Type: application/json' -d '{"mappings":{"properties":{"status_code":{"type":"text"},"session_id":{"type":"keyword","store":true}}}}'
{"acknowledged":true,"shards_acknowledged":true,"index":"test"}
[root@test ~]# curl -XGET http://127.0.0.1:9200/test/_doc/1?pretty
{"_index" : "test","_id" : "1","_version" : 1,"_seq_no" : 0,"_primary_term" : 1,"found" : true,"_source" : {"status_code" : "xxx bob","session_id" : "999"}
}
[root@test ~]# curl -XGET http://127.0.0.1:9200/test/_doc/1?pretty\&stored_fields=status_code
{"_index" : "test","_id" : "1","_version" : 1,"_seq_no" : 0,"_primary_term" : 1,"found" : true
}
[root@test ~]# curl -XGET http://127.0.0.1:9200/test/_doc/1?pretty\&stored_fields=status_code,session_id
{"_index" : "test","_id" : "1","_version" : 1,"_seq_no" : 0,"_primary_term" : 1,"found" : true,"fields" : {"session_id" : ["999"]}
}

Lucene的stored fields属于正排索引,由于字段值经过分词转换为倒排索引后,原始值并未保存,为了根据doc id快速获取字段原始值而设计的。在Lucene下的应用场景是当需要返回原始字段值时,就需要对对应字段设置stored fields; 在Es下由于设计了_source字段,一般不需开启,根据实际场景二者结合使用即可。

值得注意的是,如果 _source 为不开启,并且字段 store 属性也为 false ,字段值就无法返回了,但是如果字段index属性为true的话,还是可以通过该字段查询文档的,如果字段index属性为false的话,该字段既不能查询,也不会被返回,是无效字段。

二、Doc values

默认情况下,大多数字段都会被建立倒排索引,这使得它们可以进行搜索。但是排序、聚合和脚本操作中的字段值需要获取文档某个字段的具体值。Doc values就是专为这种场景而诞生的,简单来说就是实现根据doc id快速获取doc中某个字段值的目标。

doc values是列式存储,主要包含doc id和字段具体值(可能被压缩),属于正排索引。Es默认每个字段的 doc values 都是开启的(不支持textannotated_text类型字段)。

Doc values是Lucene引擎自带的特性,从Lucene V4.0开始引入的,在4.0之前都是依赖fieldCache机制,用于缓存字段值。

2.1、FieldCache

FieldCache,即字段缓存,作用是加载所有文档中某个特定字段的值到内存,便于随机获取某些文档中该字段的值。在FieldCache出现之前,当用户需要访问各文档中某个字段的值时,IndexSearcher.doc(docId)获得Document的所有字段值,但访问速度比较慢,而且只能获得被stored字段的值。

于是Lucene 在 3.0 提出了字段缓存(FieldCache)的概念,这个 API 主要是在内部使用,但开发者也可以外部调用,在自定义排序或者自定义评分的时候都涉及到相关的接口。准确的说,就是将相关的字段的值加载到内存中,形成一维数组。加载字段缓存,并不要求相关字段的值必须在索引中被stored,而是要求必须 index,并且不能分词,实际上字段缓存的值是从倒排索引中转换而来。

FieldCache主要用于以下场景:

  • 加速对特定字段值的访问;
  • 支持脚本中对字段值进行操作;
  • 用于排序、聚合等操作。

具体原理见下图:
Lucene FieldCache
但是这种方式有很大的不足:

  • 内存消耗大:常驻内存,大小是文档个数 * 字段类型大小;
  • 性能开销大:初始加载过程耗时,需要遍历倒排索引及类型转换。可能导致其他请求延迟;
  • 内存管理复杂:内存管理不够灵活,可能导致频繁的GC及性能问题。

Lucene 自 4.0 起提出了一个新的概念 DocValues, 允许用户在建索引的时候将相关字段的doc id 和字段值 之间的映射直接保存在索引文件中(磁盘)。因此,在 lucene4.0 中,Lucene 在根据doc id查询字段值时,首先会根据doc id 去对应DocValues中获取字段的值。如果 DocValues 中不存在,才会从倒排索引中进行类型转换,再加载到内存中,即FieldCache机制。也就是说,默认会使用 DocValues代替FieldCache机制。

PS:事实上,查了下,在 lucene5 的 API 中,FieldCache 已经被取消了,大家了解曾经有这么个机制即可
Lucene 4可以看到,Lucene 5就没有了。

2.2、DocValues

DocValues相比FieldCache机制,大大节约内存,获取字段值快;其大概结构如下:
| doc id |field value |
| 1 | 500 |
| 2 | 900 |
| 3 | 1500 |
| 4 | 100 |

使用方式:

PUT my-index
{"mappings": {"properties": {"status_code": { "type":  "keyword"  #默认开启status_code字段的doc value},"session_id": { "type":  "long","doc_values": false   #关闭session_id字段的doc value}}}
}
  • 生成时机: doc values 在文档被插入的时候与倒排索引同期生成,即Es 为了提高查询排序速度,插入文档时,同时把字段的值加入倒排索引中,也会把字段的值加入到doc values中(空间换时间)。
  • 存储位置:磁盘,使用时会被加载到内存。

通过下面案例可以清晰看到textannotated_text类型的字段不支持doc_vlues属性

[root@test ~]# curl -XPUT http://127.0.0.1:9200/test -H  'Content-Type: application/json' -d '{"mappings":{"properties":{"status_code":{"type":"text","doc_vlues":true},"session_id":{"type":"keyword","doc_values":false}}}}'
{"error":{"root_cause":[{"type":"mapper_parsing_exception","reason":"unknown parameter [doc_vlues] on mapper [status_code] of type [text]"}]}}"

Es 中textannotated_text类型的字段不支持doc_vlues属性,主要是因为Lucene中Docvalues机制保存的是字段原始值,并不支持分词后的结果;

为了更好地理解Es如何使用Lucene的DocValues机制,我们可以看一下相关API;

public class LuceneExample {public static void main(String[] args) throws Exception {// 创建一个内存目录来存储索引Directory directory = new RAMdirectory();// 使用标准分析器StandardAnalyzer analyzer = new StandardAnalyzer();// 配置IndexWriterIndexWriterConfig config = new IndexWritereConfig(analyzer);// 创建IndexWriterIndexWriter iwriter = new IndexWriter(directory, config);// 创建文档并添加字段Document doc = new Document();doc.add(new StringField("id", "1", Field.Store.YES));//字段id被建立倒排索引并存储其原始值,并且不会被分词doc.add(new TextField("title", "Lucene", Field.Store.YES));//字段title被建立倒排索引并存储其原始值,但会被分词doc.add(new TextField("content", "Hello Lucene,My is...", Field.Store.NO));//字段content被建立倒排索引但不存储其原始值,且会被分词doc.add(new LongField("createTime", 1672564789001L));//对字段createTime建立倒排索引doc.add(new StoredField("createTime", 1672564789001L));//存储字段createTime原始值doc.add(new StoredField("name", "Bob"));//只存储字段name原始值doc.add(new NumericDocValuesField(name, 1672564789001L));//对字段createTime建立docvaluesdoc.add(new SortedNumericDocValuesField("list", 3));//对字段list建立docvaluesdoc.add(new SortedNumericDocValuesField("list", 9));//对字段list建立docvalues// 将文档写入索引iwriter.addDocument(doc);// 关闭写入器iwriter.close();}
}

通过这个案例就可以想到我们就文档提交给Es后,Es也是这样调用Lucene的API来完成工作。Es会根据mapping中字段配置来组装doc:
案例中name和createTime字段的mapping配置如下:

{"name": {"type": "text""index": false,"store": true,"doc_values": false},"age": {"type": "long","index": true,"store": true,"doc_values": true}
}

Es在调用Lucene的API时,除了我们提交的字段,Es还会预留的系统字段,用作一些特殊的目的,这些字段对于Lucene来说与用户自定义的Field无任何区别,只不过Es根据这些系统字段不同的使用目的,定制有不同的索引方式,比如_uid、_source字段等,_uid字段就设置成会被索引,不需要分词,不需要被Store,不需要Docvalues。

看到这里就有些同学会问题,Lucene除了建立倒排索引,还有Store,Docvalues,三者都会存储字段值,如果都设置了,一个字段值岂不是被存了三份?
说来确实是这样,三者各自有不同的作用,倒排索引用于快速定位哪些文档包含搜索关键词,Store是为了查询时返回文档中字段原始值;Docvalues是为了查询时便于进行排序、聚合等操作。
因为Store是正排行式存储,便于一次获取文档中所有字段的值(单行多列);Docvalues是正排列式存储,便于快速获取单个字段多个值(单列多行)。
在实际业务中,根据业务需求和性能要求,灵活选择设置这些属性即可。

三、Fielddata

Fielddata是Es自身的特性,与Lucene无关,Es之所以会维护Fielddata,就是因为Lucene的Docvalues不支持textannotated_text类型的字段,本质是Docvalues只存储字段原始值,不支持存储字段值分词后的结果。

Fielddata是Es用来对textannotated_text类型的字段进行排序、聚合、脚本计算等操作的机制。默认关闭,此时无法对该类型字段进行排序等操作。

PUT my-index-000001/_mapping
{"properties": {"name": { "type":     "text","fielddata": true,   #字段name开启Fielddata特性"fielddata_frequency_filter": {"min": 0.001,"max": 0.1,"min_segment_size": 500}}}
}

只有text和annotated_text类型字段支持Fielddata特性。

  • 生成时机:使用时动态生成,懒加载;
  • 存储位置:JVM内存,不占用磁盘空间;

看到这里有同学会觉得Es的Fielddata和Lucene的FieldCache很相似,没错,很像,但:

  1. Es的Fielddata只支持text和annotated_text类型字段,Lucene的FieldCache支持所有;
  2. Es的Fielddata使用了更紧凑的内存表示,内存消耗更小;
  3. Es的Fielddata通过优化的数据结构,提高排序、聚合等操作;
  4. Es的Fielddata提供更灵活的内存管理方式,如用户通过fielddata_frequency_filter参数过滤掉低频词汇,节约内存。

Es通过Fielddata和Docvallues实现对所有字段排序、聚合等操作的支持。

Fielddata预加载

四、Index sorting

Index sorting属于Lucene特性。

Lucene 早期曾引入了一个离线的排序工具——IndexSorter。IndexSorter 把需要排序的索引完全复制了一份,将新的复制索引中的文档按用户指定的顺序重新排序。因为排序后的索引是一个新的索引,每次源索引中有新的数据更新,不得不重新执行一遍这个工具。IndexSorter 工具是第一次在索引写入阶段而不是查询阶段对文档进行排序的尝试。
针对索引预排序,社区提出了一个新的概念“early termination”。假设你要遍历出前N个文档,并且文档是按 date 字段排序的。如果索引存储在磁盘上时已经是有序的了,那么我们遍历出前N个文档就可以直接返回,而不需要遍历所有的文档。这就是我们所说的“early termination”。提早的返回查询结果,可以明显的缩短查询响应时间,特别是含有排序的查询。刚才介绍的离线排序的方案不能满足有大量文档更新的场景,这也是为什么最终离线排序方案会被其他方案取代。

Es排序定义如下:
单Field排序定义:

PUT events
{"settings" : {"index" : {  #按timestamp倒序"sort.field" : "timestamp","sort.order" : "desc" }},"mappings": {"properties": {"timestamp": {"type": "date"}}}
}

多字段排序定义:

PUT events
{"settings" : {"index" : {"sort.field" : ["timestamp","age"],"sort.order" : ["desc","desc"] }},"mappings": {"properties": {"timestamp": {"type": "date"},"age":{"type": "integer"}}}
}

Es Settings支持 Index Sorting 4个配置:

  1. index.sort.field:对应排序的Field,可指定一个或者多个Field,优先按第一个排序,相同的情况下在按后续的Field排序;
  2. index.sort.order:对应Field的排序规则,只能时asc或者时desc;
  3. index.sort.mode:当对应的Field是数组时,取Max或者Min的值作为排序的基准;
  4. index.sort.missing:当对应的Field为null时,排第一个还是最后一个。

索引排序是一个很不错的解决方案,但是只能以单一方式进行索引排序。索引排序并不适用于具有下列特征的排序查询:

  • 采用不同的排序标准,例如索引定义的是降序,你查询用的是升序;
  • 所用排序字段组合不同于索引排序定义中指定的组合。

我们这里也看看Lucene中的API:

IndexWriterConfig config = new IndexWritereConfig(analyzer);
SortField sf1 = new SortField("createTime",SortField.Type.LONG)
Sort sorts = new Sort(new SortField[]{sf1}) //支持多个字段
config.setIndexSort(sorts); //设置索引排序定义

可以看到Es就是根据用户的settings.index配置,进而转换成调用Lucene的这些API来完成工作的。

由于索引排序是在索引生成阶段完成的,根据测试不可避免的降低了写入性能,此功能最多可将写入速度降低 40-50%。由于排序是一个很耗时的操作,所以如果您最关注的是索引速度,则必须谨慎考虑是否要开启索引排序功能。
Es Index sort benchmark

Lucene针对索引排序做了很多优化,在新segment生成后刷盘时对段内所有文档进行一次排序,在segment merge时对所有段的文档进行排序(此时segment内文档有序,相当于合并有序数组 O(N))。

五、小结

通过前几章节的描述可知,Es排序是一个复杂的场景,在优化过程中不断地打补丁,不像MySQL,MongoDB依赖B+tree实现。主要分为:

  1. 实时排序:text类型字段依赖Fielddata机制,其他类型字段依赖Docvalues机制;
  2. 预排序:在创建索引时定义排序规则,一个索引只能定义一个,限制较大。

六、参考

1]:Es官网_source字段
2]:Es官网store属性
3]:Es官网doc_values属性
4]:Lucene官网FIELD对像
5]:Es Fielddata知识点梳理
6]:Es 优化排序查询
7]:Es 索引排序为人不知的优势-减少索引磁盘占用
8]:Es 索引预排序分析和英文版
9]:Lucene Fieldcache机制原理
10]:Lucene源码解析——DocValue存储方式
11]:Lucene 段合并原理

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/28306.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

内存分配器性能优化

背景 在之前我们提到采用自定义的内存分配器来解决防止频繁 make 导致的 gc 问题。gc 问题本质上是 CPU 消耗,而内存分配器本身如果产生了大量的 CPU 消耗那就得不偿失。经过测试初代内存分配器实现过于简单,产生了很多 CPU 消耗,因此必须优…

跨语言翻译的突破:使用强化学习与人类反馈提升机器翻译质量

在人工智能领域,知识问答系统的性能优化一直是研究者们关注的焦点。现有的系统通常面临知识更新频繁、检索成本高、以及用户提问多样性等挑战。尽管采用了如RAG(Retrieval-Augmented Generation)和微调等技术,但它们各有利弊&…

C++ 45 之 赋值运算符的重载

#include <iostream> #include <string> #include <cstring> using namespace std;class Students05{ public:int m_age;char* m_name;Students05(){}Students05(const char* name,int age){// 申请堆空间保存m_name;this->m_name new char[strlen(name)…

案例 采用Springboot默认的缓存方案Simple在三层架构中完成一个手机验证码生成校验的程序

案例 Cacheable 是 Spring Framework 提供的一个注解&#xff0c;用于在方法执行前先检查缓存&#xff0c;如果缓存中已存在对应的值&#xff0c;则直接返回缓存中的值&#xff0c;而不执行该方法体。如果缓存中不存在对应的值&#xff0c;则执行方法体&#xff0c;并将方法的…

免费下载全球逐日气象站点数据

环境气象数据服务平台近期升级了NOAA GSOD 全球逐日气象站点数据&#xff08;NOAA Global Surface Summary of the Day &#xff09;的检索方式&#xff0c;升级后&#xff0c;用户无需注册&#xff0c;即可以在平台上下载全球逐日气象站点数据。 检索方式&#xff1a; 1. 访…

Python学习打卡:day07

day7 笔记来源于&#xff1a;黑马程序员python教程&#xff0c;8天python从入门到精通&#xff0c;学python看这套就够了 目录 day753、列表的常用操作课后练习题54、列表的循环遍历列表的遍历—— while 循环列表的遍历—— for 循环while 循环和 for 循环的对比练习 55、元组…

3 高频小信号放大器

分类与质量指标 分类 质量指标 增益 电压与功率的放大倍数。 通频带 放大效果比较好的频率范围。 选择性 放大目标信号以滤除其他信号的综合能力。 稳定性 噪声系数 晶体管高频等效电路 混合Π等效电路 共射三极管的等效电路。 Y参数等效电路 混合Π与Y参数等效电路的转换 单…

蚂蚁集团:2023年科研投入211.9亿元

6月13日&#xff0c;蚂蚁集团发布2023年可持续发展报告。报告显示&#xff0c;2023年蚂蚁集团科研投入达到211.9亿元&#xff0c;再创历史新高&#xff0c;蚂蚁科技投入的重点是人工智能和数据要素技术。 蚂蚁集团董事长兼CEO井贤栋在报告致辞中说&#xff0c;面向未来&#x…

【LeetCode 动态规划】买卖股票的最佳时机问题合集

文章目录 1. 买卖股票的最佳时机含冷冻期 1. 买卖股票的最佳时机含冷冻期 题目链接&#x1f517; &#x1f34e;题目思路&#xff1a; &#x1f34e;题目代码&#xff1a; class Solution { public:int maxProfit(vector<int>& prices) {int n prices.size();ve…

NVIDIA Triton系列01-应用概论

NVIDIA Triton系列01-应用概论 推理识别是人工智能最重要的落地应用&#xff0c;其他与深度学习相关的数据收集、标注、模型训练等工作&#xff0c;都是为了得到更好的最终推理性能与效果。 几乎每一种深度学习框架都能执行个别的推理工作&#xff0c;包括 Tensorflow、Pytorc…

STL——set、map、multiset、multimap的介绍及使用

文章目录 关联式容器键值对树形结构与哈希结构setset的介绍set的使用set的模板参数列表set的构造set的使用set的迭代器使用演示 multisetmultiset演示 mapmap的定义方式map的插入map的查找map的[ ]运算符重载map的迭代器遍历multimapmultimap的介绍multimap的使用 在OJ中的使用…

tsp可视化python

随机生成点的坐标并依据点集生成距离矩阵&#xff0c;通过点的坐标实现可视化 c代码看我的这篇文章tsp动态规划递归解法c from typing import List, Tuple import matplotlib.pyplot as plt from random import randintN: int 4 MAX: int 0x7f7f7f7fdistances: List[List[in…

Codeforces Round 953 (Div. 2) A - C 题解

因为有事只做了A-C&#xff0c;都比较简单&#xff0c;全是很简单的思维&#xff0c;明天有空还会添加上D&#xff0c;如果有人需要可以明天常来看看&#xff01; 进入正题&#xff1a; A. Alice and Books 题意&#xff1a;给你n个数字&#xff0c;将这些数字分到两堆里&am…

@AliasFor 使用

AliasFor 使用 AliasFor 它允许开发者为一个注解的属性指定别名。通过使用AliasFor&#xff0c;我们可以提供多个名称来引用同一属性&#xff0c;从而增加了代码的灵活性和可读性 定义一个注解 package com.example.demo.aspect;import org.springframework.core.annotation…

asyncio协程提高执行效率

from fastapi import FastAPI import asyncioapp FastAPI()async def task1():# 模拟执行任务1print("开始执行任务1")await asyncio.sleep(1)print("结束执行任务1")return "Result from Task 1"async def task2():# 模拟执行任务2print("…

论文阅读笔记(通道注意力)

论文阅读笔记&#xff08;通道注意力&#xff09; 摘要Abstract1. SENet1.1 研究背景1.2 创新点1.3 SE块的构建过程1.3.1 注意力和门机制1.3.2 SE块具体运行过程1.3.3 通道间依赖关系的提取1.3.4 自适应重新校正(Excitation) 1.4 SE结合先进架构的灵活应用1.5 实验1.6 模型的实…

软考初级网络管理员__操作系统单选题

1.使用Windows提供的网络管理命令(请作答此空)可以查看本机的路由表&#xff0c;()可以修改本机的路由表。 tracert arp ipconfig netstat 2.在Windows 的命令行窗口中键入命令C:\>nslookupset type MX>202.30.192.2这个命令序列的作用是查询()。 邮件服务器信息 …

视频信号发生器上位机

在液晶屏测试、电视机信号测试、视频处理器测试中&#xff0c;经常需要使用视频信号发生器&#xff0c;市场上专业的视频信号发生器通常需要大几千元&#xff0c;多则上万元&#xff0c;而且设备测试仪器是一套硬件&#xff0c;没有办法像软件一样复制传播。所以我开发了一套基…

抖音混剪素材哪里找?可以混剪搬运视频素材网站分享

在抖音上制作精彩的视频离不开高质量的素材资源。今天&#xff0c;我将为大家推荐几个优质的网站&#xff0c;帮助你解决素材短缺的问题。这些网站不仅提供丰富的素材&#xff0c;还符合百度SEO优化的规则&#xff0c;让你的视频更容易被发现。 蛙学府素材网 首先要推荐的是蛙…

需求:如何给文件添加水印

今天给大家介绍一个简单易用的水印添加框架&#xff0c;框架抽象了各个文件类型的对于水印添加的方法。仅使用几行代码即可为不同类型的文件添加相同样式的水印。 如果你有给PDF、图片添加水印的需求&#xff0c;EasyWatermark是一个很好的选择&#xff0c;主要功能就是传入一…