某電商平臺開發記要——全文檢索
開發Web應用時,你經常要加上搜索功能。甚至還不知道要搜什么,就在草圖上畫了一個放大鏡。
說到目前計算機的文字搜索在應用上的實現,象形文字天生就比拼音字母劣勢的多,分詞、詞性判斷、拼音文字轉換啥的,容易讓人香菇。
首先我們來了解下什么是Inverted index,翻譯過來的名字有很多,比如反轉索引、倒排索引什么的,讓人不明所以,可以理解為:一個未經處理的數據庫中,一般是以文檔ID作為索引,以文檔內容作為記錄。而Inverted index 指的是將單詞或記錄作為索引,將文檔ID作為記錄,這樣便可以方便地通過單詞或記錄查找到其所在的文檔。并不是什么高深概念。
oracle里常用的位圖索引(Bitmap index)也可認為是Inverted index。位圖索引對于相異基數低的數據最為合適,即記錄多,但取值較少。比如一個100W行的表有一個字段會頻繁地被當做查詢條件,我們會想到在這一列上面建立一個索引,但是這一列只可能取3個值。那么如果建立一個B*樹索引(普通索引)是不合適的,因為無論查找哪一個值,都可能會查出很多數據,這時就可以考慮使用位圖索引。位圖索引相對于傳統的B*樹索引,在葉子節點上采用了完全不同的結構組織方式。傳統B*樹索引將每一行記錄保存為一個葉子節點,上面記錄對應的索引列取值和行rowid信息。而位圖索引將每個可能的索引取值組織為一個葉子節點。每個位圖索引的葉子節點上,記錄著該索引鍵值的起始截止rowid和一個位圖向量串。如果不考慮起止rowid,那么就是取值有幾個,就有幾個索引,比如上例,雖說有100W條記錄,但是針對只有3個可取值的字段來說,索引節點只有3個,類似于下圖:

需要注意的是,由于所有索引字段同值行共享一個索引節點,位圖索引不適用于頻繁增刪改的字段,否則可能會導致針對該字段(其它行)的增刪改阻塞(對其它非索引字段的操作無影響),是一種索引段級鎖。具體請參看 深入解析B-Tree索引與Bitmap位圖索引的鎖代價。
下面說說筆者知道的一些全文搜索的工具。
文中綠色文字表示筆者并不確定描述是否正確,紅色表示筆者疑問,若有知道的同學請不吝賜教,多謝!
- ICTCLAS分詞系統
- Postgresql的中文分詞
- Elasticsearch
- Quartz.net:用于定時任務,和全文檢索無關,我們可以用它來進行定時索引管理,比如說過期店鋪的產品索引刪除
本來想借著ICTCLAS簡單介紹下中文分詞的一些原理和算法,不過網上已有比較好的文章了,可參看 ICTCLAS分詞系統研究。中文分詞基本上是基于詞典,[可能]涉及到的知識 —— HMM(隱馬爾科夫鏈)、動態規劃、TF-IDF、凸優化,更基礎的就是信息論、概率論、矩陣等等,我們在讀書的時候可能并不知道所學何用,想較快重溫的同學可閱讀吳軍博士的《數學之美》。這些概念我會擇要在后續博文中介紹。下面我們就來看看分詞系統在數據庫中的具體應用。
在PostgreSQL中,GIN索引就是Inverted index,GIN索引存儲一系列(key, posting list)對, 這里的posting list是一組出現鍵的行ID。 每一個被索引的項目都可能包含多個鍵,因此同一個行ID可能會出現在多個posting list中。 每個鍵值只被存儲一次,因此在相同的鍵出現在很多項目的情況下,GIN索引是非常緊湊的(來自PostgreSQL 9.4.4 中文手冊)。顯然,將之應用到數組類型的字段上是非常合適的。全文檢索類型(tsvector)同樣支持GIN索引,可以加速查詢。聽說9.6版本出了一個什么RUM索引,對比GIN,檢索效率得到了很大的提升,可參看 PostgreSQL 全文檢索加速 快到沒有朋友 - RUM索引接口(潘多拉魔盒)。
幸運的是,阿里云RDS PgSQL已支持zhparser(基于SCWS)中文分詞插件。
連接要分詞的數據庫,執行以下語句:
-- 安裝擴展
create extension zhparser;
-- 查看該數據庫的所有擴展
select * from pg_ts_parser;
-- 支持的token類型,即詞性,比如形容詞名詞啥的
select ts_token_type('zhparser');
-- 創建使用zhparser作為解析器的全文搜索的配置
CREATE TEXT SEARCH CONFIGURATION testzhcfg (PARSER = zhparser);
-- 往全文搜索配置中增加token映射,上面的token映射只映射了名詞(n),動詞(v),形容詞(a),成語(i),嘆詞(e)和習慣用語(l)6種,這6種以外的token全部被屏蔽。
-- 詞典使用的是內置的simple詞典,即僅做小寫轉換。
ALTER TEXT SEARCH CONFIGURATION testzhcfg ADD MAPPING FOR n,v,a,i,e,l WITH simple;
set zhparser.punctuation_ignore = t; -- 忽略標點符號
現在我們就可以方便的進行中文分詞了,比如“select to_tsvector('testzhcfg','南京市長江大橋');”,會拆分為“'南京市':1 '長江大橋':2”。如果要分的更細粒度,那么可以設置復合分詞,復合分詞的級別:1~15,按位異或的 1|2|4|8 依次表示 短詞|二元|主要字|全部字,缺省不復合分詞,這是SCWS的配置選項,對應的zhparser選項為zhparser.multi_short、zhparser.multi_duality、zhparser.multi_zmain、zhparser.multi_zall。比如我們要設置短詞復合分詞,那么就set zhparser.multi_short=on;那么“select to_tsvector('testzhcfg','南京市長江大橋');”得到的分詞結果將是“'南京':2 '南京市':1 '大橋':5 '長江':4 '長江大橋':3”,這樣就可以匹配到更多的關鍵詞,當然檢索效率會變慢。
短詞復合分詞是根據詞典來的,比如詞典中有'一次性'、'一次性使用'、’'一次性使用吸痰管'、'使用'、'吸痰管'5個詞語,當multi_short=off時,select to_tsvector('testzhcfg','"一次性使用吸痰管"');返回最大匹配的"一次性使用吸痰管",而為on時,返回的是"'一次性':2 '一次性使用吸痰管':1 '使用':3 '吸痰管':4",讓人困惑的是,結果里沒有提取出'一次性使用'這個詞,不知怎么回事。
在產品表上建一列tsv存儲產品名稱的tsvector值,并對該列建GIN索引。
CREATE OR REPLACE FUNCTION func_get_relatedkeywords(keyword text)
RETURNS SETOF text[] AS
$BODY$
begin
if (char_length(keyword)>0) then
RETURN QUERY select string_to_array(tsv::text,' ') from "Merchandises" where tsv @@ plainto_tsquery('testzhcfg',keyword);
end if;
end
$BODY$
LANGUAGE plpgsql VOLATILE
注意plainto_tsquery和to_tsquery稍微有點區別,比如前者不認識':*',而后者遇到空格會報錯。
這會返回所有包含傳入關鍵詞的tsvector格式的字符串,所以我們要在業務層分解去重再傳遞給前端。
1 public async Task<ActionResult> GetRelatedKeywords(string keyword)
2 {
3 var keywords = await MerchandiseContext.GetRelatedKeywords(keyword);
4 if(keywords != null && keywords.Count>0)
5 {
6 //將所有產品的關鍵詞匯總去重
7 var relatedKeywords = new List<string>();
8 foreach(var k in keywords)
9 {
10 for(int i=0;i<k.Count();i++) //pg返回的是帶冒號的tsvector格式
11 {
12 k[i] = k[i].Split(':')[0].Trim('\'');
13 }
14 relatedKeywords.AddRange(k);//k可以作為整體,比如多個詞語作為一個組合加入返回結果,更科學(這里是拆分后獨立加入返回結果)
15 }
16 //根據出現重復次數排序(基于重復次數多,說明關聯性高的預設)
17 relatedKeywords = relatedKeywords.GroupBy(rk => rk).OrderByDescending(g => g.Count()).Select(g => g.Key).Distinct().ToList();
18 relatedKeywords.RemoveAll(rk=>keyword.Contains(rk));
19 return this.Json(new OPResult<IEnumerable<string>> { IsSucceed = true, Data = relatedKeywords.Take(10) }, JsonRequestBehavior.AllowGet);
20 }
21 return this.Json(new OPResult { IsSucceed = true }, JsonRequestBehavior.AllowGet);
22 }
now,我們就初步實現了類似各大電商的搜索欄關鍵詞聯想功能:

然而,尚有一些值得考慮的細節。當數據庫中產品表越來越大,毫無疑問查詢時間會變長,雖然我們只需要前面10個關聯詞,但可能有重復詞,所以并不能簡單的在sql語句后面加limit 10。暫時縮小不了查詢范圍,可以減少相同關鍵詞的數據庫查詢頻率,即在上層加入緩存。key是關鍵詞或關鍵詞組合,value是關聯關鍵詞,關鍵詞多的話,加上各種組合那么數據量肯定很大,所以我們緩存時間要根據數據量和用戶搜索量定個合適時間。以redis為例:
1 public static async Task SetRelatedKeywords(string keyword, IEnumerable<string> relatedKeywords)
2 {
3 var key = string.Format(RedisKeyTemplates.MERCHANDISERELATEDKEYWORDS, keyword);
4 IDatabase db = RedisGlobal.MANAGER.GetDatabase();
5 var count = await db.SetAddAsync(key, relatedKeywords.Select<string, RedisValue>(kw => kw).ToArray());
6 if (count > 0)
7 db.KeyExpire(key, TimeSpan.FromHours(14), CommandFlags.FireAndForget); //緩存
8 }
9
10 public static async Task<List<string>> GetRelatedKeywords(string keyword)
11 {
12 IDatabase db = RedisGlobal.MANAGER.GetDatabase();
13 var keywords = await db.SetMembersAsync(string.Format(RedisKeyTemplates.MERCHANDISERELATEDKEYWORDS, keyword));
14 return keywords.Select(kw => kw.ToString()).ToList();
15 }
當用戶在搜索欄里輸入的并非完整的關鍵詞——輸入的文字并未精確匹配到數據庫里的任一tsvector——比如就輸入一個“交”或者“鎖型”之類,并沒有提供用戶預期的自動補完功能(雖然自動補完和關鍵詞聯想本質上是兩個不同的功能,不過用戶可能并不這么想)。我們知道,在關鍵詞后加':*',比如“交:*”,那么是可以匹配到的,如:select '交鎖型:2 交鎖型股骨重建釘主釘:1 股骨:3 重建:4'::tsvector @@ to_tsquery('交:*'),返回的就是true。然而我們總不能讓用戶輸入的時候帶上:*,在代碼里給自動附加:*是一種解決方法(select to_tsquery('testzhcfg','股骨重建:*'),結果是"'股骨':* & '重建':*"),然而會帶來可能的效率問題,比如select to_tsquery('testzhcfg','一次性使用吸痰管:*'),它會拆分為"'一次性使用吸痰管':* & '一次性':* & '使用':* & '吸痰管':*",并且出于空格的考慮,我們用的是plainto_tsquery,而它是不認識:*的。
當用戶輸入一些字符的時候,如何判斷是已完成的關鍵詞(進行關鍵詞聯想)還是未輸完的關鍵詞(自動補完),這是個問題。我們可以將用戶常搜的一些關鍵詞緩存起來(或者定期從tsv字段獲取),當用戶輸入匹配到多個(>1)緩存關鍵詞時,說明關鍵詞還未輸完整,返回關鍵詞列表供用戶選擇,否則(匹配數量<=1)時,則去查詢關聯關鍵詞。同樣用redis(很幸運,redis2.8版本后支持set集合的值正則匹配):
/// <summary>
/// 獲取關鍵詞(模糊匹配)
/// </summary>
public static List<string> GetKeywords(string keyword, int takeSize = 10)
{
IDatabase db = RedisGlobal.MANAGER.GetDatabase();
//這里的pageSize表示單次遍歷數量,而不是說最終返回數量
var result = db.SetScan(RedisKeyTemplates.SearchKeyword, keyword + "*", pageSize: Int32.MaxValue);
return result.Take(takeSize).Select<RedisValue, string>(r => r).ToList();
}
當然,也有可能用戶輸入已經匹配到一個完整關鍵詞,但同時該關鍵詞是另外一些關鍵詞的一部分。我們可以先去緩存里面取關鍵詞,若數量少于10個(頁面上提示至多10個),那么就再去看是否有關聯關鍵詞補充。
大部分網站搜索還支持拼音搜索,即按全拼或拼音首字母搜索。
對關鍵詞[組合]賦予權重,權重計算可以依據搜索量、搜索結果等,每次返回給用戶最有效的前幾條。這以后再說吧。
總的來說,數據庫自帶的全文檢索還是建立在字段檢索的基礎上,適合傳統SQL查詢場景,而且圍繞分詞系統的查詢方案和邏輯大部分需要自己處理,涉及到稍復雜的應用就力不從心,或者效率低下了(比如上述的自動補完功能),另外分布部署的時候也要在上層另做集群架構。
基于5.4版本
節點:一個運行中的 Elasticsearch 實例稱為一個 節點。
集群是由一個或者多個擁有相同 cluster.name 配置的節點組成, 它們共同承擔數據和負載的壓力。當有節點加入集群中或者從集群中移除節點時,集群將會重新平均分布所有的數據。一個集群只能有一個主節點。
索引:作為名詞時,類似于傳統關系型數據庫中的一個數據庫。索引實際上是指向一個或者多個物理 分片 的 邏輯命名空間 。一個索引應該是(非強制)因共同的特性被分組到一起的文檔集合, 例如,你可能存儲所有的產品在索引 products 中,而存儲所有銷售的交易到索引 sales 中。
分片:一個分片是一個 Lucene 的實例(亦即一個 Lucene 索引 ),它僅保存了全部數據中的一部分。索引內任意一個文檔都歸屬于一個主分片,所以主分片的數目決定著索引能夠保存的最大數據量;副本分片作為硬件故障時保護數據不丟失的冗余備份,并為搜索和返回文檔等讀操作提供服務。
類型:由類型名和mapping組成,mapping類似于數據表的schema,或者說類[以及字段的具體]定義。
技術上講,多個類型可以在相同的索引中存在,只要它們的字段不沖突,即同名字段類型必須相同。但是,如果兩個類型的字段集是互不相同的,這就意味著索引中將有一半的數據是空的(字段將是 稀疏的 ),最終將導致性能問題。——導致這一限制的根本原因,是Lucene沒有文檔類型的概念,一個Lucene索引(ES里的分片)以扁平的模式定義其中所有字段,即假如該分片里有兩個類型A\B,A中定義了a\c兩個字符串類型的字段,B定義了b\c兩個字符串類型的字段,那么Lucene創建的映射包括的是a\b\c三個字符串類型的字段,如果A\B中c字段類型不一樣,那么配置這個映射時,將會出現異常。由此亦知,一個分片可包含不同類型的文檔。
文檔:一個對象被序列化成為 JSON,它被稱為一個 JSON 文檔,指定了唯一 ID 。
假如文檔中新增了一個未事先定義的字段,或者給字段傳遞了非定義類型的值,那么就涉及到動態映射的概念了。另外,盡管可以增加新的類型到索引中,或者增加新的字段到類型中,但是不能添加新的分析器或者對現有的字段做改動,遇到這種情況,我們可能需要針對此類文檔重建索引。
在 Elasticsearch 中, 每個字段的所有數據 都是 默認被索引的 。 即每個字段都有為了快速檢索設置的專用倒排索引。
樂觀并發控制,Elasticsearch 使用 version 版本號控制、處理沖突。
Lucene中的[倒排]索引(在Lucene索引中表現為 段 的概念,但Lucene索引除表示所有 段 的集合外,還有一個 提交點 的概念 ),[一旦創建]是不可變的,這有諸多好處:
- 不需要鎖;
- 重用索引緩存[,而非每次去磁盤獲取索引](即緩存不會失效,因為索引不變),進一步可以重用相同查詢[構建過程和返回的數據],而不需要每次都重新查詢;
- 允許[索引被]壓縮;
但是 數據/文檔 變化后,畢竟還是得更新 索引/段 的,那么怎么更新呢?—— 新的文檔和段會被創建,而舊的文檔和段被標記為刪除狀態,查詢時,后者會被拋棄。
安裝Elasticsearch前需要安裝JRE(Java運行時,注意和JDK的區別),然后去到https://www.elastic.co/start里,根據提示步驟安裝運行即可。(筆者為windows環境)
安裝完之后我們就可以在通過http://localhost:5601打開kibana的工作臺。為了讓遠程機子可以訪問,在啟動kibana之前要先設置kibana.yml中的server.host,改為安裝了kibana的機器的IP地址,即server.host: "192.168.0.119",注意中間冒號和引號之間要有空格,否則無效,筆者被此處坑成狗,也是醉了。同理,要elasticsearch遠程可訪問,需要設置elasticsearch.yml中的network.host。
單機上啟動多個節點,文檔中說 “你可以在同一個目錄內,完全依照啟動第一個節點的方式來啟動一個新節點。多個節點可以共享同一個目錄。” 沒搞懂什么意思,試了下再開個控制臺進入es目錄執行命令行,會拋異常。所以還是老老實實按照網上其它資料提到的,拷貝一份es目錄先,要幾個節點就拷貝幾份。。
ES官方給.Net平臺提供了兩個工具—— Elasticsearch.Net 和 NEST,前者較底層,后者基于前者基礎上進行了更高級的封裝以方便開發調用。
NEST有個Connection pools,這跟我們平常認為的連接池不是同一個概念,而是一種策略——以什么方式連接到ES——有四種策略:
- SingleNodeConnectionPool:每次連接指向到同一個節點(一般設置為主節點,專門負責路由)
- StaticConnectionPool:如果知道一些節點Uri的話,那么每次就[隨機]連接到這些節點[中的一個]
- SniffingConnectionPool:derived from StaticConnectionPool,a sniffing connection pool allows itself to be reseeded at run time。然而暫時并不知道具體用處。。。
- StickyConnectionPool:選擇第一個節點作為請求主節點。同樣不知用這個有什么好處。。。
下面我們使用ES實現自動補完的功能,順帶介紹涉及到的知識點。
服務器根據用戶當前輸入返回可能的[用戶真正想輸的]字符串——"Suggest As You Type"。ES提供了四個Suggester API(可參看 Elasticsearch Suggester詳解,這篇文章沒有介紹第四個Context Suggester,我會在本節后面稍作描述),本文舉例的自動補完,適合使用Completion Suggester(后面會說到使用上存在問題)。
我們先來看類型定義:
1 public class ProductIndexES 2 { 3 public long Id { get; set; } 4 public string ProductName { get; set; } 5 /// <summary> 6 /// 品牌標識 7 /// </summary> 8 public long BrandId { get; set; } 9 public string BrandName { get; set; } 10 /// <summary> 11 /// 店鋪標識 12 /// </summary> 13 public long ShopId { get; set; } 14 public string ShopName { get; set; } 15 /// <summary> 16 /// 價格 17 /// </summary> 18 public decimal Price { get; set; } 19 /// <summary> 20 /// 上架時間 21 /// </summary> 22 public DateTime AddDate { get; set; } 23 /// <summary> 24 /// 售出數量 25 /// </summary> 26 public long SaleCount { get; set; } 27 //產品自定義屬性 28 public object AttrValues { get; set; } 29 public Nest.CompletionField Suggestions { get; set; } 30 }
若要使用Completion Suggester,類型中需要有一個CompletionField的字段,可以將原有字段改成CompletionField類型,比如ProductName,我們同樣可以針對CompletionField設置Analyzer,所以不影響該字段原有的索引功能(CompletionField接受的是字符串數組Input字段,經測試也看不出Analyzer對它的作用(自動補完返回的字符串是Input數組中與用戶輸入起始匹配的字符串,對分詞后的字符串沒有體現),所以Analyzer配置項的作用是什么令人費解);或者另外加字段,用于專門存放Input數組,這就更加靈活了,本例采用的是后者。
創建索引:
1 var descriptor = new CreateIndexDescriptor("products") 2 .Mappings(ms => ms.Map<ProductIndexES>("product", m => m.AutoMap() 3 .Properties(ps => ps 4 //string域index屬性默認是 analyzed 。如果我們想映射這個字段為一個精確值,我們需要設置它為 not_analyzed或no或使用keyword 5 .Text(p => p 6 .Name(e => e.ProductName).Analyzer("ik_max_word").SearchAnalyzer("ik_max_word") 7 .Fields(f => f.Keyword(k => k.Name("keyword"))))//此處作為演示 8 .Keyword(p => p.Name(e => e.BrandName)) 9 .Keyword(p => p.Name(e => e.ShopName)) 10 .Completion(p => p.Name(e => e.Suggestions)))));//此處可以設置Analyzer,但是看不出作用 11 12 Client.CreateIndex(descriptor);
第6、7行表示ProductName有多重配置,作為Text,它可以用作全文檢索,當然我們希望用戶在輸入產品全名時也能精確匹配到,所以又設置其為keyword表示是個關鍵詞,這種情況就是Multi fields。不過由于我們設置了SearchAnalyzer,和Analyzer一樣,用戶輸入會按同樣方式分詞后再去匹配,所以不管是全名輸入或者部分輸入,都可以通過全文檢索到。
接著把對象寫入索引,方法如下:
1 public void IndexProduct(ProductIndexES pi) 2 { 3 var suggestions = new List<string>() { pi.BrandName, pi.ShopName, pi.ProductName }; 4 var ar = this.Analyze(pi.ProductName);//分詞 5 suggestions.AddRange(ar.Tokens.Select(t => t.Token)); 6 suggestions.RemoveAll(s => s.Length == 1);//移除單個字符(因為對自動補完來說沒有意義) 7 pi.Suggestions = new CompletionField { Input = suggestions.Distinct() }; 8 9 //products是索引,product是類型 10 Client.Index(pi, o => o.Index("products").Id(pi.Id).Type("product")); 11 }
假設我新插入了三個文檔,三個suggestions里的input分別是["產品"],["產家合格"],["產品測試","產品","測試"],顯然,根據上述方法的邏輯,最后那個數組中的后兩項是第一項分詞出來的結果。
接下來就是最后一步,通過用戶輸入返回匹配的記錄:
1 public void SuggestCompletion(string text) 2 { 3 var result = Client.Search<ProductIndexES>(d => d.Index("products").Type("product") 4 .Suggest(s => s.Completion("prd-comp-suggest", cs => cs.Field(p => p.Suggestions).Prefix(text).Size(8)))); 5 Console.WriteLine(result.Suggest); 6 }
好,一切看似很完美,這時候用戶輸入“產”這個字,我們期望的是返回["產品","產家合格","產品測試"],次一點的話就再多一個"產品"(因為所有input中有兩個"產品")。然而結果卻出我意料,我在kibana控制臺里截圖:

返回的是["產品","產品","產家合格"]。查找資料發現這似乎是ES團隊故意為之——如果結果指向同一個文檔(或者說_source的值相同),那么結果合并(保留其中一個)——所以Completion Suggester并不是為了自動補完的場景設計的,它的作用主要還是查找文檔,文檔找到就好,不管你的suggestions里是否還有其它與輸入匹配的input。這時聰明的同學可能會說要不不返回_source試試看,很遺憾,官方說_source meta-field must be enabled,而且并沒有給你設置的地方。之前有版本mapping時有個配置項是payloads,設置成false貌似可以返回所有匹配的input,還有output什么的,總之還是有辦法改變默認行為的,然而筆者試的這個版本把這些都去掉了,不知以后是否會有改變。。。
Completion only retrieves one result when multiple documents share same output
這么看來,Suggester更像自定義標簽(依據標簽搜索文檔,Completion Suggester只是可以讓我們只輸入標簽的一部分而已)。所以說自動補全的功能還是得另外實現咯?要么以后有精力看下ES的源碼看怎么修改吧。。
在Completion Suggester基礎上,ES另外提供了Context Suggester,有兩種context:category 和 geo,在查詢時帶上context即可取得與之相關的結果。意即在標簽基礎上再加一層過濾。
相關性:與之對應的重要概念就是評分,主要用在全文檢索時。Elasticsearch 的相似度算法 被定義為檢索詞頻率/反向文檔頻率, TF/IDF。默認情況下,返回結果是按相關性倒序排列的。
緩存:當進行精確值查找時, 我們會使用過濾器(filters)。過濾器很重要,因為它們執行速度非常快 —— 不會計算相關度(直接跳過了整個評分階段)而且很容易被緩存。一般來說,在精確查找時,相關度是可以忽略的,排序的話我們更多的是根據某個字段自定義排序,所以為了性能考慮,我們應該盡可能地使用過濾器。
數組:ES并沒有顯式定義數組的概念,你可以在一個string類型的字段賦值為"abc",也可以賦值為["abc","ddd"],ES會自動處理好。這在一些場景下很有用,比如產品屬于某個葉子類目,它的類目Id設為該葉子類目的Id,這樣用戶能搜索到該類目下的所有產品,但這樣會有問題:當用戶搜索父類目時將得不到任何產品。顯然這是不合理的,所以我們可以將產品的類目Id賦值包含從根類目到葉子類目的類目Id數組,用戶搜索其中任何類目都能得到該產品。 官方文檔
在給內容建索引時可以實時建立,也可以異步[批量]創建,后者的話我們常用計劃任務的方式,涉及到的工具比較常見的是Quartz.Net。
以下對Quartz.Net的描述基于2.5版本。
Quartz.Net支持多個trigger觸發同一個job,但不支持一個trigger觸發多個job,不明其意。
Quartz.Net的job和trigger聲明方式有多種,可以通過代碼
IJobDetail job = JobBuilder.Create<IndexCreationJob>().Build();
ITrigger trigger = TriggerBuilder.Create().StartNow().WithSimpleSchedule(x => x.WithIntervalInSeconds(600).RepeatForever()).Build();
_scheduler.ScheduleJob(job, trigger);
或者通過xml文件。若是通過xml文件,則要指定是哪個xml文件,也可以設置xml文件的watch interval,還可以設置線程數量等等(大部分都有默認值,可選擇設置),同樣可以通過代碼
XMLSchedulingDataProcessor processor = new XMLSchedulingDataProcessor(new SimpleTypeLoadHelper());
ISchedulerFactory factory = new StdSchedulerFactory();
IScheduler sched = factory.GetScheduler();
processor.ProcessFileAndScheduleJobs(IOHelper.GetMapPath("/quartz_jobs.xml"), sched);
以上代碼即表示讀取根目錄下的quartz.jobs.xml獲取job和trigger的聲明。還有另一種代碼方式:
var properties = new NameValueCollection();
properties["quartz.plugin.jobInitializer.type"] = "Quartz.Plugin.Xml.XMLSchedulingDataProcessorPlugin";
properties["quartz.plugin.jobInitializer.fileNames"] = "~/quartz_jobs.xml";
properties["quartz.plugin.jobInitializer.failOnFileNotFound"] = "true";
properties["quartz.plugin.jobInitializer.scanInterval"] = "600";
ISchedulerFactory sf = new StdSchedulerFactory(properties);
_scheduler = sf.GetScheduler();
以上600表示makes it watch for changes every ten minutes (600 seconds)
當然我們可以通過配置文件(同聲明job和trigger的xml文件,兩者目的不同),如:
<configSections>
<section name="quartz" type="System.Configuration.NameValueSectionHandler"/>
</configSections>
<quartz>
<add key="quartz.scheduler.instanceName" value="ExampleDefaultQuartzScheduler"/>
<add key="quartz.threadPool.type" value="Quartz.Simpl.SimpleThreadPool, Quartz"/>
<add key="quartz.threadPool.threadCount" value="10"/>
<add key="quartz.threadPool.threadPriority" value="2"/>
<add key="quartz.jobStore.misfireThreshold" value="60000"/>
<add key="quartz.jobStore.type" value="Quartz.Simpl.RAMJobStore, Quartz"/>
<!--*********************Plugin配置**********************-->
<add key="quartz.plugin.xml.type" value="Quartz.Plugin.Xml.XMLSchedulingDataProcessorPlugin, Quartz" />
<add key="quartz.plugin.xml.fileNames" value="~/quartz_jobs.xml"/>
</quartz>
或者單獨一個文件quartz.config:
# You can configure your scheduler in either <quartz> configuration section # or in quartz properties file # Configuration section has precedence quartz.scheduler.instanceName = QuartzTest # configure thread pool info quartz.threadPool.type = Quartz.Simpl.SimpleThreadPool, Quartz quartz.threadPool.threadCount = 10 quartz.threadPool.threadPriority = Normal # job initialization plugin handles our xml reading, without it defaults are used quartz.plugin.xml.type = Quartz.Plugin.Xml.XMLSchedulingDataProcessorPlugin, Quartz quartz.plugin.xml.fileNames = ~/quartz_jobs.xml # export this server to remoting context #quartz.scheduler.exporter.type = Quartz.Simpl.RemotingSchedulerExporter, Quartz #quartz.scheduler.exporter.port = 555 #quartz.scheduler.exporter.bindName = QuartzScheduler #quartz.scheduler.exporter.channelType = tcp #quartz.scheduler.exporter.channelName = httpQuartz
不需要特意指定是放在配置節中,還是quartz.config中,或者兩者皆有,Quartz.Net會自動加載配置項。代碼和配置方式也可以混著使用,總之給人的選擇多而雜,加之官方文檔并不完善,初次接觸容易讓人困惑。
參考資料:
HBuilder處理git沖突,同 10_Eclipse中演示Git沖突的解決
PostgreSQL的全文檢索插件zhparser的中文分詞效果

浙公網安備 33010602011771號