如何最佳化中日文的關鍵字搜尋 - 3

續上篇,其實在 highlight 功能上線後,給我們設計師測試,一開始測都沒問題,但後來測到「台北 美食」就出現狀況了,發現出來一堆無關的內容 (如圖的第一個結果),而且畫面上也沒有任何 highlight,這實在太怪了,所以今天就來分享一下我們怎麼解決這個問題。

這篇不會提到日文,但相同邏輯也可以套用在日文斷詞喔。進入正題,我們用了中文斷詞 (jieba_index),會發現「台北 美食」被斷成了「台北、(空白)、美食」,而其他被搜尋的內容,只要有參雜 (空白) 的內容,也因為比對成功所以被搜尋出來了。而 highlight 也因為 (空白) 無法上色,所以當然沒有紅色標示啦。

後來才發現,其實是因為 jieba 只負責斷詞,至於文字裡面是否有包含標點符號、(空白)…等,jieba 完全不在意,所以如果要解決這個問題,必須要從 ES 本身著手。

graph TD;
    A[char filter 1] --> B[char filter 2];
    B --> C[char filter 3];
    C --> D[char filter N];
    D --> E[tokenizer];
    E --> F[token filter 1];
    F --> G[token filter 2];
    G --> H[token filter 3];
    H --> I[token filter N];
    style E fill:#ff9;

其實 ES 提供了很多的 filter 與 tokenizer 做結合,結合起來才叫做 analyzer,而在 tokenize 之前所執行的是 char filter,在 tokenize 之後執行的叫做 token filter,所以我們可以利用這些 filter 將標點符號跟 (空白) 過濾掉。

使用 pattern_replace filter

graph TD;
    A["台北(空白)美食"] -->|pattern_replace filter|B["台北美食"];
    B -->|tokenizer|C["台北、美食"];

我們第一版採用的是 char filter 裡面的 pattern_replace filter,顧名思義這個 filter 就是把文字做替換,以我們的需求來說,就是要把標點符號跟 (空白) 轉成空字串,所以可以寫成下面這個樣子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const settings = {
analysis: {
char_filter: {
jieba_char_filter: {
type: 'pattern_replace',
pattern: '[\\t\\s!$%^&*()\\[\\]{}<>|:;\\\\\'",.?/˙‥‧‵、。﹐﹒﹔﹕!#$%&*,.:;?@~•…·¡¿¦¨¯´·¸º‽‼⁏※†‡ˉ˘⁇⁈⁉ˆ⁊⸘]',
replacement: ''
}
},
analyzer: {
jieba_search_normalize: {
char_filter: ['jieba_char_filter'],
tokenizer: 'jieba_search'
}
}
}
}

但後來看了幾篇文章後,發現 pattern_replace filter 用在這裡會有點怪,它是拿來用在像 emoji 轉成文字、特定的正規表示式轉換…等,所以也要注意正規表示式沒寫好的話,其實很容易造成效能瓶頸,所以要慎用。

使用 stop filter

graph TD;
    A["台北(空白)美食"] -->|tokenizer|B["台北、(空白)、美食"];
    B["台北、(空白)、美食"] -->|stop filter|C["台北、美食"];

後來發現其實應該要用 token filter 來處理才對,把經過 tokenizer 處理完的 token stream 再做二次處理,而這裡就要用到 token filter 裡面一拖拉庫 filter 的其中一個 stop filter。

stop filter 的功能是將 token stream 裡面屬於 stop word (停用詞) 的 token 移除,對於英文來說,停用詞就是「this、that、the、a、on」這類單字;而中文來說其實就是「的、啊」這類字,當然也可以將標點符號及 (空白) 加上去。所以改用 stop filter 之後,就寫成下面這個樣子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const settings = {
analysis: {
analyzer: {
jieba_search_normalize: {
tokenizer: 'jieba_search',
filter: ['jieba_stop', 'whitespace_stop']
}
},
filter: {
jieba_stop: {
type: 'stop',
stopwords_path: 'stopwords/stopwords.txt'
},
whitespace_stop: {
type: 'stop',
stopwords: [' ', ' '] // 前面是半型空白,後面是全型空白
}
}
}
}

大家可能發現到為什麼會有兩個 stop filter,我們在測試的時候,發現 (空白) 似乎沒辦法用檔案的型式 (stopwords_path) 讀取,所以直接寫死在 stopwords 陣列,這樣就可以了。


pattern_replace filter 改成 stop filter 之後的結果一樣,但意義不太一樣。而且 ES 在搜尋時好像還可以對 stop word 做一些額外的處理,但這是後話,目前也還沒研究到那裡,有機會再來分享吧。


工商服務,我們在徵後端工程師啦!如果你對於開發景點資料庫 (PostgreSQL)、關鍵字搜尋 (Elasticsearch)、機器學習有興趣的話,歡迎 FB 私訊粉絲頁或直接寄信到 kewang@funliday.com 喔!

如何最佳化中日文的關鍵字搜尋 - 2

上一篇提到了 Funliday 最近如何最佳化中日文的關鍵字搜尋,其實最佳化一直都在做,但一直找不到突破口。後來是因為最近上了一個新功能,才讓最佳化有了新的發展。

大家一定都有用過搜尋引擎,搜尋完的結果頁都會將你在這篇搜尋到的關鍵字,用紅色字標示出來,這就叫 highlight。原本 Funliday 沒這個功能,有時候使用者覺得這搜尋結果怎麼這麼爛,搜尋出來的內容都不是我想要的,所以我們最近就加了這功能。

有了這功能,至少可以讓使用者在結果頁看到內容確實是有關鍵字出現,可能是在標題,也可能是在內文。一般來說,如果兩者都有符合的話應該要放最前面,第二是只有標題符合,最後則是只有內文符合。然後再加上其他的參數,比如是不是被精選過,留言數多不多…等,但這些參數不在這次的討論範圍,就不多提了。

我們在上了 highlight 功能之後,發現其實也可以拿來做為搜尋精準度的驗證。簡單說,當使用者搜尋「新竹」,結果頁可能會出現「新竹米粉」、「今天我們從台北出發去新竹玩」這類的內容,但也發現了「聖地では・・・?世界観がそのまんま!鬼滅の刃の聖地巡礼スポット11選【全国】」這類內容,而且 highlight 的部分在新聖地的「」。這實在非常奇怪,出現一篇跟「新竹」完全無關的內容,於是就開始查問題在哪裡了。

查搜尋結果當然要用 ES 的 explain 功能啦,細節就不多提了,總之後來發現是因為「新聖地では・・・?世界観がそのまんま!鬼滅の刃の聖地巡礼スポット11選【全国】」用的是日文斷詞 (kuromoji),所以「新聖地」會被斷成「、聖地」,而「新竹」會被斷成「、竹」,最後在搜尋時就比對成功了,所以要修正這個問題。


解決這個問題的做法,我們是先用 NLP 技術判斷關鍵字的語言是什麼 (「新竹」判斷為 zh),然後搜尋時決定要搜尋 title_zh 或是 title_ja,因為「新聖地…」是放在 title_ja,所以搜尋時就不會搜尋到這篇文章了。

把這個問題修正之後,其實改善了一大部分的中日文搜尋,因為之前一直都是用 title_* 來搜尋,雖然知道這樣寫效果應該蠻差的,但一直找不到方式 (也沒有花時間) 來驗證,而 highlight 這個功能幫了很多忙。其實 highlight 應該還可以做一些延伸應用,像是在每一個搜尋結果旁邊多一個按鈕,回報給 Funliday「您認為這個搜尋結果有符合你的期待嗎?」,有了使用者的回報,再加上最前面提到的參數,這樣應該能幫助搜尋做的更好。

這篇大概差不多結束啦,下一篇來分享搜尋「台北 美食」遇到的問題。


工商服務,我們在徵後端工程師啦!如果你對於開發景點資料庫 (PostgreSQL)、關鍵字搜尋 (Elasticsearch)、機器學習有興趣的話,歡迎 FB 私訊粉絲頁或直接寄信到 kewang@funliday.com 喔!

如何最佳化中日文的關鍵字搜尋 - 1

Funliday 使用 Elasticsearch (以下簡稱 ES) 做為搜尋引擎的基礎設施,今天來分享一下 Funliday 最近的搜尋功能做了哪些最佳化。

大家都知道日文其實包括了漢字及假名,所以同樣是輸入「台北」,實在很難了解到底是在搜尋中文的「台北」還是日文的「台北」。這時候就來簡單的講一下 ES 的搜尋原理,在將文字儲存進 ES 裡面前,ES 會用 analyzer 將文字拆解後再存入 ES。

隨便舉個例子,要將「行天宮捷運站」用英文 (english) 的 analyzer 儲存進 ES 的話,會變成「行、天、宮、捷、運、站」,如果是用中文 (jieba_index) 的 analyzer 儲存進 ES 的話,則會變成「行天、行天宮、天宮、捷運、捷運站、運站」,所以選對 analyzer 是非常重要的一件事。中文跟英文可以用正規表示式來判斷到底是哪種語言,但要用程式分辨中日文真的沒有這麼容易。所以 Funliday 改用其他方式來解決這個問題。


Funliday 無時無刻都在找尋世界上各個景點,並將內容儲存起來。如果這個景點名稱是中文的話,Funliday 會將景點名稱儲存至詞庫裡面。

graph TD;
    找到的中文景點名稱 --> 存入中文詞庫;

然後將 ES 裡面所有的景點全部再重跑一次 index (將資料儲存至 ES 裡面)。在重跑 index 前,Funliday 會用一些 NLP 的技術來判斷原本在 ES 裡面的景點是中文或日文。如果是日文的話,我們會再丟入中文詞庫裡面判斷詞性,如果是名詞的話,我們就會把這個景點名稱也認定為是中文。

graph TD;
    A[開始] --> B{判斷景點名是中文或日文};
    B -->|中文| C[儲存到中文欄位];
    C --> END[結束];
    B -->|日文| D[儲存到日文欄位];
    D --> E{丟入中文詞庫判斷詞性};
    E -->|名詞| F[可能是中文];
    F --> C[儲存到中文欄位];
    E -->|非名詞| END[結束];

舉個例子,假設有一間在日本的拉麵店叫做「神鳥拉麵」,而且 NLP 判斷為這四個字是日文的話,就可以丟入中文詞庫取出詞性,如果是名詞的話,可以將「神鳥拉麵」也視為中文。這時我們如果分別用中日文的 analyzer 來看看結果,會發現日文 (kuromoji) 是「神、鳥、拉、麵」,而中文 (jieba_index) 是「神鳥、拉麵」。到時候在搜尋時輸入「拉麵」的話,如果在 index 階段沒有判斷詞性的這個步驟,是有可能會搜不到這間拉麵店的喔,不過搜尋的細節等下一篇再來分享吧。

要能讓搜尋體驗變得更好,ES 的 analyzer 真的要好好研究才行。

外地人分的清楚嘉義市跟嘉義縣嗎?

最近在處理 POIBank 搜尋的時候,難搞的 @willie 又來了一個新需求,他想要在搜尋嘉義市的時候也可以搜尋到嘉義縣的景點,簡單舉個例子。「故宮南院」的地址為「嘉義縣太保市故宮大道888號」,所以他希望使用者在搜尋時,無論城市是選擇嘉義縣太保市嘉義市的時候,都可以找到「故宮南院」。

對系統來說,沒有什麼需求是做不到的,但不能把需求客製化,所以想了一下這需求要如何開發才會比較有系統性。


我們先來整理一下中華民國的行政區劃

  1. 省、直轄市
  2. 縣、市
  3. 鄉、鎮、縣轄市、直轄市山地原住民區、區
  4. 村、里

嘉義縣與嘉義市

嘉義市是「第二級市」,與嘉義縣無關。而嘉義縣是「第二級縣」,與嘉義市無關,兩者是平行位階。另外嘉義縣的縣治是太保市,為「第三級縣轄市」,但就算當地人也不見得搞的懂從屬關係。

以行政區邊界來看,嘉義縣嘉義市雖然是平行位階,但嘉義縣卻將嘉義市包圍起來 (嘉義市是嘉義縣的內飛地),就跟新北市台北市雖然都是「一級直轄市」,但新北市卻將台北市包圍起來一樣的意思。

宜蘭縣與宜蘭市

宜蘭縣是「第二級縣」,而宜蘭市是「第三級縣轄市」,另外宜蘭縣的縣治正好為宜蘭市

以行政區邊界來看,因為宜蘭縣的縣治為宜蘭市,所以宜蘭縣宜蘭市包圍起來也是很正常的事。

系統化需求

了解了行政區劃以及自己在 Google 上的搜尋經驗來講,使用者真的很難知道所搜尋的關鍵字,到底是屬於哪個行政區的位階,能打出「嘉義」或「宜蘭」就已經很厲害了。所以使用者在宜蘭市搜尋「羅東夜市」,或者是嘉義市搜尋「故宮南院」的時候,都應該要能搜的到正確的結果。

回到開頭所講的,這裡就可以從這裡把原始需求系統化,其實就是需要一個 similar city 的功能。把 schema 整理出來之後,也就是下面這樣而已:

city similar
嘉義市 嘉義縣
宜蘭市 宜蘭縣

所以在嘉義市搜尋的時候,必須要將嘉義縣也加進來,在 PostGIS 就是用 ST_Union 將兩個 bounding box 合併起來再丟去 Elasticsearch 用 geo_polygon 做搜尋。

關於生活圈

上面的這個功能雖然已經上線了,但這世界真的沒有這麼簡單。其實 similar city 不只是單純的地名相似而已,應該也要包括相似的地理位置、生活習性、交通便利度…等。以下舉幾個例子大家應該就懂意思了。

  • 九份:應該算基隆、新北、台北哪個城市呢?
  • 淡水老街:應該算新北、台北哪個城市呢?
  • 長庚醫院:應該算林口、桃園、龜山、新莊、蘆竹、迴龍哪個城市呢?

從上面這些例子就知道了,其實還有很多可以改善的空間,最重要的是要把生活圈也加到 similar city 才行,還有很大的進步空間啊!

參考資料

Funliday 的 tech blog 開張啦!

假日想說來做一個 tech blog,將小編之前在個人粉絲頁關於 Funliday 的技術文都整理上去,這樣子也比較容易擴散,以後關於文章主文也只會在這個 blog 裡面撰寫。

後來用了 GitHub pages+hexo 來處理,只要用 markdown 來寫文章就可以了,然後串接 Travis CI,push 到 GitHub 就直接發佈出去。而且客製化高,對於寫技術文來說,是個非常好的選擇!

雖然現在功能只有 RSS 而已,但有空會再來把其他必備功能 (像是留言) 加上去,總之快來訂閱一下啊!

解決瞬時大流量的 GET 請求

最近 Funliday 常發一些精選旅遊回憶的 App 通知給使用者,在去年十一二月的時候發通知 Server 還能撐的了瞬時大流量的 request。

但今年開始發這類通知,總共發了三次,三次都造成 Server 被打掛,而且重開 AP 還緩解不了,瞬間手足無措。大概都要等過了十分鐘左右,Server 才將這些 request 消化完。

這裡就來簡單整理一下時間軸,順便分享一下 Funliday 是如何解決這個問題。


  • 1/6 1900:系統排程發送精選旅遊回憶的 App 通知
  • 1/6 1900+10s 開始:Server 收到極大量的 request
  • 1/6 1900+20s:Nginx 出現錯誤訊息 1024 worker not enough,並回傳 http status code 503
  • 1/6 1900+25s:PostgreSQL 出現錯誤訊息 could not fork new process for connection (cannot allocate memory)
  • 1/6 1900+38s:Node.js 收到 PostgreSQL 的 exception。There was an error establishing an SSL connection error
  • 1/6 1900+69s:PostgreSQL 出現錯誤訊息 database system is shut down
  • 1/6 1900+546s:PostgreSQL 出現錯誤訊息 the database system is starting up

看了時間軸就覺得奇怪,先不論 10s 的時候發了極大量 request,造成 20s 在 Nginx 出現 worker not enough 的錯誤訊息。而是要關注 25s 時的 PostgreSQL 出現 could not fork new process for connection 的錯誤訊息。

Funliday 用了同時可承載 n 個 connection 的資料庫,而且程式碼又有加上 connection pool,理論上根本不該出現這個錯誤訊息。但整個時間軸看下來感覺就是 PostgreSQL 的 capacity 問題,造成系統無法運作。

因為就算將 Nginx 的 worker connection size 再加大 10 倍,只是造成 PostgreSQL 要接受的 request 也跟著被加大 10 倍,但 PostgreSQL 那裡因為 request 變多,原本在 69s 直接關機的時間點只會提早,而無法真正緩解這個狀況。

基於以上狀況,小編就開始回去看自己的程式碼是不是哪裡寫錯了。會這樣想也是覺得 PostgreSQL 應該沒這麼弱,一下就被打掛,一定是自己程式碼的問題 Orz


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const express = require("express");
const { Pool } = require("pg");

const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: true
});

const router = express.Router();

router.get("/", async (req, res) => {
const results = (await pool.query(QueryString.GET_ALL_DATA)).rows;

return res.success(results);
});

這邊來分享一下自己程式碼的寫法,上面是原始寫法,在每個 API 都 create 一個 db client instance 來處理該 API 層的所有 db request。這是蠻單純的做法,也是 day 1 開始的處理方式。但有個小問題,就是每個 API 層都要自己 create instance,不好管理,且浪費資源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// inject-db.js
const express = require("express");
const { Pool } = require("pg");

function InjectDb(req, res, next) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: true
});

req.pool = pool;

next();
}

module.exports = { InjectDb };

// poi-recommend.js
const express = require("express");

const router = express.Router();

router.get("/", async (req, res) => {
const results = (await req.pool.query(QueryString.GET_ALL_DATA)).rows;

return res.success(results);
});

後來因為想要做 graceful shutdown 的關係,所以調整了一下 db client instance 的建立方式,用 inject 將 instance 綁在 request 上面,如第二段程式碼。這樣只要在 middleware (inject-db.js) 建立 db client instance 就好,好管理,而且只要有 req 就可以取得 instance (poi-recommend.js),非常方便。而這也是 1/6 時的程式碼,就從這裡開始研究吧。


直接切入 node-postgres 的文件,認真讀了一下 pool 有下面兩種使用方式:

  1. pool.connect, pool.release:文件寫著 checkout, use, and return,光看描述就應該用這個沒錯。
  2. pool.query:適用於不需要 pool 的連線方式,文件上也清楚寫著內部實作是直接 call client.query,所以用了這個方式是完全跟 pool 扯不上邊。

但偏偏小編從 day 1 用的就是第 2 種方式 Orz,雖然看起來應該是寫錯,但也是要修改後實測,才知道是不是真的可以解決問題。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// inject-db.js
const express = require("express");
const { Pool } = require("pg");

const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: true,
});

function InjectDb(req, res, next) {
req.pool = {
query: async (...args) => {
const client = await pool.connect();

let results;

try {
results = await client.query(...args);
} finally {
client.release();
}

return results;
},
};

next();
}

module.exports = { InjectDb };

// poi-recommend.js
const express = require("express");

const router = express.Router();

router.get("/", async (req, res) => {
const results = (await req.pool.query(QueryString.GET_ALL_DATA)).rows;

return res.success(results);
});

如上面,這是修改後的程式碼。想了一下子,覺得目前在 API 層使用 req.pool.query 還不錯,不想用官方的建議做法:先 create client,然後 query 之後,再使用 release。

如果照官方建議做法,API 層的程式碼會多一堆與商業邏輯無關的程式碼,也不好維護。所以在不想動到 API 層的程式碼,只能使用 monkey patch 的方式來達到這個需求。

monkey patch 可以將原方法利用類似 override 的方式,將整個方法改掉,而不改變 caller 的程式碼,這也是 JavaScript, Ruby, Python 這類動態語言的特性之一,但真的要慎用,一不小心就會把原方法改成完全不同意義的方法了。

所以原本應該要在 API 層實作 connect, query, release 一大堆程式碼,可以用 monkey patch 完美解決這一大堆程式碼。


在 dev 壓測後至少 capacity 可以達到原本的 4 倍以上,隔天實際上 production 之後也確實如壓測般的數據,可以承載目前的流量。

其實這篇分享的重點只有一點,文件看仔細才是最重要的事啦!如果沒把文件看仔細,然後開發經驗也不足的話,什麼 RCA、monkey patch 都幫不上忙啦!


後記:有夠丟臉,其實完全用不到第三種的寫法,只要把第二種的 pool creation 放到最外層就好了,因為 pool.query 的內部實作已經有做 connect, query, release 了。

感謝 FB 的 Mark T. W. Lin 及 Rui An Huang 的幫忙,實在是太搞笑了 Orz

在 PostgreSQL.TW 分享的「大解密!用 PostgreSQL 提升 350 倍的 Funliday 推薦景點計算速度」

今年最後一次的公開演講,一年講四次不一樣的題目真的很累人 XDDD。這次到 PostgreSQL.TW 來分享一下 Funliday-旅遊規劃 是如何做推薦景點的,也是之前筆記的投影片版本。

這兩年推薦景點的改版學到不少東西,除了投影片裡面提到的技術面,其他部分小編也來分享一下:

  1. 資料庫是存資料用的,不要陷入正規化迷思
  2. APM 很好用,建議 day 1 就加上去
  3. 即時是相對的,不強求每次都取得最新資料
  4. 技術很重要,但技術是滿足業務需求
  5. 定期檢視原程式碼,有時問題是資料量大才會出現

PS. 沒想到這次場地的仁愛路小樹屋,旁邊居然就是 iCHEF 資廚 的新辦公室,真是太巧了 XD

在 MOPCON 2020 分享的「如何使用 iframe 製作一個易於更新及更安全的前端套件」

這是小編在這次 MOPCON 分享的內容「如何使用 iframe 製作一個易於更新及更安全的前端套件」,來聽的人跟預期的差不多,應該都被吸去 R1 聽游舒帆大大的分享了 XD。

不過要反省一下,這個題目已經改過一次了,但還是取的不好,這個確實要好好檢討。雖然改名後的題目名為「前端套件」,但內容主要是在講後端的安全性,要對前端做些保護,主要有下面幾點:

  • CORS:要加上 Access-Control-Allow-Origin
  • api key 外流:加上 referer 做允許名單的驗證
  • 網域偽造:使用 HTTPS 及 HSTS

最後分享了如何把網域加到 HSTS preload list 以及目前還在草案中的 SVCB/HTTPS 如何解決首次 HTTP request 的問題 (又是看 DK 大神的文章學知識 XD)

另外,這個套件還沒公開出來,關心這個套件的開發狀況,歡迎隨時關注我們喔!

如果十一月的 PostgreSQL 小講堂沒有入選的話,這應該就是今年的最後一個公開演講了,今年一次投了三個分享都有上,真的要感謝各大研討會的議程委員會。但真的是累死,之後一年最多還是投兩個就好,要不然光行前準備就花很多時間,累死了 XDDD

在 ModernWeb 2020 分享的「pppr - 解決 JavaScript 無法被搜尋引擎正確索引的問題」

這次在 Modern Web 分享的 pppr 就是之前在處理 SSR 所開發的一個後端套件,可以讓前端工程師不用改任何一行程式碼,純粹在後端就可以把這個難搞的問題完美解決。

內容包括 CSR 及 SSR 的比較,以及 pppr 在開發時所遇到的問題,以及如何解決這些問題。如果前端在處理 SSR 時遇到不少問題,可以來試試 pppr,一定會讓你節省不少時間!

影片

再度最佳化推薦景點的回應時間

現在大家使用 Funliday 在「景點瀏覽」輸入城市,或者是移動地圖出現的推薦景點,應該都是即時出現,也是最近花了一些時間的成果。下面先筆記一下推薦景點開發的歷程,之後如果有機會應該可以到 PostgreSQL.TW 分享一下完整的內容。

簡單來說,就是一個從 70000ms 的 response time,提升到 200ms 的 response time 過程。


2019/02 推薦景點第一版

如何用 PostgreSQL 的 advisory lock 實作推薦景點

2019/09 late 加上使用者查詢的 poi history 到推薦景點

  1. 如果該 city 最近 30 天的 poi history 景點超過 10 個,則直接使用
  2. 如果該 city 最近 30 天的 poi history 景點小於等於 10 個,則加上 KNN+rating 算法所取出的 poi 一起回傳給使用者
  3. 使用網路上找到的 uniq,將 poi history 的景點及 KNN+rating 算法的景點去除重覆資料
  4. 會將計算後取出的 poi id 存入 Redis,TTL 為 1 天

2020/06 late 加快查詢速度

  1. 將原本要從 poi_data (超過 2500 萬筆的 POI) 取得 city 的 bbox,改從 city_data (約 200 萬筆的 city) 取得,加快查詢速度

2020/07 late 刪除不正確的 poi

  1. 刪除該 city 經過 poi history 計算後 count 為 0 的 poi
  2. 刪除部分不該出現在推薦景點上的景點類型,如:飲水機、球場、停車場…等
  3. 將 KNN+rating 的景點 TTL 設定為 14 天,而從 poi history 計算出來的景點 TTL 一樣為 1 天

2020/09 late 重構推薦景點功能,並加上 L2, L3 cache

2020/09/22 late 重構推薦景點功能

  1. 先從 Redis 取出確認該 city 是否有 cache,有的話就直接回傳,沒有的話寫入 refresh = true 的 flag 到 city_data table,表示該 city 資料必須更新
  2. 從 DB 取出確認該 city 是否有 cache,有的話就直接回傳,沒有的話就必須即時計算該 city 的推薦景點
  3. 推薦景點的算法與之前相同,包括了 poi history 及 KNN+rating,但移除 uniq,改用 Set 的寫法去除重覆資料
  4. 計算完該 city 的推薦景點之後,直接將資料存回 Redis 及 DB,並且回傳計算完的推薦景點
  5. 期望這樣跑下來後,之後使用者就算在 Redis 找不到 cache (因為過期),也可以從 DB 找到 cache (因為不會消失)
  6. 每天固定跑 scheduler,掃出 refresh flag 為 true 的 city,在 background job 將最新的推薦景點寫回 Redis 及 DB,並將 refresh flag 設定為 false

2020/09/23 early 加快預熱 cache 到 DB 的速度

  1. 因為目前在 Redis 還有部分 city 的 cache,如果要等一天 (TTL) 之後這些 cache 失效後才能啟動 L2 cache 機制會有點浪費
  2. 所以增加若在 Redis 確認該 city 有 cache 且 DB 沒 cache 的話,就將該 cache 寫回 DB,並且回傳

2020/09/23 middle 確認是否有最佳化空間

  1. 延長 city Redis cache 的 TTL 從 1 天增加到 14 天,避免部分活動過於熱門,導致該區域的推薦景點都被該活動洗掉

2020/09/24 early 加上 new relic

  1. 加上 new relic 的 custom attributes,記錄該 city 的 IO 相關讀取時間

2020/09/24 middle 加上 local cache

  1. 因為從 Redis 或 DB 取回來的 id 要再經過一次 DB 查詢,才能組合完整的 response 回傳
  2. 所以加上 city id 及使用者語言做為 local cache key,加快同 city id 且同語言的資料回傳速度
  3. 如果 city id 或使用者語言在該 cache 找不到,再回 DB 查詢
  4. 加上 new relic 的 custom segment,方便判讀 request 的瓶頸是在哪一段

2020/09/24 late 調整 poi history 的執行速度

  1. 將原本使用 where + group 取得最近 30 的 poi history 語法,改為先做 where,再做 group,將原本放在同一個 CTE 的 query 拆成兩個 CTE 執行
  2. 速度從原本的 40 ~ 50 秒,甚至到 70 秒,下降到約 10 秒左右

2020/09/25 early 移除不必要的 middleware

  1. 在整整一年前將 poi recommend 及 search 拆開成不同檔案的時候,沒有仔細確認,所以每次 request 都要到 Redis 做兩次的 auth check
  2. 所以將其中一個 auth check 拿掉,加快約 1ms

2020/09/27 early 再次檢視 poi history 計算方式是否有最佳化空間

  1. 原本在使用經緯度計算是哪個 city 時,查詢速度大約都 400ms 左右,加上 GiST index 之後,查詢速度提升 1000x,變成只要 0.4ms 就能算出來
  2. 整整一年前導入 poi history 到推薦景點時,資料量還沒這麼大,所以在計算時速度都蠻快的
  3. 但 poi history 現在資料筆數已經超過 2500 萬筆,都變成 Seq Scan,雖然 planner 會判斷成 Parallel Seq Scan,但速度還是很慢
  4. 因為是使用近 30 天的資料做計算,所以將 created_at 加入 index,速度從 10 秒左右降到 1 秒左右