把 Web 底層重構,從前後端分離變成 SSR

Web 底層重構完成過了半年多,目前效果還算不錯,來分享一下我們是如何重構的。

前後端分離

Funliday 的初期開發模式,一直都是 backend 撰寫 API,然後 Web 及 App 都直接串接 API,達到真正前後端分離。這樣的開發模式無論對於前後端都非常單純,串接的 API 都是一模一樣,所以溝通成本也不高,那為什麼要重構?

為什麼要重構?

對於 App 來說,因為是原生系統,所以還是維持串接 API 為主。但 Web 如果還是用純 API 串接的話,其實會失去原本 Web 就有的優勢。

Web 原本純粹只使用 API 做前後端的溝通,所以每一次的初始畫面呈現都要經過好多次的 API call。像是我們的首頁,因為 API 是基於前後端分離所開發的,所以要分別取得熱門行程、熱門遊記、熱門城市…等,大概近 10 支 API。

然後每個畫面在第一次使用的時候,因為要取得初始參數,所以又要多一次 API call,這樣子來來回回,就算各個 API 互不相關,可以用 Promise.all 同時取得資料,但只要一支 API 因效能卡死。又或是有相關,前面死了就無法取得後面的內容,都會讓開發上變得更複雜。當然這些都能透過各種 design pattern 解決,但還是解決不了速度慢的問題。

而且 API call 是從使用者的瀏覽器發出,中間經過各種 latency 才到達,這個速度延遲可見一斑。更何況 Funliday 在上升期,SEO 真的是非常非常重要,雖然已經有 pppr 可以幫忙處理 prerender 的事情,但底層的 puppeteer 三不五時要不就是記憶體吃太多,要不就是暫存檔太肥,所以改成 SSR 是更重要的事。

SSR 開始動工

動架構是一件很重大的事,而且在人數不足的狀況之下,工作項更要謹慎評估。與前端工程師討論後,決定前端只做一個 adapter,把新流程用 adapter 接回舊流程解決。

如同上面所提的,在 web 執行第一次 React 動作的時候,會先去打 init API 取得所有參數後,再繼續後面步驟。而改成新流程之後,最主要的想法是解決 API 的 round-trip 問題,而且 web 也是使用 expressjs 做為後端框架,所以把 init API 取得所有參數的流程,直接在每一個 route 加上一個 middleware 處理就好。

init middleware

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function initMiddleware(req, res, next) {
let webToken = getCookie(req, "webToken");

console.log(`webToken: ${webToken}`);

if (!verifyWebToken(webToken)) {
webToken = generateWebToken();

setCookie(res, "webToken", webToken);
}

return next();
}

const getCookie = (req, name) => req.cookies[`fld-${name}`];

const setCookie = (res, name, value) => {
res.cookie(`fld-${name}`, value);
};

app.get("*", initMiddleware, appRouter);

把後端提供的 init API 改成在 web server 的 init middleware 處理,但前端要如何取得 middleware 的所有參數?那當然就是要找可以前後端共同存取的資料結構啦。

一開始想到的當然是 cookie,後端的 init middleware 寫入 cookie,前端的 init adapter 取得 cookie,然後再執行原本的工作流程。後端把 middleware 開發完後,塞了測試資料,前端也可以順利取得測試資料。但開發到後期,要真正把所有參數塞到 cookie 之後,卻發現了一個重大的問題,那就是每一個 cookie 的大小只能有 4096bytes 而已,這對於還要經過 base64 編碼後的參數大小根本不夠。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const setCookie = (res, name, value) => {
const stringifyValue = JSON.stringify(value);

// split value every 1000 chars if value's length > 2000
if (stringifyValue.length > 2000) {
console.log(`Use array cookie for ${name}`);

const valueArr = [];

for (let i = 0; i < stringifyValue.length; i += 1000) {
valueArr.push(stringifyValue.slice(i, i + 1000));
}

for (let i = 0; i < valueArr.length; i++) {
res.cookie(`fld-${name}-${i}`, valueArr[i]);
}

res.cookie(`fld-${name}-$`, valueArr.length);
} else {
res.cookie(`fld-${name}`, stringifyValue);
}
};

所以改良後的寫法變成把 cookie 分割,比如每 2000 bytes 切一刀,總共切成 n 個 chunk,然後前端取得資料的時候再合併起來後解碼。這個改良後的方式最後沒上線,因為原本前端只要關注取得實際的內容就好,結果現在卻因為 cookie 分割的關係,還要處理「合併」這個步驟。怎麼想都覺得奇怪,所以後來就把這個方案捨棄,不使用 cookie 了。

想到了 View State

1
2
3
4
5
6
<input
type="hidden"
name="__VIEWSTATE"
id="__VIEWSTATE"
value="QxHX4IaM9Z+otkbxCcwK...lNymmMdHoN+iO3PnA06vqcbm+JiQGvJNiqJTDNK918Tfnylm7Bdw1f83/GVw=="
/>

View State 是 ASP.net 在 WebForm 在保留控制項狀態時的解決方案,就是把狀態存在 <input type="hidden" /> 裡面,讓狀態可以帶到下一次的 request 裡面,我想到這方式剛好可以讓 HTML 做前後端共同存取。所以利用這方式,把原本在 init middeware 寫到 cookie 的內容,改成寫到 res.locals 裡面,然後再利用 res.render 把內容寫到 HTML 裡面

後端的 middleware

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
async function initMiddleware(req, res, next) {
const initParams = await getInit({
memberId,
language,
});

writeConfig(res, "initParams", initParams);

combineInitEnv(res);

return next();
}

const writeConfig = (res, name, value) => {
if (!Array.isArray(res.locals.xxxConfig)) {
res.locals.xxxConfig = [];
}

res.locals.xxxConfig.push({
name,
value,
});
};

const combineInitEnv = (res) => {
res.locals.xxxConfig = Buffer.from(
JSON.stringify(res.locals.xxxConfig)
).toString("base64");
};

後端的 view template

1
2
meta(charset="UTF-8")
meta(name='funliday-env' content=xxxConfig)

而前端的 adapter 就單純使用 document.querySelector 取得初始參數就可以了。adapter 剩下的工作就是把所取得的初始參數,一個一個接回去原本的流程就能結束工作。但這牽涉到的業務邏輯太多,這裡就不多提了。

前端的 adapter

1
2
3
const funlidayEnv = document.querySelector('meta[name="funliday-env"]');

initAdapter.parse(funlidayEnv.content);

結論

從規劃到真正實作完成,大約經歷了半年左右,雖然只完成部分的頁面,但成功從前後端分離轉型為 SSR,顯著提升了使用者體驗與 SEO 效能。這次重構不僅加速了頁面載入速度,還大幅提高了我們在搜尋引擎的排名,吸引更多訪客。雖然最終的解決方案沒有什麼了不起,只要有一點經驗的 Node.js 後端工程師都應該要做的出來,但穿著衣服改衣服,突破各種舊架構上的限制,成功上線後還是覺得蠻值得拿來說嘴的 XD

利用 Puppeteer 把行程轉換成 PDF 的實務經驗

Funliday 最近功能萬箭齊發,其中有幾項比較值得一提的,今天先來分享第一個,大家敲碗已久的行程轉成 PDF 功能是如何煉成的。

十年前在前公司有做過一個產險的案子,其中有一個功能是將保戶在 App 上填寫完的申請資料,轉成 PDF 存下來。雖然這個 PDF 功能不是我開發的,但記得當初的做法好像是用 iText 之類的 PDF library,把保戶填的資料塞進已經預先定義好的欄位裡面。

這種做法可以保證輸出時的版面不會受到影響,但對於一般的網頁開發者,門檻高了不少。因為網頁開發者還要了解從原始文件轉成 PDF 的過程,再加上預定義的欄位,完全沒使用網頁技術,總是覺得麻煩不少。

畢竟網頁開發者比較熟悉的還是 HTML+CSS,直接用網頁產生 PDF 的話,應該會更受到網頁開發者歡迎。過了這麼多年,直接用網頁產生 PDF 的開發工具也愈來愈多,現在當紅的應該就是 Puppeteer 跟 Playwright 了!總算進入正題,來分享一下我們是如何用 Puppeteer 產生 PDF 的。

最簡單使用 Puppeteer 的方式

Puppeteer 是一套 Headless Chrome 的 toolkit,Funliday 目前還在運作的 pppr (prerender engine) 也是使用 Puppeteer 開發的喔!而 Puppeteer 本身就有一行程式碼可以將目前讀取到的網頁,直接輸出成 PDF 內容。

1
2
3
4
5
await page.goto("https://www.funliday.com");

const output = await page.pdf(); // 就是這行啦

return res.set("Content-Type", "application/pdf").send(output);

只要三行程式碼就可以完成工作的話,那今天寫這篇真的是灌水灌大了!所以下面就來分享一下實務上有哪些要注意的地方。

減少 Google Chrome 記憶體使用量

Google Chrome 是一個非常吃記憶體的怪獸,我們也不想開太大的機器來服侍 Google Chrome,於是 CDN 就變的非常重要了。

使用者點擊 URL 的時候,會先去 CDN 問這個 URL 有沒有資料,有的話就不經過 origin server 直接回傳結果,減少 origin server 的負擔,沒有的話就先到 origin server 執行業務邏輯,再透過 CDN 回傳給使用者,以這裡的例子就是把網頁 render 成 PDF。

所以如果同一個 URL 丟到 LINE 的大群組之類的,短時間就不用擔心一堆人點 URL 造成 Google Chrome 吃一大堆記憶體重複 render。

但這裡有個額外的小細節,如果丟到社群媒體上,記得要多判斷 user agent,判斷如果是社群媒體的話,就顯示 PDF 的縮圖或想要呈現的內容,使用者體驗會更好。(但我們還沒做 XD)

加上驗證功能

1
PDF 網址:https://host/p/:userid/trips/:trip_id?member_id=:my_member_id&token=:my_auth_token

另外這個功能目前限制只有自己跟同群組的夥伴可以使用,所以在 URL 上有做了一點小處理。就是 URL 有帶了 member id 以及 auth token,如果有人隨意更動這兩個值的話,會造成驗證失敗,算是非常基本的驗證,目前需求也用不到嚴謹的驗證。如果要再嚴謹驗證的話,目前的想法應該是會再加上 cookie 驗證。

另外帶了 member id 之後,也可以拿來做統計,算是目前有限資源的最快開發方式。

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
async function CheckAuthMember(req, res, next) {
req.memberId = extractMemberId(req);

if (!req.memberId) {
return res.redirect(302, "https://www.funliday.com");
}

return next();
}

const extractMemberId = req => {
if (!req.query.member_id || !req.query.token) {
return "";
}

let decoded;

try {
decoded = jwt.verify(req.query.token, tokenSecret);

return req.query.member_id === decoded.member_id ? req.query.member_id : "";
} catch (err) {
console.error(err);

return "";
}
};

提升 render 速度

再來就是關於 render 的速度了,在 local 端做 render 一定是比 remote 端要快上許多,而且專案複雜度 local 也遠比 remote 要少許多,所以我們先使用 pug 在 local 做完 render 之後 (其實就是 server side render),直接開啟 file:///tmp/the-best-pdf.html,減少 remote 的資料傳輸,當 render 結束後再輸出成 PDF。最後記得要把 local 的 HTML 檔刪除喔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const page = await browser.newPage();

const content = pug.renderFile("pdf.pug", {
trip
});

const tmpFilename = `/tmp/${fileName}.html`;

await fs.writeFile(tmpFilename, content);

await page.goto(`file://${tmpFilename}`, {
waitUntil: ["load", "domcontentloaded", "networkidle2"]
});

const output = await page.pdf();

await page.close();

await fs.unlink(tmpFilename);

return res.set("Content-Type", "application/pdf").send(output);

提升開發效率

因為 PDF 的內容設計主要是依靠前端,所以可以與後端大致脫勾,這樣的開發方式大幅提升了我們 delivery 的效率,除非畫面上的元素有特殊要判斷的內容,否則前端設計完 PDF 之後就可以直接 push 到 master 上線使用。


這個功能在沒宣傳的狀態之下,每日使用者人數比預期的多,算是蠻開心的。另外往日本遊玩的台灣使用者,PDF 也會放入各種 coupon,最重要的當然就是 BIC CAMERA唐吉訶德啦,幫大家省錢之餘,也希望大家多多愛用這個功能喔!

用 App search 的 curation 概念解決熱門景點的搜尋問題

無論在哪個領域,「搜尋」這個議題,一直都是以讓使用者找到最適合的內容為最高原則。而 LBS 這類服務,如果要做搜尋通常都會再加上「經緯度」為變因,但常會遇到在「台北市」找不到「東京鐵塔」,這類我們想要讓使用者能夠直接搜尋到的景點,這篇文章就是要來分享 Funliday 如何解決這個問題。

小編今年買了一本喬叔的 Elasticsearch 實戰書籍 (非業配,但覺得真的不錯),看到裡面介紹了 App search,這其實就是把 Elasticsearch 再封裝之後的服務,把搜尋功能的開發難度降低許多。而其中的 curation 更是啟發小編,讓小編不會糾結在搜尋只能在一個索引上完成。

在接觸 curation 這個概念之前,小編一直認為搜尋就應該要在同樣一個索引上完成才對,沒有想到其實可以做兩次索引的操作。而 curation 其實就是將想要讓使用者特別找到的內容,放在另一個索引,搜尋時先搜這個索引,然後再搜原本的索引,就能達成想要的內容先出現,剩下的依序排在後面,這樣子也就達成依照熱門程度的搜尋結果。


一開始只是想做 autocomplete 熱門景點

在還沒開發熱門景點搜尋的時候,一開始其實只是想在畫面上用 autocomplete 讓使用者選擇熱門景點,而當初也不想大改原本的資料結構,所以就用 Redis 來做熱門景點的 POI autocomplete。

開發完後在 dev 環境測試沒發生問題,但是 production 環境在建立資料時,Redis 無論加到多少記憶體都不夠用,這是始料未及的。主要是 production 資料比較完整,所以要寫入到資料庫的內容相比就多很多。另外因為 Redis 是 in-memory database,跟硬碟相比記憶體又特別貴,所以只好捨棄 Redis,改用 Elasticsearch 來做。

雖然用 Elasticsearch 來做 autocomplete 會稍慢於 Redis,但在成本跟效率相比,這是目前最平衡的做法。

把 curation 概念套入 search 熱門景點

用 Elasticsearch 實作完 autocomplete 之後,又想到書上寫的 curation 這個概念,看了看現在的這個索引,好像可以用來做熱門景點的搜尋。

無論輸入的關鍵字是什麼,都先在熱門景點這個 index 搜尋,然後再進原本的 index 搜尋,這樣可以達成在任何地方,都保證能找到東京的「東京鐵塔」,然後才是該地區的「東京鐵塔」。

1
2
3
4
5
6
7
8
9
10
{
"_id": "poi-12345",
"name": ["日本電波塔", "japan radio tower"],
"alias": ["東京鐵塔"],
"view_count": 99999,
"location": {
"lon": 139.74537080,
"lat": 35.65855880,
}
}

也可以拿來做別名功能

大家可以看到這個 index 把所有語言都放在一起,然後再用 edge n-gram 分詞,所以無論是哪種語言都可以搜出相同的景點。更重要的是,當這個結構建立之後,小編發現也可以拿來做別名 (alias name) 了!

像「東京鐵塔」其實只是別名,正式名稱叫做「日本電波塔」,在沒有這個 index 之前,雖然 Elasticsearch 有同義詞 synonym 的功能,但維護不是很方便 (每次都要 reopen index,而且也無法指定 document),所以一直沒有拿來使用。有了這個 index,做別名搜尋可以更得心應手!


到此為止,search v2 的整體架構算是大致成形,當然還有超多細節,像是 autocomplete 及 search 這兩支 API,如何讓前端串接更容易 (因為太多邏輯了),又或是如何更精準搜尋到想要的內容等,由於涉及商業邏輯過多,之後有機會再來分享給大家看看吧。

Elasticsearch 的多語言 index 到底該如何設計?

新版的 POI Bank 除了要解決多欄位搜尋的不便之外,還要解決底層 Elasticsearch index 不易維護的問題。

1. 原本的索引設計

其實原本設計的索引結構就如同下面這樣,同一個 document 存入所有的語言,再加上經緯度和觀看次數這些額外欄位。但實際在運作時,會發現因為 CJKV 這類語言要定期更新詞庫的關係,造成每次更新詞庫時都有 downtime 產生 (index 要先 close 再 open)。雖然 Funliday 還不是一個非常大的服務,但有 downtime 對於使用者的體驗總是不好。

1
2
3
4
5
6
7
8
9
10
11
12
13
{ // idx-poi
"id": 999,
"location": {
"lon": -0.1425249,
"lat": 51.5007972
},
"country_id": 12345678,
"name_zh_cn": "白金汉宮",
"name_zh_tw": "白金漢宮",
"name_de": "Buckingham Palace",
"name_en": "Buckingham Palace",
"view_count": 98765
}

2. 將語言從欄位層級提昇到索引層級

所以為了解決 downtime 的問題,將語言從欄位層級 (field level),提昇到索引層級 (index level),這樣子就算中文索引因為定期更新詞庫需要 reopen 造成 downtime,但其他語言的索引還是可以正常運作。

於是改成了下面這種結構,但實際運作時卻發現了另一個問題 Orz

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{ // idx-poi-zh_tw
"id": 999,
"location": {
"lon": -0.1425249,
"lat": 51.5007972
},
"country_id": 12345678,
"name": "白金漢宮",
"view_count": 98765
}
{ // idx-poi-zh_cn
"id": 999,
"location": {
"lon": -0.1425249,
"lat": 51.5007972
},
"country_id": 12345678,
"name": "白金汉宮",
"view_count": 98765
}

程式改寫完之後,一執行下去發現回傳的資料量怎麼這麼多重複的內容,只差在是從不同語言的索引回傳。後來仔細想了一下才發現,因為景點名稱本來在各語言就是不同名稱,像是「東京巨蛋」、「tokyo dome」,如果使用者關鍵字為「tokyo 巨蛋」,這樣子同個景點就會出現兩筆資料,這真的很困擾。

所以為了要解決 downtime 改寫結構,就目前來說其實是個不太划算的方式,所以又換了另種方式,將索引分割成子索引。

3. 將索引分割為子索引

其實分割成子索引主要也是避免 downtime 的發生,做法就是每 n 個 document 為一個索引,要 reopen 索引的時候,就只會影響到 n 個 document 而已

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
{ // idx-poi-0001
"id": 999,
"location": {
"lon": -0.1425249,
"lat": 51.5007972
},
"country_id": 12345678,
"name_zh_cn": "白金汉宮",
"name_zh_tw": "白金漢宮",
"name_de": "Buckingham Palace",
"name_en": "Buckingham Palace",
"view_count": 98765
}
{ // idx-poi-0002
"id": 1000,
"location": {
"lon": 121.123456,
"lat": 24.5007972
},
"country_id": 87654321,
"name_zh_cn": "國立台灣大學",
"name_zh_tw": "国立台湾大学",
"name_de": "Nationaluniversität Taiwan",
"name_en": "National Taiwan University",
"view_count": 5566
}

這裡假設 n 為 20 萬,所以如果有 600 萬個 document 的話,只針對單一個索引 reopen,不會一次所有 600 萬個 document 都無法搜尋,只會有大約 3% 左右 (20/600) 的 document 會搜不到,所以絕對不會有 downtime。聽起來是個權衡之計,但開發完後測試遇到另個問題。

現在突然忘了確切的問題是什麼,但小編記得當初好像是 index 太多個,所以產生 too many shard 還是 too many index 的 exception。這其實要對 Elasticsearch 底層 (lucene) 有較深的理解,才會知道如何設計比較好。

總之,後來又改回原本的單一個 index 了,不過之後勢必還是會分成子索引,讓 downtime 儘量減少。


整理一下總共實作的方式及會遇到的問題:

  1. 將所有資料放在同一個索引:在更新詞庫時會有 downtime 的問題
  2. 依照語言分別放在不同索引:在搜尋時會有重複資料的問題
  3. 將一個大索引分割為子索引:會有 too many shard 或 too many index 的問題

這篇其實是分享小編在重構 Elasticsearch 索引時的思路,因為 Elasticsearch 有 index alias 功能,所以在設計索引時可以非常靈活,雖然試了兩種方式都不能滿足現在的場景,但也希望能讓大家知道各自的適用場景。接下來就是解決熱門景點的問題了,大家期待下一篇文章吧!

新版的景點搜尋功能要上線了!

自從 POI Bank 從三年多前上線以來,使用者一直詬病的兩欄位搜尋,操作體驗真的很差。因為一開始要先用 autocomplete 選擇要找的 city,然後再輸入關鍵字送出,與大家習慣直接在一個輸入框裡面就能搜尋的方式,實在差異很大,所以一直被使用者抱怨很難用!

經過了半年斷斷續續的開發,將近 200 個 commit 之後,POI Bank 總算要升級了,當然一樣是 Android 先上線,iOS 及 Web 隨後就會跟上了。新版的搜尋只需要一個欄位就能找到大部分的景點,這裡就來分享一下新版的搜尋是如何實作的。


舊版搜尋到底遇到什麼問題?

  1. 一定要先選擇城市才能用關鍵字搜尋
  2. Elasticsearch index 維護不方便
  3. 很難找到熱門景點

第一點是要最先解決的問題,其實江湖一點訣,搜尋最重要的就是斷詞,所以把關鍵字先用全世界的城市斷詞,簡單來說「東京晴空塔」當然要先用中文斷詞,把關鍵字斷成「東京 (城市)」和「晴空塔 (關鍵字)」。

不過由於原本 POI Bank 的 city 資料都是官方名稱,「東京」的官方名稱是「東京都」,這樣子斷詞當然是無法斷出正確的結果。所以一定要加別名,這樣子斷詞才會正確,這就是考驗基礎資料收集的功力了,也著實花了我們不少功夫。

另外,如果使用者輸入的關鍵字 (如:牛肉麵) 無法判斷出地點的話,就只能拿畫面上的中心點來搜尋附近的景點了。

當然除了基本斷詞之外,判斷出正確的語言也是一個非常重要的課題。因為判斷錯誤語言的話,就像是拿英文斷詞器斷中文關鍵字一樣,會斷出詞不達意的結果,「東京晴空塔」會被斷成「東」、「京」、「晴」、「空」、「塔」,搜尋出的結果當然會差個十萬八千里。

不過就如同之前文章所提到的,這實在是個很難的題目。根據 Elasticsearch 部落格所寫的,一般使用者在搜尋時輸入的關鍵字長度平均為 2.4 個字 (word) (2001 年的調查),即使過了 20 年到現在,大家搜尋的關鍵字長度依然很短。而且許多判斷語言的演算法,都建議至少要 50 個字元 (character) 以上,判斷起來效果才會比較好

而中日文因為有漢字的問題,又更難分辨了。所以目前看到建議的解決方式,在搜尋時一樣是針對全部語言,只是把能判斷出或使用者目前語言的權重高一點,這樣搜尋出來的結果會比較好。還是大家有什麼好方式,也歡迎提供給小編參考一下。

今天就寫到這邊,剩下兩個問題再另開新文章來分享!

在 MOPCON 2022 分享的「深入淺出 autocomplete」

這次在 MOPCON 分享的「深入淺出 autocomplete」,算是歷次在 blog 分享的 autocomplete 相關內容以來,首次在研討會有完整的分享。

就如同大綱所提的,這次分享了如何使用 Redis 實作 autocomplete 之外,也首次分享了 Funliday 如何改用 Elasticsearch 實作這項功能。Redis 的實作之前提過蠻多次的,今天就多講一些關於 Elasticsearch 的實作。


其實 Elasticsearch 的 edge n-gram tokenizer 非常適合拿來做 autocomplete 的底層,除了不用自己寫 normalize 和 tokenize 外,也有限制長度的參數可以使用。

再來就是跟 Redis 相比,Elasticsearch 天生有 inverted index 的資料結構,所以比較不會有資料量過度膨脹的問題。而且不像 Redis 的 sorted set,資料寫入時就已經決定好格式,Elasticsearch 的 document 更新非常方便。

另外,雖然 Elasticsearch 的資料是儲存在硬碟裡,跟 Redis 原生就儲存在記憶體的速度一定有差異。但因為 Elasticsearch 實作 autocomplete 時,用的是 filter,所以只要這個關鍵字用的夠頻繁,其實最後 Elasticsearch 還是從 cache 裡面取資料出來,速度影響不會這麼大。


現在 POIBank 的 search v2 (還沒上線 囧) 的一部分 autocomplete 就是用 Elasticsearch 來實作,先來下個 flag,希望明年過年前後可以把所有的 autocomplete 都從 Redis 移植到 Elasticsearch!


在 COSCUP-2022-分享的「釋放你的儲存空間!移除那些已經沒使用的 index」

今年小編ZHIH在 COSCUP 上分享的內容,「釋放你的儲存空間!移除那些已經沒使用的 index」,主要是針對這個主題所做的survey。

內容主要分作兩部分

  1. 移除未使用的index
  2. 針對已存在的index 進行空間上的優化

會以大略講重點的方式提及內容


移除未使用的index

為何要移除這些沒有在使用的index

  • 預算考量,本身系統用不到下一個量級,卻因為這些不在使用的資料佔據空間,導致必須升級提高成本,備份時間也拉長
  • 當初為了特定的feature設置的index,隨時間推移,原本的feature不再使用,而當初的設置index也不再需要,而佔據空間
  • delete / update / insert 導致空間碎片化,造成效率降低

該怎麼確認這些沒有使用的index

可以透過pg_stat_all_indexes 利用語句將 idx_scan & idx_tup_read & idx_tup_read 設為 0 (或是一個很小的數字)來找出候選人

刪除之前

  • 看到idx_scan & idx_tup_read & idx_tup_read 的 0,並不代表真的沒使用到,背後的統計邏輯其實是可能會用到但不算記載統計表內, 舉例來說: optimizer 其實會讀取index 來做後續query planer的決策,但讀取的動作不會算到討既資料內
  • 目標的index 沒有使用到的原因,是否為語句上的失誤導致沒有正常使用?
  • 目標的index 在當前的情況下,刪除後是否有刪除的價值,比方說 佔據空間很大,卻又都沒在用

刪除之後

  • 監控目標index 刪除前後對效能的影響,如果有嚴重影響要補回去
  • 養成定期檢查習慣,重置index統計表,方便確認每一個區間是否有一些待觀察名單,養成好習慣,日後好相伴~

針對已存在的index 進行空間上的優化

為何要做針對已存在的index 進行空間上的優化?

Pg有實作MVCC去保存舊有的資料,而這個實作雖然可以方便roll back,卻造成了update,並不是直接針對column修改成指定的value,而是會先標記就資料是過期的再去insert新的資料上去,照樣的特性下,會保留舊有的資料一段時間,進而造成空間上的佔據浪費空間,而這個狀況也增作bloat。

要怎麼解決bloat 的情形呢?

主要分兩種語法

  • vacuum
  1. vacuum
    可以把位置上的舊資料清掉,重複利用,但這個動作本身不會把空間還給OS
  2. vacuum full
    可以把位置上的舊資料清掉,且會把空間還給OS,缺點是進行時整張table 會lock不能使用,有不間斷得使用需求是硬傷
  • reindex
  1. reindex
    建立新的index替換舊的index,但是會write lock,有write的需求時會有影響
  2. reindex concurrently
    可以在不write lock & read lock情況下持續運作,但會造成IO loading負擔及記憶體上升,完成動作的時間拉長,執行效率變差,但好處是service 可以保持運作不間斷

綜合上述:

  • reindex concurrently 適合production運作,好處是不會中斷,但可接受service 中斷選擇在離峰執行也是不錯的選擇
  • 至於development 則依照需求,找出適用的即可

避免在常變更的column設置index

提到實際行為前要先知道幾樣東西的特性

  • 實際存放資料的地方叫做page (像是memory) or block(像是disk),每一個page or block都有大小的限制
  • index 裡面會存放資料的實際位置 ex: (22,8) 代表第22個block第8個element,藉此快速拿到資料

Pg在有建立index的情況下進行update,除了在index 會需要多一個entry來放新的資料(前面提及的mvcc有關),也會針對實際的資料新增一筆entry,新的index會指向新的資料位置,這一來一往,除了page 會增加資料外,index的部分也額外需要多一個entry造成資料的浪費,為了優化index那邊空間浪費的部分,而有了HOT update
而HOT update的特性有以下:

  • 使用同一個index entry 指向實際的資料位置不做更換,代表不會額外新增entry
  • 新舊資料要在同一張page or block

這樣的特性下,確實減少了index那邊的空間,但要滿足觸發條件除了新舊資料要在同一個page or block,也需要更新的 column value 並不屬於任何一個 index,因為對應的column value改變,勢必要產生新的entry來做對應,就跟一般update一樣了,當然實務上還是會有需求對有設index 的column value 做變更,但依然盡可能地去做避免

避免錯誤的設置index

假設一個情境有一張table 專門儲存訂單資料,多數的情況下訂單都是成立的不太需要特別查看,但總會有少數幾張訂單是會取消的,在某些情況下還是需要特別拿出來去確認,假設這個是否取消的column叫”isCanceled” 是boolean value,這個table有1000筆資料有5筆是取消的,為了加快query速度對”isCanceled”這個column做index,但實際上需求真正需要的只是要知道被取消的那5筆資料,但操作上去卻1000筆資料都做index,造成其餘的995筆都是沒意義的,那為何不針對那5筆作index就好?1000 vs 5空間誰大誰小這顯而易見了吧~,而針對這五筆作index動作就是partial index,因此也可以知道適當使用partial index 的重要性

結論

  • 透過定期監看 pg_stat_all_indexes,確認沒用到的 index ,並移除
  • 定時去處理 index bloat 問題,節省空間同時也能維護效率

用 Redis 來處理熱門景點的 autocomplete 功能

又是一篇關於 Autocomplete 的文章,不過這次我們來講如何用 Redis 來處理熱門景點的 autocomplete。Funliday 是一個排行程的服務,當然會有使用者在安排時的熱門景點,所以當使用者輸入「北門」的時候,如果在 autocomplete 可以出現下面這些 candidates 的話,對使用者一定更方便。

Name
北門口肉圓
北門綠豆沙
北門肉羹

即使如此,這些 candidate 也一定有先後順序,所以加上熱門程度 (可以用加入或觀看的次數做為基礎) 之後就會變成下面這樣。

Name Hot
北門口肉圓 79
北門綠豆沙 84
北門肉羹 82

寫入階段

以前在 用 Redis 來處理 City 的 autocomplete 功能 - 1 的時候有提過,在做 Autocomplete 的時候,我們可以利用 sorted set 來處理字母順序的問題,而 sorted set 在新增元素的時候,其實也可以加分數 (score) 上去,以上面為例,我們可以使用 ZADD 這樣處理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ZADD ac_poi:北 79 北門口肉圓
ZADD ac_poi:北門 79 北門口肉圓
ZADD ac_poi:北門口 79 北門口肉圓
ZADD ac_poi:北門口肉 79 北門口肉圓
ZADD ac_poi:北門口肉圓 79 北門口肉圓
ZADD ac_poi:北 84 北門綠豆沙
ZADD ac_poi:北門 84 北門綠豆沙
ZADD ac_poi:北門綠 84 北門綠豆沙
ZADD ac_poi:北門綠豆 84 北門綠豆沙
ZADD ac_poi:北門綠豆沙 84 北門綠豆沙
ZADD ac_poi:北 82 北門肉羹
ZADD ac_poi:北門 82 北門肉羹
ZADD ac_poi:北門肉 82 北門肉羹
ZADD ac_poi:北門肉羹 82 北門肉羹

兩種不同的寫入方式

大家應該有發現到這裡的儲存方式和以前不同,之前在儲存的時候是固定的 key 搭配不同的 member prefix,以及固定為 0 的 score。

1
2
3
4
5
6
7
8
9
10
11
12
# 以前
ZADD ac_city 0 台
ZADD ac_city 0 台北
ZADD ac_city 0 台北市
ZADD ac_city 0 台北市*

# 現在
ZADD ac_poi:北 79 北門口肉圓
ZADD ac_poi:北門 79 北門口肉圓
ZADD ac_poi:北門口 79 北門口肉圓
ZADD ac_poi:北門口肉 79 北門口肉圓
ZADD ac_poi:北門口肉圓 79 北門口肉圓

主要是為了可以讓 score 做排序,所以將 prefix 移往 key。雖然 key 看起來會有點奇怪,但這是一個還蠻巧妙的設計。

寫入後的結果

內容排不下,我們就以「北門口」為例,存到 redis 之後會變成下面這樣子。

ac_poi:北 ac_poi:北門 ac_poi:北門口
score member score member score member
79 北門口肉圓 79 北門口肉圓 79 北門口肉圓
82 北門肉羹 82 北門肉羹
84 北門綠豆沙 84 北門綠豆沙

所以 sorted set 在同一個 key 存入不同 member 之後,會以 score 由小排到大。到這邊為止,算是寫入階段完成。

查詢階段

在搜尋的時候就是仿照 City 那邊的做法,只不過會將原本需要用 ZRANK 將 cursor 定位至特定的 member 再往後用 ZRANGE 取得資料,現在只要直接用 ZRANGE 取得就可以了。雖然 City 也可以用 Lua 來達成 atomic operation,但為了讓系統單純一點,這裡就先不這樣處理了。

1
2
3
4
5
6
# 以前
pos = ZRANK ac_city 北門
results = ZRANGE ac_city pos + 1 pos + 1 + 50

# 現在
results = ZRANGE ac_poi:北門 +INF -INF BYSCORE REV LIMIT 0 50

取回來的 results 會變下面這樣子 score 由大排到小,然後再做一些處理,就可以輸出回前端了。

  • 84 北門綠豆沙
  • 82 北門肉羹
  • 79 北門口肉圓

結論

原文章還有提到可以用 ZINCRBY 來更新 score,但 Funliday 的使用情境其實不用到這麼即時,所以我們的做法是每天重新產生新的 autocomplete 資料。

現在這個功能也已經出現在最新版的 App 了,大家可以在首頁點進「住宿」做搜尋,就會看到這個功能囉。至於原本的「景點瀏覽」要再給我們一些時間,會儘快將這個功能上線!

References

Funliday 2022 誠徵前端工程師及實習生

Funliday 最近誠徵前端工程師以及軟體實習生,有興趣的快來 CakeResume 投履歷喔!

前端工程師

Funliday 剛完成新一輪的募資,已轉型為旅遊社群的 Funliday 會開始進行首頁及遊記內容頁的改版,還有提昇網站整體效能,包括 Core Web Vitals 所提到的 LCP, CLS 及 FID

另外 SEO 及分析工具在今年是 Funliday 的重中之重,尤其現在 Google 已經開始將 mobile index 做為第一優先,還有 schema.org 等結構化資料,如果你可以理解甚至熟悉如何最佳化 Google SERP 以及建構語意化內容,以及有 GTM 和 GA4 的導入經驗,也是 Funliday 會優先考量的夥伴。

最後則是今年開始會與日本市場有較多的互動,所以會以 SaaS 的方式與日本旅行社合作,而合作方式會以網站為主,所以會有另外一部分的時間要開發 SaaS 的行程規劃工具

職務需求

人格特質

  • 獨立思考
  • 充滿好奇心
  • 積極面對各種問題
  • 喜愛自行規劃行程
  • 愛用各種地圖工具
  • 熱愛 open source

必備

  • 三年以上前端工作經驗
  • React+Redux
  • 熟悉 RESTful API 串接
  • 熟悉撰寫高品質的程式碼

加分

  • 熟悉 template engine (如 EJS, Pug)
  • 熟悉效能調校 (如 Core Web Vitals)
  • 熟悉 GTM, GA4
  • 熟悉測試框架
  • 熟悉 CI/CD 流程
  • 整合過 Google Maps 或 OpenStreetMap

履歷內容應該包含

  • 做過的專案介紹,你在裡面負責哪些內容 (必備)
  • GitHub 或任何可公開的程式碼托管平台帳號

薪酬制度

薪資:55k ~ 70k * 14 個月
年終獎金計算方式:全薪計算
加班費制度:比照勞基法
公司分紅與獎金:員工股票選擇權

其他

  • 員工是否需自備工具?:否,配有 MacBook Pro + 螢幕
  • 每日工作時間
    • 疫情期間目前維持星期一三四辦公室工作,二五遠端工作
    • 10:00 ~ 18:00 (8 小時)
    • 中午彈性休息 (12:00 ~ 13:00)
  • 工作地點:台北市中山區近捷運中山站

軟體實習生

Funliday 的 POI Bank 擁有全世界的景點,為了提昇景點內容品質,需要你來協助整理景點內容,包括合併重覆景點、移除品質不佳景點。包括公司庶務性的工作,也需要你來協助處理。

因為全世界的景點眾多,所以如果你能使用自己開發程式,減少人工處理而提昇景點品質,那你絕對是我們需要的人才!

如果你對開發自動化有興趣的話,比如環境建置、App 自動上版…等,我們也非常歡迎你來協助我們建置開發自動化。

職務需求

人格特質

  • 獨立思考
  • 充滿好奇心
  • 積極面對各種問題
  • 喜愛自行規劃行程
  • 愛用各種地圖工具

加分

  • 有程式開發經驗
  • 熱愛 open source
  • 熟悉 JavaScript 語言
  • 熟悉 GIS 技術
  • 熟悉 CI/CD

其他

  • 員工是否需自備工具?:否,配有 MacBook
  • 每日工作時間
    • 疫情期間目前維持星期一三四辦公室工作,二五遠端工作
    • 10:00 ~ 18:00 (8 小時)
    • 中午彈性休息 (12:00 ~ 13:00)
  • 工作地點:台北市中山區近捷運中山站

到保哥直播分享「老司機帶你上手 PostgreSQL 關聯式資料庫系統」

一個月前上了保哥的直播,主要是分享一下 Funliday 如何使用 PostgreSQL。

其實一開始會選擇 PostgreSQL 主要有兩點,第一點是 Funliday 是放在 Heroku 上面,而 Heroku 配置的資料庫就是 PostgreSQL,第二點是 POI Bank 儲存的內容是一堆的景點資料,用 PostgreSQL 內附的 PostGIS 操作當然是更適合,所以當時也沒考慮用其他的資料庫。

這次接到保哥邀請的時候,小編真的想了很久,到底要跟大家講什麼才好。講語法?CRUD 大家都會,這沒什麼好講的;講效能評估?這也不是小編的專長,主要都是看各位神人的文章來做調校;講如何安裝?這直接上網找教學文章就有了,在直播講這個實在太浪費時間。所以後來想了一下,還是以 Funliday 如何使用 PostgreSQL 為主,然後分享了五個部分,這裡簡單整理一下

1. Migration 資料庫版本管理

在程式開發過程中,如果有做任何變更資料庫結構動作 (也就是 DDL) 的話,記得要做 migration。小編第一次看到這個概念是 RoR 正紅的時候學到的,任何的資料庫結構變更都要有 tear down 以及 set up,後來學的幾套 web framework 好像也都有類似的機制,所以在使用 PostgreSQL 的時候就直接拿來用。

但 Express.js 這個 web framework 其實沒有內建 migration 的功能,找了一些 Node.js 的套件也沒看到比較好的。所以後來就找了這套 Java 開發的 Flyway,完全可以達到我們的需求,所以後來就一直沿用下去了。

2. Node.js 整合

這部分是在分享如何把 Node.js 與 node-postgres 套件整合在一起,其實寫久已經習慣了,但一開始在用這個套件的時候,光是為了要找如何把 array 塞到 SQL 語法裡面就花了好多時間。這個在影片裡面有介紹到,大家可以直接去看看。

3. PostGIS 實務應用

這是將之前在 PostgreSQL.TW 以及 ModernWeb 2019 分享的內容再整理了一下,簡單舉了幾個 PostGIS 使用的例子,也分享如何在 pgadmin 上面用 OpenStreetMap 直接顯示結果,還蠻好用的!

4. DB 儲存資料用

這其實也是把 PostgreSQL.TW 的內容整理了一下,然後再加上最近實作的方式分享出來。其實就是想強調一點,資料庫主要還是存資料為主,有做正規化當然很好,但也是要看當時的資源跟時間是否允許。像影片中有提到因為 Redis 比較貴,所以把一部分的 cache 負擔丟給 PostgreSQL,這樣也比較省錢。

5. Blurhash 顯示

Blurhash 好像是這次迴響比較大的部分,其實在去年的 COSCUP 2020 就有提到這個套件了。就是把一張 client 要顯示的圖片,運用演算法計算出 20 個 byte 的字串,因為這個字串短,所以可以直接存在資料庫裡面。而 client 將這個字串 decode 之後,就可以顯示一個具有象徵意義的色塊,而不是傳統的 image placeholder。

這個 Blurhash 在 Funliday 上面已經運用在 Web 跟 Android,大家可以實際去使用看看效果,小編是覺得還不錯。