在 COSCUP 2021 分享的「使用 PostgreSQL 及 MongoDB 從零開始建置社群必備的按讚追蹤功能」

這是今年小編在 COSCUP 上分享的內容,「使用 PostgreSQL 及 MongoDB 從零開始建置社群必備的按讚追蹤功能」,分享了 Funliday 在建置「按讚」、「追蹤」的實作經驗。

其中包括了 PostgreSQL table、Redlock key、MongoDB document 的設計思路,以及如何使用 BullMQ 來串接不同的服務,以提昇效能。


其實這場裡面分享的一些做法,不一定適合每個情境,像是反正規化因為要同步許多欄位,在需要精準控制 table 狀態的情境就要花很大功夫處理,但這也是在使用 NoSQL 時的必學課題。

另外 lock key 也是在處理同步時一個很重要的設計,先搞懂要解決的問題是哪一種,如果想解決的是同一篇文章不能在 lock 期間給不同的使用者按讚,那 key 就要設計為 journalid_like 才對。


因為疫情關係,所以這次的 COSCUP 改在線上進行,使用 Gather town 也是一個蠻有趣的嘗試。但還是希望疫情能趕快過去,全世界回到正常生活吧!

References

用 Redis 來處理 City 的 autocomplete 功能 - 4

最近一直在改 autocomplete,對於使用者的體驗應該有蠻明顯的提升,所以繼兩年半後,來發第四篇關於 autocomplete 的文章。

1. 照使用次數排序

在 autocomplete 功能上線的第一天,排序一直以來是用字典排序,但如果有熱度排行的話,這樣子對使用者會更友善。而 Funliday 本來就有記錄了每次使用者選取了哪個 city,這樣子我們就可以用這些資料來做 city 的熱度排序。

所以當使用者輸入「中山區」的時候,就有可能會是「基隆中山區」跟「台北中山區」互爭第一,而不是一直固定的順序。當然,這個的實作方式前提就是要把熱度存進 metadata 裡面才行,這個部分可以參考第二篇的內容喔。


2. 加上別名 (alias)

直接舉例,很少人會打「我要去板橋區逛夜市」,應該都是打「我要去板橋逛夜市」吧?在 Funliday 裡面,資料庫存的都是官方名稱「板橋區」、「花蓮縣」,但使用者一般不會直接打全名,能讓使用者少輸入就儘量少輸入,所以有了 alias 這個機制的產生。

1
2
3
4

板橋
板橋山*123457
板橋區*123456

照原本的邏輯來看,如果輸入「板橋」的時候,candidate 會照順序顯示「板橋山」、「板橋區」,這也是之前提到的 Sorted Set。但加了 alias 的邏輯之後,則會照順序顯示為「板橋」、「板橋山」,儲存方式會變下面這樣:

1
2
3
4
5

板橋
板橋*123456
板橋山*123457
板橋區*123456

使用者對於板橋到底是「市」還是「區」根本不在意,所以需要有個 alias 可以顯示出來,邏輯會變這樣:

graph TD;
    A[開始] --> B[將內容使用 id 做分組];
    B --> C{判斷單一組裡面的個數有幾個};
    C -->|只有一個| D[直接加入 candidate list];
    C -->|超過一個| E{判斷裡面有沒有 alias};
    E -->|有| F[把 alias 加入 candidate list];
    E -->|沒有| G[走原本的邏輯];
    D --> END;
    F --> END;
    G --> END;
    END[結束];

改用上面這種邏輯之後,使用者只要輸入「板橋」就能避免像是「板橋山」會在「板橋區」前面顯示的問題,也可以讓使用者少輸入一些字就能在第一筆找到他要的內容了。


3. 照目前國家排序

其實相同城市名的狀況在許多國家都有出現,以台日舉例,台灣有三重區、松山區、台東市,日本有三重縣、松山市、台東區。Funliday 是全球化的服務,如果照字典排序的話,至少會有一個國家的使用者不滿意,所以為了解決這個問題,我們拿了使用者的「所在國家」來做應用。

若使用者同意使用定位功能的話,我們就會把所在國家的城市排序在前面,這是因為我們認為使用者對於自己國家的城市名比較有親切感,如果其他國家的相同城市名顯示在最上面,可能會有點困惑。


4. 將全球百大城市往前排

跟上一點的問題一樣,因為全世界同名的城市實在太多了,在實作時我們發現光是 San Francisco 就有超過十個 (包括義大利、巴拉圭、阿根廷、瓜地馬拉…),而廣為人知是美國的舊金山,於是我們想到或許將全球百大城市排序往前排是一個不錯的方式。

相同情況也發生在 Paris,有美國、英國、加拿大,但大家應該對法國的比較熟悉。而這個功能的實作方式就是先定義好哪些是百大城市,然後直接放在 autocomplete 的 metadata 裡面,等到取出來之後要排序時,再依照百大城市來排,這樣可以確保百大城市會排在最上面。

不過這樣的實作方式會有一個重大的問題,假設美國的 san francisco 在 Sorted Set 是排在其他國家的後面,像下面這樣子,然後取資料時只取 3 筆,那就無論如何都找不到美國的 san francisco。

1
2
3
4
san francisco*123456
san francisco*226857
san francisco*423896
san francisco*498437 => 美國舊金山

雖然目前好像還沒遇到這個問題,不過也已經先想好了解法,就是在使用 ZADD 塞資料到 Redis 的時候,可以將這筆資料的分數提高讓資料可以排在前面,避免無法取得資料。


5. 調整分隔符號

大家看到這幾篇內容應該都有發現,我們的 autocomplete 格式是 name*id,用 * 來做為分隔符號。但會遇到下面的這個問題:

1
2
3
4
5
6
san francisco*123456
san francisco*226857
san francisco*423896
san francisco*498437 => 美國舊金山
san*45632 => 城市 A
san*67545 => 城市 B

如果只輸入 san 的話,理論上應該要先顯示最下面兩個 san city 才對,但因為我們用了 * 做分隔符號,而 (空格) (0x20) 在 ASCII code 裡面是排在 * (0x2A) 前面的,所以造成如果只取得四筆資料,這樣子是永遠拿不到理應在最前面的「城市 A」和「城市 B」了。

所以我們只能改分隔符號,想了一下比 (空格) 還要更適合,而且字典排序又在它前面的,那應該就是 (NULL) (0x00) 了,改用 (NULL) 之後,儲存內容會變成下面這樣:

1
2
3
4
5
6
san\x0045632 => 城市 A
san\x0067545 => 城市 B
san francisco\x00123456
san francisco\x00226857
san francisco\x00423896
san francisco\x00498437 => 美國舊金山

避免歐美語系常用多個單字加上空格結合成一個城市名,但無法找到單名城市名的狀況。而回到 Node.js 的部分,分隔符號既然改成了不可視字元,那我們的程式也要調整成下面這樣子才行:

1
2
3
4
5
// BEFORE
const beforeParts = name.split("*");

// AFTER
const afterParts = name.split("\u0000");

6. 加上 parent city

簡單舉個例,畫面上如果顯示成下面這樣,有人看的懂嗎?

1
2
中山區, 台灣
中山區, 台灣

其實這也是一開始設計的時候沒處理好,這樣子真的不知道是「台北市的中山區」,還是「基隆市的中山區」,所以我們最近把 parent city 也加上去了,所以會變成這樣子顯示:

1
2
中山區, 基隆市, 台灣
中山區, 台北市, 台灣

其實我們的許多資料都是從 Open data 來的,但 parent city 這個就不一定全世界都有,所以我們由土法煉鋼的方式。原則上 candidate city 應該會被 parent city 的範圍包住,所以用 PostGIS 的 ST_Intersects 來計算全世界的 parent city,這個也跑了好幾天才跑完。

但因為 Open data 的 boundary box 不一定很準確,相對的 parent city 也是有一些錯誤。這段就真的只能透過工人智慧來解決問題了。


7. 移除連續重複名稱

這個主要是從新加坡的需求而來的,新加坡既是國家,也是城市。但顯示出來就會變成下面這樣:

1
新加坡, 新加坡

這樣子真的有點奇怪,所以我們想了一個方式,目前既然有了 city, parent, country 這三層的結構,所以如果有任兩個連續一樣名稱的話,就只留一個就好,調整後會變下面這樣子:

1
新加坡

這樣使用者應該也比較不容易困惑。


洋洋灑灑列了 7 大調整,雖然有些功能還沒上 production,但這樣的修改應該可以讓使用者的體驗有感提升才對!


References

如何最佳化中日文的關鍵字搜尋 - 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