目前 Funliday 打過的怪

這是篇很棒的大型系統演進史,是後端工程師一定要看的文章。

其實台灣很多技術文章,但屬於這類偏大型系統演進史的文章,依比例來說正體中文是少之又少,看到的大都是簡體中文的內容。最近看到的另一篇是 PressPlay 從 AWS 轉到 GCP 的分享。

後端是一個看到什麼怪就打什麼怪的開發模式,很難一次把架構做到位。

  • 熱門排行讀取速度太慢?就加 local cache
  • 熱門排行內容不一致?把 cache 改成 redis
  • 圖片讀取速度太慢?就加 CDN
  • 圖片檔案太大?就加 resize server
  • 圖片上傳太慢?MQ+redis+notification
  • 一般搜尋太慢?開 explain 加 index
  • 一般搜尋不精確?加 ElasticSearch

隨便列幾個就是目前 Funliday 打過的怪,而且還遠遠不止咧 Orz

有時候需求端也要懂得妥協,什麼功能都要的話就是錢要夠,時間要夠。天下真的沒有白吃的午餐啊!

PS. 如果明年有機會的話,也希望可以在研討會分享一下 Funliday 這兩年來的技術演進史

POI Bank 正式公布!

小編到 Funliday 之後就是專注在 POI Bank 的開發,這篇文章出來後,總算可以公布 POI Bank 這個名稱了。年前最後一個工作天把 POI Bank 弄上線真的要有很大的勇氣,過年期間果然被使用者罵很慘,搜尋搜不到、沒有圖片、一堆景點名稱都是羅馬拼音…族繁不及備載。

除夕當天除了吃年夜飯跟打掃家裡以外,全部時間都拿來轉檔跟調整演算法,想辦法讓搜尋更精確,春節期間真的是整個團隊都沒好好過年。但也是這樣的壓力測試,讓 Funliday 可以快速迭代!

不過過完年後看到使用者操作的真實數據後,發現 POI Bank 真的是大有可為,就像文內所舉的其中一例:

「舉個例子,若我們從觀察用戶的數據資料,發現這個假期許多人計畫去某觀光景點,也許就能跟該觀光景點合作,提早讓店主掌握狀況預先處理,提供更好的旅遊品質給民眾。」

其實 POI Bank 能做到的遠遠不只這個,但就讓 Funliday 賣個關子吧 XDDD


這裡順便來聊一下搜尋精準度的問題,整整一個月除了上星期小編去東京考察以外,每天都在調整精準度。

這星期回來重新思考跟測試後,發現根本就是斷詞的問題,雖然用了大家都推薦的 jieba 結巴斷詞,但沒用台灣詞庫,斷出來的結果真的是慘不忍睹。

這週二 (2/26) 把用了台灣詞庫的搜尋丟上去之後,情況看來好了很多。因為搜尋精準度太難用程式量化了,所以現在加了 ab testing 上去,每幾天用工人智慧看一下大家的使用狀況後,再持續最佳化下去了。


因為沒心力做行銷,Funliday 現在只能口耳相傳,一傳十十傳百,希望沒使用過 Funliday 的朋友看到這篇文章之後,能下載來用用看,對你出門旅遊絕對有很大的幫助,有使用過的朋友,也幫忙多拉幾個朋友來使用。

Android 的共同編輯跟 Web 完整版就快要上線了,希望大家以後多多支持 Funliday 啦!

如何用 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 可不只有這樣而已,剩下的細節等下次有空再來分享一下好了。