资讯详情

资讯详情

Lucene全文检索引擎实战:倒排索引、中文分词与搜索模块搭建

很多朋友第一次听Lucene都会下意识觉得它是一个“搜索引擎”装上之后就能像使用网站一样搜索资料。实际上完全不是这么回事。Lucene是一个Java编写的全文检索引擎库它不做网络服务不提供界面不负责分布式部署它只负责一件事把你的数据做成索引然后以极快的速度查出来。你可能没用过它但你用的很多搜索功能背后都有它的影子。这篇教程我打算直接从实际应用出发带你把它跑起来再把索引、分词、查询这些核心概念讲透最后给出一套可以直接复用的搜索模块写法。这篇内容适合谁看如果你正准备给自己的项目增加站内搜索或者在学搜索引擎原理又或者你未来要去用Lucene之上封装的搜索服务分布式搜索方案本身也是基于Lucene内核那么这篇教程都帮得上忙。我会把一些容易踩坑的地方比如中文分词、索引更新、分页性能、高亮显示这些尽量讲明白。1. Lucene到底是什么——先弄清定位再动手1.1 图书馆类比索引卡片与倒排索引有一次我给一个新手同事解释Lucene打了一个比方你走进一个没有检索系统的图书馆想知道哪几本书里提到“蜜蜂养殖”只能一本一本地翻这就是数据库的LIKE查询而Lucene做的事相当于图书馆在每本书入库时先把所有词拆出来记在一套卡片柜里每张卡片上写着“这个词出现在哪些书的第几页”查的时候直接翻卡片柜。这个“卡片柜”就是Lucene里的倒排索引。它和你原始数据的关系是独立的可以单独放在磁盘目录也可以放在内存里。你构建好索引之后搜索的是索引不是原始数据。这也是为什么Lucene能秒级返回结果因为它的查找路径不是全表扫描而是在一部已经排好序的“词典”里定位。很多人刚接触Lucene时容易拿它和数据库比。数据库擅长的是事务、关系约束、灵活的联合查询但遇到“某个词出现在大量文本的任何位置”这种需求数据库往往要靠模糊匹配每个词都要扫一遍数据量上来之后性能下降得非常快。Lucene恰恰相反它在写入阶段多花了一些时间做分词和索引把“查询时遍历”的成本转移到“写入时预处理”换来了查询时的巨大速度优势。1.2 什么时候该选Lucene什么时候该绕开Lucene毕竟是一个库不是一个开箱即用的服务。我在项目里见过两种极端一种是数据量只有几千条却强行引入Lucene结果索引维护成本比查询省下的时间还多另一种是数据量大到需要分布式部署却想用单机Lucene硬扛。拿实际场景来说如果你的数据量在百万级、千万级单机内存和磁盘扛得住需求是站内搜索、文档检索、日志查看那么直接使用Lucene是很快捷的。但如果你需要多节点扩容、需要RESTful API、需要在线扩容这时候直接用Lucene就不太合适了因为Lucene本身没有集群的概念每个索引目录都是独立的。分布式搜索服务把Lucene当作内核替你解决了分片、复制、协调这些分布式问题但它也引入了一套独立的部署和运维体系复杂度完全是另一个量级。所以我的建议是如果项目刚起步数据量不大团队又熟悉Java直接嵌入Lucene是最可控的方案。如果已经明确未来数据量和并发会涨到单机撑不住那么从一开始就考虑分布式搜索服务架构但学习和调试Lucene内部的原理对理解这类系统依然很重要——毕竟所有上层搜索服务的索引文件格式、分片机制底层都是Lucene在做实际工作。理解Lucene相当于拿到了排查一切搜索问题的底牌。2. 快速搭建Lucene开发环境2.1 JDK、依赖和目录规划Lucene是用Java写的所以第一个前置条件就是JDK。目前主流的Lucene 9.x要求JDK 11以上我本地习惯直接用JDK 17兼容性没什么问题。如果你的项目还在用JDK 8建议要么升级JDK要么选Lucene 8.x版本Lucene 8.x最高支持到JDK 8只是部分新API和新特性用不上。引入依赖时不需要像安装MySQL那样去配置服务它就是一个普通的Java库通过Maven或Gradle引入即可。最小需要两个模块dependency groupIdorg.apache.lucene/groupId artifactIdlucene-core/artifactId version9.8.0/version /dependency dependency groupIdorg.apache.lucene/groupId artifactIdlucene-queryparser/artifactId version9.8.0/version /dependencylucene-core是核心包索引和搜索的基础API都在里面。lucene-queryparser负责把用户输入的那串查询表达式解析成Lucene内部的Query对象。后面如果要做中文分词加一个lucene-analysis-common要做高亮加一个lucene-highlighter。模块化之后的好处是用多少引多少不会一上来就把所有功能都拖进来。目录规划上建议把索引目录和程序运行目录分开。比如项目根目录下建一个index文件夹专门存放索引文件然后在.gitignore里把它忽略掉因为索引属于运行时生成的数据不应该进版本库。如果你的索引需要频繁大批量重建还可以在代码里配置每次启动时先清空目录再写入避免旧数据和新数据混在一起。2.2 最小可运行的“建索引搜索”代码光说不练没有用。我第一次跑通Lucene时写了一个特别简单的示例差不多二十几行代码却把最核心的流程全走了一遍。先看建索引的代码import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.document.TextField; import org.apache.lucene.index.IndexWriter; import org.apache.lucene.index.IndexWriterConfig; import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; import java.nio.file.Paths; public class HelloLuceneIndex { public static void main(String[] args) throws Exception { // 1. 指定索引存放目录 Directory directory FSDirectory.open(Paths.get(index)); // 2. 定义分词器 Analyzer analyzer new StandardAnalyzer(); // 3. 构建IndexWriter配置 IndexWriterConfig config new IndexWriterConfig(analyzer); // 4. 创建IndexWriter IndexWriter writer new IndexWriter(directory, config); // 5. 构建文档并写入 Document doc1 new Document(); doc1.add(new TextField(title, Lucene教程, Field.Store.YES)); doc1.add(new TextField(content, Lucene是一个Java全文检索引擎库, Field.Store.YES)); writer.addDocument(doc1); writer.commit(); writer.close(); System.out.println(索引写入完成); } }然后是搜索的代码import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.analysis.standard.StandardAnalyzer; import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.IndexReader; import org.apache.lucene.queryparser.classic.QueryParser; import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.Query; import org.apache.lucene.search.ScoreDoc; import org.apache.lucene.search.TopDocs; import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; import java.nio.file.Paths; public class HelloLuceneSearch { public static void main(String[] args) throws Exception { Directory directory FSDirectory.open(Paths.get(index)); IndexReader reader DirectoryReader.open(directory); IndexSearcher searcher new IndexSearcher(reader); Analyzer analyzer new StandardAnalyzer(); QueryParser parser new QueryParser(content, analyzer); Query query parser.parse(全文检索); TopDocs topDocs searcher.search(query, 10); System.out.println(命中数量: topDocs.totalHits.value); for (ScoreDoc scoreDoc : topDocs.scoreDocs) { Document doc searcher.storedFields().document(scoreDoc.doc); System.out.println(doc.get(title)); } reader.close(); } }这两个类写完分别运行如果控制台输出了“索引写入完成”然后搜索时输出了《Lucene教程》说明整个环境已经通了。这段代码里值得注意的地方有几处。第一IndexWriter在创建时会获取索引目录的写锁同一个时刻不能有两个进程同时往同一个索引目录写数据否则会抛出LockObtainFailedException。第二commit方法不是非得每次都调IndexWriter在close的时候内部也会提交但如果你的程序是长驻服务每批写入后commit一次更安全。第三IndexSearcher内部使用的IndexReader在索引被更新后必须重新打开也就是说如果你在运行过程中往索引里加了新文档原来的IndexSearcher是看不到新数据的这一点特别容易在实际项目中忽略。3. 核心概念逐个拆解索引、分词、查询3.1 倒排索引为什么比模糊查询快这么多前面我用图书馆卡片柜打了比方这里再往深一层讲。Lucene的倒排索引结构可以理解为两个主要部分词典和倒排表。词典里记录着所有出现过的词项每个词项都指向一个包含它的文档ID列表这个列表就是倒排表。举例来说你索引了三篇文档第一篇内容是“红色苹果”第二篇是“青苹果”第三篇是“红色汽车”。经过分词之后词典里会有“红色”出现在文档1、3、“苹果”文档1、2、“青”文档2、“汽车”文档3这些词项。当用户搜索“苹果”时Lucene在词典里定位到“苹果”直接拿到文档1和2的ID。这个过程对词典进行的是二分查找复杂度是O(log n)对倒排表是按照文档编号顺序读取简直快到离谱。它比数据库模糊查询快在哪里数据库的LIKE %苹果%在最坏情况下要遍历整张表的每一行挨个做字符串匹配。索引数据量从一万涨到一千万模糊查询的耗时也大约涨一千倍。而Lucene的词典查找时间只跟词典规模的对数相关数据量翻十倍查询耗时只是微涨。Lucene在倒排表之上还有一层优化它把文档编号按块压缩存储并且用跳表的方式跳过那些不可能匹配的区间。这样即便倒排表很长它也不需要一个个扫描而是跳跃式地定位。和数据库索引的思路相比Lucene更像一个专为文本检索设计的精密仪器每一个环节都在为“少做无用功”服务。3.2 分词器中文检索好不好用全看它分词器是整个Lucene里最容易被忽略却又影响最大的组件。它决定了一句话会被切分成哪些词进而决定索引里有什么词、搜索时能查到什么词。同一个分词器必须用来建索引和查搜索如果两边不一致很可能出现“明明有这个文档却搜不出来”的诡异现象。Lucene自带的StandardAnalyzer在处理英文时表现还可以它会按空格和标点切分并且把大写转成小写。但处理中文的时候就比较简陋了它只是按照单字切分比如“全文检索”会被切成“全”“文”“检”“索”四个字。当然这种方式也不是完全不能用搜“检索”时包含“全文检索”的文档也能被命中因为“检”和“索”这两个字都在里面。但它的相关性很差搜“苹果”可能会把“苹果汁”和“苹果醋”都返回却不会优先匹配“红苹果”这种连续词。在实际生产项目中处理中文内容时我建议要么使用Lucene扩展包里的SmartChineseAnalyzer要么接入第三方中文分词器。SmartChineseAnalyzer是一个基于概率语言模型的切分方案对常见中文词组的识别比单字切分好很多。比如输入“这是一个苹果”它能切出“这是”“一个”“苹果”。不过它对新词、人名、商品名的支持一般真要追求效果需要自己维护分词器的扩展词库。分词器还有一个使用细节容易踩坑自定义词库的加载。很多分词器支持配置扩展词典你在一个文本文件里维护一批词每行一个这些词在切分时会被强制当成独立词项。实际运行时如果发现某些品牌词、专业术语被切碎导致搜索不准最先应该检查的就是词库里有没有这个词而不是去改查询语法。我见过团队排查搜索问题排查了一下午最后发现是词库里缺少某个新款手机型号分词器把“Y90”切成了“Y”和“90”怎么搜都搜不到完整型号。3.3 Query语法写查询条件时避开这些坑Lucene的QueryParser支持的语法非常接近你在网上见到的那些搜索指令。最基础的是直接输入词比如搜索“手机”想要限定字段写“title:手机”想要多个词同时出现写“手机 充电器”默认是OR关系想要强制必须出现写“手机 充电器”想要排除写“手机 -老年机”。这些语法看起来简单但实际使用时有几个坑必须注意。一个是大写操作符的问题。AND、OR、NOT这些操作符支持大写也支持小写但如果你直接输入一个大写“AND”作为搜索词呢QueryParser会把它解析成逻辑操作符而不是一个普通单词。处理用户输入的搜索词时最好先对特殊字符做转义。另一个坑是通配符和模糊查询。星号代表任意字符序列问号代表单个字符比如“华*”可以匹配“华为”“华夏”。模糊查询用波浪号比如“华为~1”会查找和“华为”编辑距离不超过1的词。这些功能看着美好但滥用起来性能会很难看。尤其不能把通配符放在词的开头比如“*为”因为Lucene的词典是按词首字母顺序排列的头部通配符等于无法走二分查找只能遍历词典性能直接崩。还有一点是查询语句的括号与字段组合。项目里如果需要实现“分类为手机且品牌是华为或荣耀”这种复合条件语法可以写成“category:手机 AND (brand:华为 OR brand:荣耀)”。QueryParser对这种嵌套布尔表达式是支持的但注意不要让单一布尔查询的子句数量超过上限Lucene默认有maxClauseCount限制通常是一千多个子句。如果动态拼接了一万个品牌进去会直接抛出TooManyClauses异常。像这种场景应该在构建查询时改用底层API比如用BooleanQuery.Builder来编程式构建而不是靠字符串拼接。4. 实战完整实现一个图书搜索模块4.1 索引字段建模Document和Field怎么搭Lucene里一条记录叫Document一个字段叫Field。很多初学者容易踩的坑是把所有信息一股脑塞进一个TextField以为这样“搜索范围大”。实际上正确的做法是对每个字段仔细决定它的索引方式。以图书搜索为例图书有书名、作者、ISBN、出版日期、价格、简介。书名和简介是全文搜索的主体作者可能需要精确匹配或前缀匹配ISBN必须精确匹配出版日期和价格需要支持范围过滤和排序。Lucene里有个概念决定了一个字段能不能被搜索、能不能被存储、能不能被排序。TextField适合大段文本会分词存储用Field.Store.YES决定原始文本是否保留StringField适合精确匹配不分词比如ISBN和作者名对这种字段搜索时只能全等匹配IntPoint、LongPoint这类数字点类型适合数值过滤和范围查询但它们会占用额外的数据结构SortedDocValuesField或NumericDocValuesField是为了排序、聚合而设计的列式存储字段。这里有开发中常遇到的困惑字段既要做全文匹配又要展示原始值还要能排序怎么办答案是同一个逻辑字段可以在Document里添加多个Field。比如书名你可以加一个TextField用来搜索和展示再加一个用于排序的StringField或SortedDocValuesField版本。字段之间不冲突只是对同一个数据建立了不同维度的存储结构。索引数据规模上来后返回原始值也会成为性能瓶颈。Lucene在搜索时返回的是文档ID而不是完整数据如果你Field.Store.YES的字段特别多每次结果展示都要去读取存储空间反而不如只存ID再去数据库查出最新数据。我个人的习惯是Document里只保留ID、标题这类展示高频、体量小的字段正文和详情存数据库查到ID列表之后回表查询。这样既能享受Lucene的检索速度又不浪费磁盘空间。4.2 增量更新、删除与提交策略真实项目里的数据不可能只写一次。图书信息会更新旧书会下架新书会加入。Lucene对更新和删除的支持核心是利用了段不可变的机制。意思是索引目录由很多个“段”组成每个段一旦生成就不可修改了。你要更新文档Lucene并不是在原段上改而是新增一个包含新文档的段同时把旧文档标记为删除。这种方式在Lucene里叫作“标记删除”物理删除要等到段合并时才会真正执行。理解这一点就不难明白为什么定期merge是必要的了。如果你的程序大量更新和删除却从来不触发段合并索引目录里的文件会越来越多占用的磁盘空间也不会立刻降下来查询还要额外跳过已删除的文档性能会慢慢变差。IndexWriter的updateDocument方法接受一个Term和一个新文档含义是“把所有包含该Term的旧文档标记为删除然后把新文档写入”。比较常见的策略是用一个唯一ID字段做主键比如“id:978-xxx”更新时调用writer.updateDocument(new Term(id, isbn), newDoc)。注意这个“id”字段必须用StringField来存因为Term匹配是精确匹配不分词的。提交策略上commit太频繁会反复执行磁盘同步性能差commit太少一旦进程崩溃丢失的数据就越多。常见的做法是在每批次写入后调用一次commit或者依赖IndexWriterConfig里的事务日志配置。但有一点要弄清楚commit操作在Lucene里是“同步全部缓存到磁盘”然后更新索引目录的提交点。加文档时其实可以不开commit因为Lucene还有一个机制叫“近实时搜索”。调用writer.commit之后再用IndexWriter生成新的reader此时新建的IndexSearcher才能搜到新数据。4.3 搜索实现分页、高亮和排序Lucene搜索返回的TopDocs包含命中的总数量和一个得分最高的前N条文档ID列表。实际项目中几乎不可能不做分页但Lucene的分页和数据库分页思路不一样。数据库用LIMIT offset, size向后翻页Lucene如果也简单叠加offset在深层分页时性能会越来越差因为前offset条它都要算一遍得分。深层分页的推荐方式是用searchAfter它接收上一次返回的最后一个ScoreDoc然后从那个位置往后继续取。这里有个需要注意的细节ScoreDoc依赖排序顺序如果只是按相关性得分排序两次搜索的得分完全相同会影响正确性所以最好在排序中加入一个稳定的第二排序字段比如文档ID或某个唯一编号。高亮显示可以借助lucene-highlighter模块。简单流程是把原始文本字段传入构建一个Highlighter然后调用getBestFragment返回包含关键词的片段。一个常见困惑是高亮片段中文乱码多半是编码问题注意在创建IndexWriter时不需要设置编码因为Lucene内部统一使用UTF-8但程序的输入源务必确认是UTF-8编码。有一个能大幅提升搜索体验的排序技巧应用“时间衰减”。很多场景下用户希望较新的内容排在前面但又不能完全按时间排序而忽略相关性。Lucene允许自定义排序和打分可以在查询阶段对原始得分乘以一个时间权重因子。比如得分乘以一个数学函数新内容权重略高旧内容逐步衰减。刚上线时可以先用多字段排序解决第一关键字用自定义评分第二关键字用出版日期降序效果直观且实现简单。等用户反馈稳定后再逐步引入复杂的自定义评分脚本。5. 性能调优索引和检索两侧分别怎么做5.1 入库侧的优化参数Lucene写入慢的烦恼大多数可以在配置级别解决。IndexWriterConfig里有几个关键参数RAMBufferSizeMB决定内存缓冲多大才把索引写入磁盘默认是16MB数据量大的批量导入时可以调到128MB甚至更高MaxBufferedDocs决定缓冲多少条文档开始刷盘MergeFactor决定段合并的频率。只调大RAMBufferSizeMB不一定总是有用。如果每个文档的字段特别大缓冲很快就被塞满如果字段小而密可能长时间不触发刷盘。我在倒腾日志索引时把RAMBufferSizeMB调到256MB同时关闭了并发合并导入速度肉眼可见地提升。当然这个不是固定值最好在真实的服务器配置和数据量下做压测。段合并策略也很重要。Lucene默认的TieredMergePolicy会尽量让段大小相近合并成更大的段。这个策略在容忍磁盘IO稍高的情况下表现不错。如果机器磁盘IO很紧张可以设置合并线程数为1或者降低段大小比例来减少合并压力。但反过来段太多会拖慢查询因为一次搜索需要打开所有段所以这里要在“查询性能”和“写入性能”之间找平衡。还有一个容易被忽略的点并发写索引。IndexWriter本身不是线程安全的多个线程不要共用一个writer去addDocument。通常的解法是每个线程创建自己的IndexWriter或者把并发写入改成生产者-消费者队列由单线程统一写入。Lucene的并发模型是“文档可以并行提交给自己线程的writer”但同一个writer实例内部有锁并发调addDocument需要靠外部同步。项目里如果把异步线程池直接和writer一起用别忘了加锁或排队否则会出现内部状态不一致的异常。5.2 搜索侧与内存的优化经验搜索侧的性能调优首先要搞清楚瓶颈在哪里。一般来说分词器和查询解析是CPU密集读取倒排表是磁盘IO密集高亮和排序则分别要看原始字段大小和DocValues是否构建。对症下药才能有效。第一个经验是IndexSearcher是线程安全的可以多个请求线程共享同一个实例。但IndexReader必须重新打开才能看到新索引数据。所以生产做法是使用一个后台线程周期性检测索引目录是否有更新如果有新提交就重新open一个IndexReader然后替换掉老的IndexSearcher。注意替换时把旧reader关闭否则文件句柄越来越多最终把系统资源耗尽。第二个经验大文本字段的高亮很耗CPU。每次搜索都实时高亮尤其搜索结果几十条、每条文本几万字符时性能会非常差。常见优化是在搜索返回后只对Top N比如前20条做高亮而不是对所有命中结果做或者干脆在索引阶段把整篇文本的摘要提前生成一个字段查询时直接用摘要避免实时切分一整篇文档。第三个经验排序用DocValues而不是存储字段。如果对某个字段排序必须确保索引时添加了对应的DocValues。比如对价格排序需要添加NumericDocValuesField对日期排序可以先将日期转为数值再添加NumericDocValuesField。如果字段没有对应的DocValues排序时Lucene会在内存中临时排序当结果集很大的时候会慢得离谱甚至抛出异常。还有缓存策略的问题。Lucene内部本身有filter cache这些机制但如果你在业务层频繁查询相同的关键词建议自己做一层结果缓存比如在Redis中缓存搜索词对应的ID列表设置一个合理的过期时间。这样做的好处是可以把Lucene从重复劳动中解放出来。热点词和长尾词分开处理热点词走缓存长尾词走实时查询整体QPS压力会小很多。6. 常见问题排查与避坑清单6.1 实战中高频踩坑记录我把这些年见过的、自己也踩过的Lucene高频问题整理成一张速查表开发时对号入座会省很多时间。现象常见原因解决思路搜索返回0条结果但数据明明写入了索引未commit或IndexSearcher使用旧reader写入后调用commit搜索前重新open reader中文搜索不准、碎片化分词器选择不当使用中文分词器并维护扩展词库报LockObtainFailedException多个进程同时打开同一个索引目录确保同一目录只被一个IndexWriter写入程序异常退出时检查并清理锁文件报TooManyClauses异常BooleanQuery子句数量超过上限提高限制值或改用底层API构建查询索引越写越慢、磁盘越来越大大量更新删除导致段堆积定期触发forceMerge合并段释放空间高亮内容乱码程序输入输出编码不一致统一使用UTF-8检查HTTP响应头与文件编码深层分页越来越慢使用了传统offsetlimit方式改用searchAfter游标方式搜“addidas”搜不到“adidas”缺少纠错与模糊匹配对用户输入做拼写纠错或使用模糊查询但要注意系统开销6.2 查不到结果时的标准排查思路遇到“搜索结果为空”时不要急着怀疑Lucene有问题我一般按下面几条线逐一排查。先检查索引是否真的有数据。最简单的办法是打开IndexReader调用numDocs方法确认数量不为0。如果数量为0问题在写入端要么没commit要么用错了Analyzer导致写入时所有词都被过滤掉。再看查询语句是不是合法。在QueryParser里如果输入了带特殊符号的文本比如“C”很可能被解析成查询语法的一部分导致实际查询条件和预期不符。可以先用自定义的转义方法处理用户输入或者用QueryParser 生成的Query对象打印它的toString结果验证一下。然后对比分词结果。在Lucene的Analyzer上直接用analyze方法查看分词结果和索引分词结果对照。如果查询是“华为手机”而索引里切的是“华为”“手机”查询时却切成了“华”“为”“手”“机”两边不一致那就搜不准。这种情况的根源一般是建索引和查询时用了不同的Analyzer。最后再考虑是不是过滤和排序的问题。如果查询本身没问题检查是不是加了过滤条件过滤字段的值和索引中的值不匹配。比如过滤条件写的是“status:1”但索引里status以字符串形式存的“1001”就是匹配不上的。排查完这四步还查不出问题那就考虑换一种查询方式或简化条件逐个字段试。一般来说排查搜索问题就是个缩小范围的过程把变量一个个排除掉总能找到那个“看似没问题”的环节。最后我再分享一点个人经验。搜索项目的坑十个里有八个出在分词器其余两个出在索引生命周期管理。新手往往把精力放在研究各种高级查询语法上结果数据建模和字段设计没做好后面越改越痛苦。如果你准备在自己的系统里集成Lucene我强烈建议先花时间把“哪些字段索引、哪些字段存储、哪些字段排序”这件事想清楚哪怕多花半天也值得这比用什么高深的调优技巧都重要。我第一次做站内搜索的时候就是因为偷懒把所有字段都设计成TextField后来搜索排序和筛选怎么做都不对推翻重写模型才解决。希望你看完这篇教程之后能少走这一段弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →