GraphQL api performance tuning紀錄

前情提要

這是一個監控交易狀況和計算損益的服務,每當程式進行交易後更新目前的持倉和損益。

要求與交易系統狀態同步的延遲時間盡可能縮短。 (交易發生後到服務更新完成的時間)

本文紀錄從最初開發到現在的演變過程。

alpha版

all in one

成交紀錄和系統資料皆存放在遠端機房的MySQL DB中,

為了減少讀寫和網路壓力,使用在本地機房建立的slave MySQL DB讀取資料來進行計算。

這一個版本的實作只有一個api process。

在啟動時從slave讀取成交紀錄,計算出損益後開始serve HTTP。

分拆讀取和計算功能

為了更方便做JOIN和aggregate query,首先分拆出了同步服務,將資料從slave複製到本地DB。

使用本地DB的Primary Key,就能拿來當損益紀錄對應的成交紀錄Foreign Key。

損益的計算改為先從本地DB拿出成交紀錄,計算出損益紀錄後寫到DB中,

方便直接從DB中查詢資料而不用將所有功能寫在api裡。

api則使用GROUP BY拿出需要的資料

beta版

GraphQL

最初的版本使用RESTful提供資料,但系統的資料是樹狀呈現,

一個user可能擁有多個account,一個account底下可以擁有一個portfolio,

一個portfolio可以有多個subscription。

而portfolio和subscription又有各自的損益計算方式。

user又想看到所有portfolio加起來的損益。

因此API變成:

GET /user
GET /user/profit
GET /account
GET /portfolio
GET /portfolio/profit
GET /subscription
GET /subscription/profit

結果API常常需要回傳一部分相同的資料或功能非常類似,因此我們開始加入GraphQL,

將schema定義成:

profit {
    date
    netbalance
}

subscription {
    portfolio
    profits []profit
}

portfolio {
    subscriptions []subscription
    account
    profits []profit
}

account {
    user
    portfolio
}

user {
    accounts []account
    profits []profit
}

查詢則類似:

user {
    accounts {
        portfolio {
            subscription {
                profit
            }
            profit
        }
    }
    profit
}

GraphQL雖然簡化了API設計,但是完全沒有減少計算複雜度,而且容易引起大量查詢,

以上面的schema來說,如果有100個user,每個user有2個portfolio,每個portfolio有2個subscription

就會觸發100 + 200 + 400次profit的resolver去查詢資料

另外如果profit裡面指定需要回傳每日的損益合計,一共有一年份的歷史紀錄,

就會有700*365個profit summary object,會變成一個MB等級的巨型回應。

Materialized View

最初的profit查詢方式是

SELECT deal_date, SUM(profit)
FROM profits
WHERE account_id = ? AND subscription_id = ?
GROUP BY deal_date

顯而易見的這會花一定時間來計算,儘管加了index還是需要50ms~300ms,

再加上海量查詢次數使得回應時間最慢來到5s~20s。

實際上這個計算是可以cache起來的,但放在in-memory或redis又會失去DB查詢的方便性,

所以加入了Materialized View,這是Postgresql提供的功能,可以將View做一個snapshot。

另外MView也可以加上INDEX,以及UNIQUE INDEX來支援REFRESH MVIEW CONCURRENTLY,

再增加查詢和更新的速度。

加入以後降低到1~2s的回應時間

V2

v1的設計主要是配合vue前端可以在每個component寫自己想要的schema,

因此支援了四種query:

query {
    users()
    accounts()
    portfolios()
    subscriptions()
}

但這個做法事實上違反了SSOT,每個component都會各自發出query,相同的資料存在多個地方。

當透過websocket通知前端:持倉有變化時,大量的component都在監聽event。

為了解決這個問題,在v2中的query只留下users()一個root。同時刪減欄位減少不必要的傳輸。

前端則將GraphQL的結果存在vuex store中。

回應速度降低到了300~500ms

V2.5

在V2的基礎上,我們做了更多細部調校

database

postgresql用docker啟動可以另外指定shared_memory的大小,可以考量資料庫大小和機器ram大小做設定

shm_size: 4G

細部設定可以修改 postgresql.conf

主要是增加可以使用的memory大小,以及紀錄耗費時間太長的查詢、需要檔案來暫存的查詢

temp_buffers = 128MB # 預設8MB
work_mem = 128MB     # 預設4MB

log_min_duration_statement= '500' # 0.5s
log_temp_files = '4' # 4k

graphql

使用的package是 github.com/graph-gophers/graphql-go

使用option

   graphql.UseFieldResolvers(),
   graphql.Tracer(trace.NoopTracer{}),
   graphql.MaxParallelism(runtime.GOMAXPROCS(0)),
   graphql.MaxDepth(20),
   graphql.DisableIntrospection(),

write response

drop in replacement

替代std的encoding/json github.com/json-iterator/go

使用 jsoniter.ConfigFastest

替代std的gzip github.com/klauspost/pgzip

使用 gzip.BestSpeed

vue

使用 Object.freeze 鎖定不會更新的資料