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

Funliday 最近做了大改版,其中一項功能就是把去年中因為 Google 要開始收費而暫時拿掉的「推薦景點」加回來,這個功能就是提供使用者指定區域附近評價較高的景點。

大家現在在使用景點瀏覽時,應該有時候會發現資料出來的速度不一致,慢的時候 (超過 5 秒,有時候會落在 10 秒以上) 代表可能有其他使用者正在查詢這個區域的「推薦景點」,思考邏輯就是在同一區域只要有一個使用者查詢過這個區域的「推薦景點」,其他同區域的使用者就會因為已經查詢過這份資料 (如熱門景點:台北 101、東京晴空塔…等) 而受惠。

在目前小編能力還無法將查詢效能大幅改善的狀況之下 Orz,若同一個區域有多個使用者同時查詢,每個查詢都要花費 10 秒的話,這樣子資料庫會浪費超多時間在不必要的查詢上面。

像這類無法避免的使用者行為,但又要減少資料庫無謂的同樣操作時,小編突然想到之前 Triton Ho 在上課時提到的 exclusive lock。

Advisory lock 是 PostgreSQL 的一種 lock 機制,可以在 application 層操作 exclusive lock (以下稱為 X lock) 或是 shared lock (以下稱為 S lock)。當使用 X lock 時,若非同一個 session 是無法將它 unlock,而 S lock 則是任何一個 session 都可以用 S lock 拿取 (acquire) 相同的 lock,但不可以用 X lock 拿取 S lock。

依小編的這個使用情境,可以在查詢「推薦景點」之前先問一次 Redis 有沒有這個區域的資料,有的話直接從 Redis 取得,沒有的話就先到 PostgreSQL acquire 一次 X lock,如果可以成功的話,則表示還沒有其他 session 正在查詢這個區域的資料,所以可以在 acquire 之後直接查詢,接著再 unlock 這個 X lock;但如果無法 acquire 的話,則表示目前有其他 session 正在查,所以直接回 client 查詢中的訊息,可以等一下再查。

所以小編使用 PostgreSQL 的 advisory lock 及 Redis 就可以達成不會有同一時間查詢相同耗時的查詢了。

其實再好一點的作法可能是把這類耗時的查詢丟到 MQ 裡面,讓 response 先回 client,等到 MQ 做完這個 job 之後,再用 push notification 通知 client 到 Redis 取得已經計算完的資料。或是用 polling 固定 n 秒鐘查詢一次,這都要視你的基礎設施而定。小編現在是先用 polling 的方式簡單處理,雖然方法有點笨,但在沒多餘心力 (Elasticsearch 太博大精深了 Orz) 的狀態之下就先這樣做了。

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

前兩篇分享了 Autocomplete 的實作方式及開發細節,算是少數大家迴響比較多的文章 XDD,下面就來整理一下大家的迴響好了。


1. 減少傳輸量可以使用 msgpack

小編有聽過 msgpack 但還沒實際了解這是如何運作的。剛查了一下資料,說是比 JSON 更省資料大小,基本上聽過的語言都有支援。

在前公司也用過 Avro 這類的格式,主打的也是省資料大小。但現在應該還不會考慮改用這類要另外做 serialize 的格式。

主要是基於後端是以 Node.js 為主開發,JSON 已經是原生支援,再引入一種資料格式會增加前後端維護的複雜度。另外就是開發人力,新創小公司要儘量減少工作,目前可以順暢運作就好,還有其他更重要的事要做,等之後用量大了再改也不遲。

2. 減少傳輸量可以使用 HTTP server 的壓縮機制

這真的是忽略了,忘了 expressjs 只是一套 web framework,在上面對資料做壓縮其實會影響到效率。讓如 nginx 之類的 HTTP server 做壓縮應該才是更好的作法。

不過因為現在的 infra 是建在 heroku 上面,heroku 並沒有原生 nginx 的支援。等量大撐不住的時候,倒是可以優先考慮使用 heroku 的 buildpack 把 nginx 架上去試試

另外也有提到用 CDN 做動態壓縮,這就真的沒做過了,也是可以研究的方向之一。

3. 減少使用者打 server 的次數,加上 debounce time

這大家都主推使用 debounce 方式,前端沒玩很深的小編第一次碰到這個名詞是高職的時候。記得那時上課在教 8051,老師說按按鈕時要加上 15 - 20ms 的 debounce time,避免重複送外部中斷。小編對單晶片實在不在行,但大概記得是這個意思。

剛查了一下資料,前端的 debounce time 大概也是類似的意思。在輸入文字後,會 delay n 秒再送出,若是在 n 秒內又有打其他內容的時候,就把之前的 request 從 queue 裡面丟棄,只關注最後一次的 request 就好。

這個應該也是有效減少 request 量的作法了。

4. 減少使用者打 request 的次數,將已經送出的 request 取消掉

這也是一個不錯的作法,若 A request 已經送出去,但還沒回 response 時又送了 B request 的話,此時可以把 A request 取消。

但要注意就是 A request 目前正在執行的步驟是去 DB 拿資料,或是在 server 本身處理一些基本計算。之前在使用 Java (grizzly + jersey) 開發的時候,若有這種情況發生會常在 log 裡面看到 IOException。

原因是 server 已經準備好資料要回傳給 client,但發現 A request 已經取消,不知道要怎麼回傳時就會發生這個狀況。但也有可能是小編自己沒控制好收發的關係啦 XD


關於 Autocomplete 的三篇大概就到這篇為止啦,等上線之後做了哪些調整再來分享給大家知道一下。

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

前一篇 提到了 Autocomplete 的實作方式,但仍然有許多可以調整的地方,像是如何加大 throughput、帶額外資料…等,下面就來分享一下小編的作法。


1. 減少傳輸量

因為 Autocomplete 的操作行為是使用者每打一個字,就要傳給 server,server 再回傳使用者一些 candidate。所以減少傳輸量是最先要處理的事情,要不然資料量太大傳輸慢會影響前端使用體驗。最簡單的作法就是改變原本回傳的 JSON 格式,如下所示:

調整前

1
2
3
4
5
[
{"id": 123, "candidate": "taipei"},
{"id": 456, "candidate": "taiwan"},
{"id": 789, "candidate": "tall"}
]

調整後

1
["123%taipei","456%taiwan","789%tall"]

前端拿到資料後自己再用 split 的方式分割字串,實測下來大概可以減少 40% 的資料量。

2. 減少傳輸量

沒錯!第二點也是減少傳輸量,將準備要回傳的資料用 gzip 壓縮後再回傳。

以 expressjs 本身建議的 compression 套件來說,實測下來發揮不了什麼作用。因為 compression 套件預設為資料量大於 1kb 才會做壓縮,而目前的資料已經是小於 1kb 了,所以沒做任何壓縮就直接回傳。

另外還發現加了 compression 套件之後,以目前開的 heroku 機器來說,回應時間會加上 5-10ms 左右。不過現在服務還沒上線,沒有使用量都不準,等上線之後再來觀察看看好了。

3. 減少使用者打 server 的次數

前端可以在輸入一個字元的時候不要送 request 給 server,因為經驗法則,使用者應該至少會打兩個字元之後,Autocomplete 回應給使用者 candidate,這樣對 UX 上應該會比較好吧 (小編不專業分析 XD)。不止可以降低 server 的 loading,也可以減少存入 Redis 的資料量。

但這會牽涉到 CJK 與 non-CJK 的處理方式,這就還要再看看如何處理比較好。

4. 減少使用者打 server 的次數

沒錯!又是減少次數。client 可以在 server 回傳資料的時候,將資料暫存在 client 的記憶體內。因為常會有輸入相同文字的時候,這時就可以直接從 client 的記憶體取出資料,就不用打到 server 了。

但這個使用方式比較不好處理,需視情境而定。若是 Redis 的資料常常在變動,那這個方式會造成取不回最新的資料。或許可以在 client 放個 LRU cache 來做處理。

5. 減少使用者打 server 的次數

又是我 XDDD!這次是要 server 幫忙,當 client 重複輸入相同 keyword 時,client 會帶 If-None-Match 的 header 給 server,server 會檢查這串值是否已經有打過了,如果打過就回 client 304,表示資料沒變動,可以直接用 client 本身的資料。

這在之前的 JCConf 有分享 (https://www.facebook.com/kewang.information/posts/2192127034396992) 過,大家可以回去翻一下。

6. 減少 Redis 的資料量

西方國家所用的拉丁字母除了大家常用的 26 個英文字母外,也常會有一些包括重音之類的字母。像是 a 及 á 之類的,這個在搜尋的時候不會太影響,JavaScript 可以利用 String.normalize(‘NFD’) 把 á 轉換成 aˊ,最後再將 ˊ 取代為空字串 (https://stackoverflow.com/a/37511463/939212),Redis 裡面只要存 a 就好,這樣可以節省不少資料量。

當然還有將大寫轉為小寫、trim 掉頭尾空白這幾種做法,也都可以省下不少資料量。

至於 CJK 的話,再說吧 XDDD

7. 存入 metadata

如果這個 Autocomplete 只是單純選擇 candidate 之後做搜尋,那可以不用存 metadata 進去。但有些功能其實是要把 candidate 回傳給 client 時,也帶一些 metadata 給 client 做其他運用,最常見的應該就是帶 id 這類 metadata 了。

最簡單的作法就是在存入 candidate 的時候,直接把要存的 metadata 帶在字尾,如下所示:

  1. t
  2. ta
  3. tai
  4. taiw
  5. taiwa
  6. taiwan
  7. taiwan*123

把 123 放在 taiwan 後面,在取出 candidate 的時候再利用 split 的方式把 taiwan 跟 123 分別取出就可以了。

總結

總結上面的幾種方式,目前小編這裡用到了 1, 2, 5, 6, 7 共五種,效果還不錯,就等上線再來看看實戰結果囉。

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

Autocomplete 在現在的應用程式已經是個不可或缺的功能,但這個功能因為要一直發 request 到 server 上,簡直就是 DDoS 了 XDDD,對 server 是個不小的負擔。一方面要讓功能正常快速的運作,一方面又要讓 server 不會被打掛,是個不容易做的好的功能。這篇就來分享一下 Redis 的作者 antirez 是如何運用 Redis 來達到這個功能。

Redis 是一個 in-memory database,讀寫的效率自然不在話下,做 Autocomplete 用這類資料庫是再正常不過了。Redis 裡面有個資料結構叫做 Sorted Set,只要塞資料 (ZADD) 進去,它就會幫你按照字母順序排列好。所以小編只要把要搜尋的資料 (這裡小編稱為 candidate) 分割成獨立的字元 (這裡小編稱為 keyword),存進 Sorted Set 就可以完成初步的資料處理。

比如想要找到 Taiwan 這個 candidate 的話就先把 Taiwan 拆成 t, ta, tai, taiw, taiwa, taiwan,把這六個 keyword 都存入 Sorted Set 裡面。最後再存入 taiwan*,表示這個 candidate 的結尾。所以 Sorted Set 的內容會變成下面這樣:


  1. t
  2. ta
  3. tai
  4. taip
  5. taipe
  6. taipei
  7. taipei*
  8. taiw
  9. taiwa
  10. taiwan
  11. taiwan*
  12. tal
  13. tall
  14. tall*

當使用者輸入 t,發送請求到 server 的時候,server 用 Redis 的搜尋指令 (ZRANK) 找出 index 為 1,然後再從 1 開始,將資料取回來 (ZRANGE) 50 筆,所以上面的 14 筆資料都會取回來。最後 server 再把這 14 筆結尾有 * 的資料過濾出來,剩下 7, 11, 14 這三筆,再把 * 濾掉回給使用者就完成這個功能了。

所以使用者輸入了 t,server 就會回給使用者 taipei, taiwan, tall 這三個 candidate,這就完成最簡單的 Autocomplete 功能。但 Autocomplete 可不只有這樣而已,剩下的細節等下次有空再來分享一下好了。