首页
学习
活动
专区
工具
TVP
发布

Elasticsearch搜索引擎性能调优看懂这一篇就够了

1

写优化

对于写索引负载很高但是对搜索性能要求不是很高的场景如日志搜索,采用优秀的写索引策略就显得非常重要了。可以尝试以下几种方法来提升写索引的性能。

1.批量提交

当有大量的写任务时,使用批量提交是种不错的方案。但是每次提交的数据量为多大时能达到最优的性能,受文件大小、数据类型、网络情况、集群状态等因素的影响。通用的策略如下。

(1)批量提交的数据条数一般是根据文档的大小和服务器性能而定的,但是一次批处理的数据大小应从5MB~15MB开始,逐渐增加,直到性能没有提升时才结束。

(2)在通过增加一次提交的数量而效果没有显著提升时,则需要逐渐增加并发数,并且需要使用监控工具如Marvel来监控服务器的CPU、I/O、网络、内存等资源的使用情况。

(3)若抛出EsRejectedExecutionException错误,则表明集群已经没有处理能力了,说明至少有一种资源已经到达瓶颈,这时需要升级已经到达瓶颈的资源或者增加集群的节点。

2.优化存储设备

在现代服务器上,相对于CPU和内存,磁盘是限制服务器性能的最大瓶颈,并且Elasticsearch是一种密集使用磁盘的应用,特别是在段合并的时候对磁盘要求较高,所以磁盘速度提升之后,集群的整体性能会大幅提高。这里对磁盘的选择提供以下几点建议。

(1)条件允许的话,强烈建议使用固态硬盘(Solid State Disk)。SSD相比于机械磁盘具有超高的读写速度和稳定性。

(2)使用RAID0。RAID0可以提升磁盘写入的速度,因为我们集群中的副本分片已经提供数据备份的功能,所以没有必要再使用镜像或者奇偶校验的RAID。

(3)在Elasticsearch的服务器上挂载多块硬盘。并且在Elasticsearch的配置文件elasticsearch.yml中设置多个存储路径,例如:path.data: /path/to/data1,/path/to/data2。这样可以在多块硬盘上同时进行读写操作。

(4)不要使用类似于NFS(Network File System)的远程存储设备,因为这些设备的延迟对性能的影响是很大的。

3.合理使用段合并

Lucene是以段的形式存储数据的,每当有新的数据写入索引时,就会自动创建一个新的段,所以在一个索引文件中包含了多个段。随着数据量的不断增加,段的数量会越来越多,需要消耗的文件句柄数及CPU就越多,查询时的负担就越重。

Lucene后台会定期进行段合并,但是段合并的计算量庞大,会消耗大量的I/O,所以Elasticsearch默认采用较保守的策略,如下所述。

(1)当段合并的速度落后于索引写入的速度时,为了避免出现堆积的段数量爆发,Elasticsearch会把写索引的线程数量减少到1,并打印出“now throttling indexing”这样的INFO级别的“警告”信息。

(2)为了防止因段合并影响了搜索的性能,Elasticsearch默认对段合并的速度进行限制,默认是20m/s。但是,如果使用的是SSD,就可以通过下面的命令将这个限制增加到100m/s:

如果只考虑写的性能而完全不考虑查询的性能,则可以通过下面的命令关闭限制:

在设置限流类型为none时将彻底关闭合并限流。但当导入完成时,则一定要把none再改回merge,重新打开限流。

4.减少refresh的次数

我们知道,Lucene在新增数据时,为了提高写的性能,采用的是延迟写入的策略,即将要写入的数据先写到内存中,当延时超过1秒(默认)时,会触发一次refresh,refresh会把内存中的数据以段的形式刷新到操作系统的文件缓存系统中。我们知道,在数据以段的形式刷新到文件缓存系统后才可以搜索,所以如果对搜索的时效性要求不高,则可以增加延时时间,比如30秒,这样还可以有效地减少段的数量,为后面的段合并减少压力。但这同时意味着需要消耗更多的Heap内存和搜索延时。可以通过配置index.refresh_interval:30s来减少refresh的次数。当然,也可以将其设置为-1,临时关闭refresh,但是在数据导入完成后一定要把该值再改回来。

5.减少flush的次数

但是这样的设置意味着在文件缓存系统中要存储更多的数据,所以要减少Heap的内存空间,为操作系统的文件缓存系统留下更多的空间。

6.减少副本的数量

Elasticsearch的副本虽然可以保证集群的可用性,同时可以增加搜索的并发数,却会严重降低写索引的效率。因为在写索引时需要把整个文档的内容都发给副本节点,而所有的副本节点都需要把索引过程重复进行一遍。这意味着每个副本也会执行分析、索引及可能的合并过程。

如果是大批量导入,则考虑先设置index.number_of_replicas:0关闭副本,在写入完成后再开启副本,恢复的过程本质上只是一个从字节到字节的网络传输。

2

读优化

1.避免大结果集和深翻

在5.2.5节中讲到了集群中的查询流程,例如,要查询从from开始的size条数据,则需要在每个分片中查询打分排名在前面的from+size条数据。协同节点收集每个分片的前from+size条数据。协同节点将收集到的n×(from+size)条数据合并起来再进行一次排序,然后从from+1开始返回size条数据。

如果在from、size或者n中有一个很大,则需要参加排序的数量也会很大,这样的查询会消耗很多CPU资源,并且效率也很低。

为了解决这种问题,Elasticsearch提供了scroll和scroll-scan这两种查询方式。

1)scroll

与search请求每次返回一页数据不同,scroll是为检索大量的结果(甚至所有的结果)而设计的,比如,我们有一个批量查询的需求,要查询1~100页的数据,每页有100条数据,如果用search查询,则每次都要在每个分片上查询得分最高的from+size条数据,然后协同节点把收集到的n×(from+size)条数据合并起来再进行一次排序。接着从from+1开始返回size条数据,并且要重复100次,随着from的增大,查询的速度越来越慢。

但scroll的思路是:在各个分片上一次查询10000条数据,协同节点收集n×10000条数据,然后合并、排序,将排名前10000的结果快照起来,最后使用类似数据库游标的形式逐次获得部分数据。这种做法的好处是减少了查询和排序的次数。

Scroll初始查询的命令是:

该查询语句的含义是,在blog索引的blog type里查询title包含“lucene”的所有数据。scroll=1m表示下次请求的时间不能超过1分钟(这里是下次请求而不是全部请求完的时间);size表示这次和后续的每次请求一次返回的数据条数。在这次查询的结果中除了返回了查询到的结果,还返回了一个scroll_id,它是下次请求的参数。

再次请求的命令如下:

因为这次并没有到分片里查询数据,而是直接在生成的快照里面以游标的形式获取数据,所以这次查询并没有包含index name和type的名字,也没有具体的查询语句。

"scroll": "1m"指下次请求的时间不能超过1分钟,而不是快照的保存时间。

scroll_id是上次查询时返回的,通过这次查询提交会重新返回一个新的scroll_id,供下次查询使用。

2)scroll-scan

scroll通过使用“快照”保存了要返回的结果,减少了查询和排序的次数,但是在初次查询时需要进行文本相似度计算和排序,这个过程也是比较耗时的。scroll-scan是一种更高效的大数据量查询方案。其思路和使用方式与scroll非常相似,但是scroll-scan关闭了scroll中最耗时的文本相似度计算和排序,使得性能更加高效。

scroll-scan的查询方式和scroll非常相似,只是增加一个search_type=scan参数来告诉Elasticsearch集群不需要文本相似计算和排序,只是按照数据在索引中的顺序返回结果集:

虽然scroll和scroll-scan的实现思路和使用方式很像,但也有区别,如下所述。

(1)scroll-scan不做文本相识度计算,不排序,按照索引中的数据顺序返回。

(2)scroll-scan不支持聚合操作。

(3)scroll-scan的参数size控制的是每个分片上的请求的结果数量,如果有n个分片,则每次返回n×size条数据。而scroll每次返回size条数据。

虽然scroll-scan的查询性能更高效,但是它已经在Elasticsearch 2.10版本中被废弃了,并且在Elasticsearch 5以后的版本中被移除。但是,我们还可以通过scroll的命令实现跟scroll-scan命令性能一样的查询,只是需要在scroll查询的后面添加对_doc的排序,命令如下:

需要注意的是,scroll每次查询的是快照里的数据,而不是Elasticsearch集群里的实时数据,在快照生成后,Elasticsearch集群中的数据变更不影响快照中的数据。

段合并是通过把多个小的分段合并成一个更大的分段来优化索引的,在生成大段的同时会删除合并过的小段。但是,如果scroll还在进行中,就有可能有旧的小段还在使用中,所以小段在这时是不会被删除的,这就意味着有可能会消耗更多的文件句柄。所以,虽然scroll有个超时时间,但是如果能够确认不在使用中,则还是要显式地清除。清除的命令如下:

2.选择合适的路由

在多分片的Elasticsearch集群中,对搜索的查询大致分为如下两种。

(1)在查询条件中包含了routing信息。即查询时可以根据routing信息直接定位到其中的一个分片进行查询,而不需要查询所有的分片,再经过协调节点二次排序。如图5-24所示。

图5-24

(2)如果在查询条件中不包含routing,在查询时就不知道要查询的数据具体在哪个片上,所以整个查询主要分为Scatter、Gather两个过程。

Scatter(分发):在请求到达协调节点后,协调节点把查询请求分发到每个分片上。

Gather(聚合):协调节点搜集在每个分片上完成的搜索结果,再将搜集的结果集进行重新排序,返回给用户请求的数据。如图5-25所示。

图5-25

通过对比上述两种查询流程,我们不难发现,使用routing查找的性能要好很多。所以我们在设计Elasticsearch方案时要合理地利用routing来提升搜索性能。比如,在大型的本地分类网站中,可以考虑通过将城市id作为routing的条件,来作为分片的依据。默认的公式如下:

其中,routing默认使用索引的唯一标识“_id”。但是,如果使用其他字段作为分片的依据,就要注意分片不均匀的情况。比如,根据城市id进行分片时,大型城市的分片上的数据过多,而小城市的分片上的数据太少,导致分片严重不均衡。这时就可以通过修改上述公式来保证分片的均匀,比如把多个小城市的数据合并到一个分片上。

3.SearchType

在Scatter、Gather的过程中,节点间的数据传输和打分(SearchType)有以下4种组合方式,可以根据不同的场景选择一种合适的方式。

(1)QUERY_THEN_FETCH。Elasticsearch默认的搜索方式分两步完成一次搜索:第1步,先向所有的分片发出请求,各分片只返回文档的相似度得分和文档的id,而不需要文档的详细信息,然后协调节点按照各分片返回的分数进行重新排序和排名,再取出需要返回给客户端的size个文档id;第2步,在相关的分片中取出文档的详细信息并返回给用户。这种方式虽然有部分数据需要两次查询分片,但是可以节约很大一部分的带宽。

(2)QUERY_AND_FETCH:协调节点向所有分片都发出查询请求,各分片返回数据时将文档的相似度得分和文档的详细信息一起返回;然后,协调节点按照各分片返回的分数进行重新排序,再取出需要返回给客户端的size个文档,将其返回给客户端,这种查询方法只需要在分片中查询一次,所以性能是最好的,但是各分片返回结果的大部分详情信息没有用处,会浪费很大的带宽。

(3)DFS_QUERY_THEN_FETCH:与QUERY_THEN_FETCH类似,但与QUERY_THEN_ FETCH相比,它包含一个额外的阶段:在初始查询中执行全局的词频计算,以使得更精确地打分,从而让查询结果更相关。也就是说,QUERY_THEN_FETCH使用的是分片内部的词频信息,而DFS_QUERY_THEN_FETCH由于需要访问公共的词频信息,所以要比直接查询本分片的词频信息性能更差。

(4)DFS_QUERY_AND_FETCH:与QUERY_AND_FETCH类似,只是使用的是全局的词频。

4.定期删除

由于在Lucene中段具有不变性,所以删除一个文档后不会立即从硬盘中删除该文档,而是产生一个.del文件专门记录被删除的文档。而在检索的过程中,被删除的文件还会参与检索,只不过最后会被过滤,如果被删除的文件太多,则也会影响查询的效率。我们可以在机器空闲时通过如下命令定期删除这些文件,来提升查询的效率:

该命令只是合并有数据删除的段,而不是合并全部的段。

3

堆大小的设置

Elasticsearch默认的堆内存大小是1GB,由于Elasticsearch是一个比较耗内存的应用,所以对于大部分应用来说,这个值太小。我们可以通过一些方式来改变堆内存的大小。如果是通过解压安装包安装的Elasticsearch,则在Elasticsearch安装包下的config文件夹中包含一个jvm.option文件,打开该文件,添加如下命令来设置Elasticsearch的堆大小:

或者

该命令表示堆的初始大小(Xms)和可分配的最大内存(Xmx)都是5GB。建议在设置堆大小时让初始大小和最大可分配的值一样,这样就可以避免在运行时因为改变堆内存的大小而导致系统资源浪费。

也可以通过设置环境变量的方式设置堆的大小。比如:

在启动Elasticsearch时设置堆的大小:

这种设置方式并不是一劳永逸的,在每次启动Elasticsearch时都需要添加–Xmx5g –Xms5g参数。

如果服务器有足够多的内存,那么是否给堆内存分配的内存越大越好?虽然内存对Elasticsearch来说是非常重要的,但是答案是否定的!因为Elasticsearch堆内存的分配要考虑以下两个原则。

(1)最好不要超过物理内存的50%。因为Elasticsearch底层是Lucene实现的。由于Lucene段的不变性,所以我们不用考虑数据的变化,这对缓存来说是非常友好的。操作系统可以把这些文件缓存在操作系统的文件缓存系统(Filesystem Cache)中而非堆内存中,如果我们设置的堆内存过大,导致系统可用的内存太小,就会严重影响Lucene的全文本查询性能。

(2)堆内存的大小最好不要超过32GB。在Java中,所有对象都分配在堆上,并且每个对象头都通过一个Klass Pointer指针指向它的类元数据,而这个指针在64位的操作系统上为64位,在32位的系统上为32位。32位的操作系统的最大寻址空间为4GB(232),64位的操作系统可以使用更多的内存(264)。但是在64位的操作系统上,因为指针本身变大了,所以会有更大的空间浪费在指针本身上,更糟糕的是,更大的指针在主内存和各级缓存(例如LLC、L1等)之间移动数据时,会占用更多的带宽。

Java使用内存指针压缩(Compressed Oops)技术来解决这个问题。它的指针不再表示对象在内存中的精确位置,而是表示偏移量。这意味着32位的指针可以引用4GB个Byte,而不是4GB个bit。也就是说,堆内存为32GB的物理内存,也可以用32位的指针表示。

所以,在越过那个神奇的边界——32GB时,指针就会切回为普通对象的指针,每个对象的指针都变长了,就会浪费更多的内存,降低了CPU的性能,还要让GC应对大的内存。事实上,当内存到达40~50GB时,有效的内存才相当于使用内存对象指针压缩技术时的32GB内存,所以在大内存的服务器上设置的堆大小要小于32GB,比如可以设置为31GB:

虽然32GB是一个很重要的分割线,但是随着硬件成本的下降,现在有大内存的服务器愈发常见,比如一台有128GB内存的服务器。这时我们需要根据以下业务场景来考虑内存的分配情况。

(1)如果业务场景是以全文检索为主的,则依然可以给Elasticsearch分配小于32GB的堆内存,而把剩下的大部分内存空间留给Lucene,让Luccene通过操作系统的文件缓存系统来缓存更多的segment,使Lucene带来极速的全文检索。

(2)如果在业务场景中有很多的排序和聚合,而且大部分聚合计算是在数字、日期、地理点等非分词的字符串上的,则聚合计算将在内存友好的doc values(非堆内存)上完成!我们依然可以为Elasticsearch分配小于32GB的堆内存,其余部分为操作系统的缓存使用doc values。

(3)如果在业务场景中有很多排序和聚合,并且是在分词的字段上进行的,则不幸的是,我们需要fielddata来缓存。但是和doc values不同,fielddata是分配在堆内存上的,这时就需要分配更多的堆内存了,但是让一个节点拥有太大的堆内存,并不是一种明智的选择。可以考虑在同一台服务上部署多个节点,使得每个节点的内存分配不超过32GB,不会有太多的资源浪费。

4

服务器配置的选择

在选择Elasticsearch服务器时,要尽可能地选择与当前业务量相匹配的服务器。如果服务器配置得太低,则意味着需要更多的节点来满足需求,一个集群的节点太多时会增加集群管理的成本。如果服务器配置得太高,则选择一台服务器部署多个节点的方案通常会导致资源使用不均衡(比如内存耗尽,而CPU使用率很低),导致资源浪费,而且在单机上运行多个节点时,也会增加逻辑的复杂度。

另外,请关掉swap。我们都知道,在计算机中运行的程序均需由内存执行,若执行的程序占用的内存很大或很多,则会导致内存消耗殆尽。为了解决该问题,操作系统使用了一种叫作虚拟内存的技术,即匀出一部分硬盘空间来充当内存使用。当内存耗尽时,操作系统就会自动调用硬盘来充当内存,并把内存中暂时不使用的数据交换到硬盘中,在再次使用时从硬盘交换到内存。

虽然这种非常成功的方式“扩大”了内存,缓解了内存过小带来的压力,但是由于数据在内存和磁盘之间来回交换对服务器的性能来说是致命的。所以为了使Elasticsearch有更好的性能,强烈建议大家关闭swap。

关闭swap的方式如下。

(1)暂时禁用。在Linux服务器上执行如下命令就可以暂时关闭,但在服务器重启后失效:

(2)永久性关闭。在/etc/sysctl.conf(不同的操作系统路径有可能不同)中增加如下参数:

swappiness的值是0-100的整数。数字越大,则表示越倾向于使用虚拟内存。系统默认为60,但是并不建议把该值设置为,因为设置为时,在某些操作系统中有可能会触发系统级的OOM-killer,例如在Linux内核的内存不足时(Out of Memory),为了防止系统的崩溃,需要强制杀掉(killer)一个“bad”进程。这里的“bad”进程,表示占用内存最多的那个进程。

(3)在Elasticsearch中设置。如果上述两种方式都不合适,则可以在Elasticsearch的conf文件下的elasticsearch.yml文件中添加如下命令。bootstrap.mlockall为true表示让JVM锁住内存,禁止内存的交换:

5

硬盘的选择和设置

对于Elasticsearch集群来说,硬盘的性能是非常重要的,特别是对于有大量写请求的业务场景(例如存储日志数据的集群)。因为硬盘一般是服务器上最慢的子系统,这意味着那些写入量很大的集群很容易让硬盘饱和,使它成为集群的瓶颈。

所以,如果条件允许,则请尽可能地使用SSD,它的读写性能将远远超出任何旋转介质的硬盘(如机械硬盘、磁带等)。基于SSD的Elasticsearch集群节点对于查询和索引性能都有提升。

磁盘阵列(RedundantArray of Independent Disks,RAID)是一种把多块独立的磁盘按照不同的方式组合起来形成的一个磁盘组,提供了比单个磁盘更好的存储性能和数据备份技术。我们将组成磁盘阵列的不同方式称为RAID级别(RAID Level)。在提供数据镜像的磁盘阵列中,用户的数据一旦发生了损坏,则可以利用备份信息把损坏的数据恢复,从而保障了用户的数据的安全性。在用户看来,这个组成的硬盘组就像一个硬盘,用户可以对它进行分区、格式化等。总之,对磁盘阵列的操作与单个硬盘一模一样。不同的是,磁盘阵列的存储速度要比单个硬盘快很多,而且可以提供自动数据备份。

RAID技术经过不断的发展,现在已拥有了从RAID 0到RAID 6这7种基本的RAID级别。另外,还有一些基本的RAID级别的组合形式,例如RAID10(为RAID 0与RAID 1的组合)、RAID50(为RAID 0与RAID 5的组合)等。不同的RAID级别代表不同的存储性能、数据安全性和存储成本,但最常用的是以下几种。

RAID 0:最少需要两块磁盘,其原理是把连续的数据分散到多个磁盘上进行读写操作,这样,系统有数据请求时就可以被多个磁盘并行执行,每个磁盘执行属于它自己的那部分数据请求。这种数据上的并行操作可以充分利用总线的带宽,显著提升磁盘的整体存取性能。因为这种模式没有冗余数据,不做备份,任何一块磁盘损坏都无法运行,所以拥有n块磁盘(同类型)的阵列在理论上读写速度是单块磁盘的n倍,但风险性也是单一硬盘的n倍。所以,RAID0是磁盘阵列中存储性能最好的,但也是安全系数最小的,所以这种模式适用于对安全性要求不高的大批量写请求场景。

RAID 1:通过磁盘数据镜像(备份)实现数据冗余,在成对的独立磁盘上产生互为备份的数据。因为RAID1在写数据时,需要分别写入两块硬盘中并做比较,所以在RAID 1模式下写数据比RAID 0慢很多,并且两块硬盘仅能提供一块硬盘的容量,所以也会造成很大的资源“浪费”。但是在读数据时,可以在两块磁盘的任意一块中读取,可提升读的性能,数据的安全性也会大大提升,因为只要有一对磁盘没有同时损坏,就可以正常使用。

RAID 10:为RAID0和RAID 1的组合模式,至少需要4块磁盘,既有数据镜像备份,也能保证较快的读写速度。缺点是成本较高。

RAID 5:至少需要3块磁盘,不对数据进行备份,而是把数据和与其对应的奇偶校验信息存储到组成RAID 5的各个磁盘上,并且奇偶校验信息和对应的数据分别被存储在不同的磁盘上。当RAID5的一个磁盘数据发生损坏时,可利用剩下的数据和相应的奇偶校验信息去恢复被损坏的数据。所以可以将RAID 5理解为RAID 0和RAID1的折中方案。RAID 5可以为系统提供数据安全保障,但保障程度要比RAID 1低,磁盘空间利用率要比RAID1高。RAID 5具有和RAID 0相近的数据读取速度,只是多了一个奇偶校验信息,写入数据的速度比对单个磁盘进行写入操作稍慢。同时,由于多个数据对应一个奇偶校验信息,所以RAID 5的磁盘空间利用率要比RAID 1高,存储成本相对较低,是目前运用较多的一种解决方案。

因为在Elasticsearch集群中分片一般都有备份,并且Elasticsearch的原始数据都来自于关系型数据库或者日志文件,所以数据的安全性显得并不是那么重要。所以无论是使用固态硬盘还是使用机械硬盘,我们都建议将磁盘的阵列模式设置为RAID 0,以此来提升磁盘的写性能。

6

接入方式

Elasticsearch提供了Transport Client和NodeClient这两种客户端的接入方式,这两种方式各有利弊,分别对应不同的应用场景。

1.TransportClient

TransportClient(传输客户端)作为一个集群和应用程序之间的通信层,是集群外部的,和集群是完全解耦的。正是因为与集群解耦,所以它们在连接集群和销毁连接时更加方便和高效,也适合大批量的客户端连接。所以,Transport Clien是一个轻量级的客户端,但是执行性能会比节点客户端差一些。

2.NodeClient

Node Client(节点客户端)把应用程序当作一个集群中的Client节点(非Data和Master节点)。因为它是集群内部的一个节点,所以知道整个集群的状态、所有节点的分布情况、分片的分布状况等,这意味着它有更高的执行效率。

虽然Node Client有更好的性能,但由于它是集群的一部分,所以在接入和退出时会比较复杂,会影响整个集群的状态,所以Node Client更适合需要持久地连接到集群的少量客户端,以提供更好的性能。

7

角色隔离和脑裂

1.角色隔离

由于节点默认的是node.master和node.data都为true,所以我们往往会让Master节点也担当了Data节点的角色。Elasticsearch集群中的Data节点负责对数据进行增、删、改、查和聚合等操作,所以对CPU、内存和I/O的消耗很大,有可能会对Master节点造成影响,从而影响整个集群的状态。

在搭建集群时,特别是比较大的集群时,我们应该对Elasticsearch集群中的节点做角色上的划分和隔离。我们通常使用几个配置比较低的虚拟机来搭建一个专门的Master集群。在集群中做角色隔离是一件非常简单的事情,只需在节点的配置文件中添加如下配置信息即可。

候选主节点:

数据节点:

最后形成如图5-26所示的逻辑划分。

图5-26

2.避免脑裂

本文节选自《可伸缩服务架构:框架与中间件》一书,作者:李艳鹏、杨彪、李海亮、贾博岩、刘淏。

扫二维码,持续关注

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20181129B06RAO00?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券