此專案是針對Dcard公司的申請作業,題目描述:
Dcard 每天午夜都有大量使用者湧入抽卡,為了不讓伺服器過載,請設計一個 middleware:
- 限制每小時來自同一個 IP 的請求數量不得超過 1000
- 在 response headers 中加入剩餘的請求數量 (X-RateLimit-Remaining) 以及 rate limit 歸零的時間 (X-RateLimit-Reset)
- 如果超過限制的話就回傳 429 (Too Many Requests)
- 可以使用各種資料庫達成
因此本專案除了做了該middleware,並模擬了小型Dcard系統的專案,例如有抽卡API及創建user API,因此背後需要有PostgreSQL、Redis兩種資料庫的支持。
採用Golang語言的Gin框架開發RESTful API,使用PostgreSQL作為資料庫,以Redis做為快取資料庫,負責IP次數檢查的middleware。
可利用本專案的docker-compose.yaml會一次啟動Backend、PostgreSQL、Redis,方便直接運行測試。請確保主機有docker環境,如果是Linux環境則需要另外安裝docker-compose套件。而如果是Windows、Mac則只需要安裝Docker Desktop即可。
# 透過 git clone 專案到主機任意路徑下
git clone https://github.com/KennyChenFight/dcard-simple-demo.git
# 在本專案的根目錄下執行以下指令即可
# -d 代表背景運行(Optional)
docker-compose up -d
總共起了以下五個Docker Container
-
postgres
裡面預設會自動放入一些測試資料,可參考測試資料
-
postgres-client
這個是採用pgAdmin作為client端,開啟localhost:5432,並需要輸入帳號密碼: [email protected]、dcard,登入後連接postgres即可,注意hostname是postgres,至於連接的帳號密碼可參考創建db說明
-
redis
-
redis-client
這個是採用phpRedisAdmin作為client端,開啟localhost:8081,登入帳密為admin、admin,登入進去就會直接連接Redis,在左邊即可看到當前的所有key List,點擊key即可看到儲存的資料內容。
-
dcard-simple-demo
此為Golang的Backend Service。
可打開URL:localhost:8080/api-docs,利用Swagger框架打造RESTful API,可透過該框架直接測試API。
建議去官網下載Golang 1.13+的編譯器,而不在Ubuntu直接安裝,因為Ubuntu的Golang是舊版的。
安裝指令如下:
# 下載 Golang 壓縮檔
wget https://dl.google.com/go/go1.13.linux-amd64.tar.gz
# 解壓縮
sudo tar -xvf go1.13.linux-amd64.tar.gz
# 放置到/usr/local
sudo mv go /usr/local
# 設定Golang環境變數:GOROOT、GOPATH、
nano ~/.profile
# 加入以下內容
export GOROOT=/usr/local/go
export GOPATH=$HOME/go
export PATH=$GOPATH/bin:$GOROOT/bin:$PATH
# 生效設定
source ~/.profile
Note:Golang開發慣例是在GOPATH路徑下,會有三個資料夾需要創建,分別是,bin、pkg、src,建議可以先創建好。
# 透過 git clone 專案到GOPATH/src 路徑下
git clone https://github.com/KennyChenFight/dcard-simple-demo.git
並且會教你如何一步一步地建立資料庫內所需的物件。
詳細安裝教學可參考我的Blog文章:Redis安裝教學
簡單指令如下:
# 下載Redis原始碼
wget http://download.redis.io/releases/redis-3.0.5.tar.gz
# 解壓縮
tar xzf redis-3.0.5.tar.gz
# build原始碼
cd redis-3.0.5
make
# 啟動Redis Server
./src/redis-server
Note:由於本專案,預設連接Redis時需要密碼,但因為安裝完Redis,Redis預設是沒有設定密碼的,因此需要多加設定密碼的步驟,如果不想要設定密碼,則需要去main.go刪除連接Redis的Password選項。
Redis設定密碼:
修改配置文件:/redis-3.0.5/redis.conf
# Warning: since Redis is pretty fast an outside user can try up to
# 150k passwords per second against a good box. This means that you should
# use a very strong password otherwise it will be very easy to break.
#
# requirepass foobared
找到被註解掉的requirepass foobared
因此把註解移除,以及把foobared換成你想要設定的密碼即可~
本專案預設的Redis密碼為root。
接著儲存配置檔案,需要將redis-server重啟:
./redis-cli shutdown
./redis-server
當前面Golang、PostgreSQL、Redis環境都建置好之後,在本專案下有一個.env檔案,裡面定義了本專案需要的環境變數。
基本上,如果是按照前面教學一步一步操作,則裡面的環境變數的值不需要做任何更改,直接用本專案的預設值即可。若有改變,請自行修正。
環境變數介紹:
-
JWT 設定
本專案採用JWT作為認證機制,因此需要SECERT_KEY、TOKEN_LIFETIME。
-
PostgreSQL 設定
需要定義DB_HOST、DB_PORT、DB_NAME、DB_USERNAME、DB_PASSWORD、DB_SSL_MODE。
-
Redis 設定
需要定義REDIS_ENDPOINT、REDIS_PASSWORD、REDIS_POOL_SIZE。
當前面必要的環境都架設好之後,並成功運行後
接著,可打開URL:localhost:8080/api-docs
這邊利用的Swagger框架,作為靜態檔案直接放置在本專案上,並定義好本專案所寫的RESTful API,方便測試用,就不需要額外使用其他如Postman等工具來測試。
- Redis讀寫快速,且專門存放Hot Data,就算這類Hot Data遺失了對於系統的損失是輕微的,不會影響整個系統的運作。而且對應該middleware的功能,Redis只需存放IP連線次數及到期時間,這類資料不需要也沒必要放在系統的主RDBMS。
- 不應該使用PostgreSQL這類的RDBMS去做該middleware,系統的主RDBMS應該是要負責重要的API端口使用,否則由於Dcard的使用人數過多,導致高併發流量,若重要的API前面都加了該middleware去做檢查,會導致在middleware這層,RDBMS效能就會被大量拖垮。
所以,考量高併發的要求,使用Redis這類資料庫在前面middleware層作緩解是必要的方法,主RDBMS理應是Backend的最後一層且不應輕易被使用到。
在Redis利用Hash結構存放資料:
-
Hash Name
將API URL + API Method + IP位址當作Hash Name,因為考慮到其他API可能也會用該middleware,以URL+Method為前綴可以區分不同API的訪問流量控管。
-
count
裡面存放名為count的key name,值存放的是訪問API次數,最高數值只會到10,符合題目要求,而每當過了一個小時後,會將數值歸0重新計算。
-
reset
裡面存放名為reset的key name,值存放的是到期時間,格式為timestamp,而當到期時間小於現在時間時,會將到期時間改為現今時間+一小時在重新存入。
就我對題目的理解,每次回傳要給剩餘次數及到期時間,因此透過Hash結構存會比較好操作。
而在程式實作上有兩種方法:
-
Golang利用Redis Client Library操作
簡單來說,就是透過Golang呼叫Redis的GET、SET等操作,但是會有以下缺點:
由於每次使用者訪問都需要透過讀取Redis數據,才能判斷要存入怎樣的次數或是到期時間,再加上Redis沒辦法將多個操作都視為Atomic操作,就算將這些操作都包在Redis Transaction結構裡面,GET操作在Transaction是沒辦法得到值的,造就程式判斷錯誤,進而造成Race Condition,會使得該使用者可能會得到相同的剩餘次數。也就是說使用者可以在一個小時內拜訪不只1000次,無法完全阻擋住。
-
利用Lua腳本來對Redis進行操作
Redis官方有說:推薦使用Lua腳本對Redis進行操作,而非使用Transaction方式。並且Redis對於Lua腳本是原生支持原子性,因此不會有第一種方法的問題,加上在Lua腳本寫會有更大的彈性。
本專案採用第二種方法,可以嚴格的讓使用者一小時內確實只能拜訪一千次,而不會造成Race Condition。但其實兩種方法各有優缺點:對於第一種方法而言,如果系統並沒有高流量,即使使用者會多拜訪個幾次,對於系統負擔造成不是很大,就可以採用第一種方法,因為第二種方法雖然不會Race Condition,但是需要利用Lua語言來寫腳本,會是額外的負擔。但我覺得對於極度高併發的系統,使用第二種方法會是較好的方法。
local key = KEYS[1]
local now = tonumber(ARGV[1])
local ipLimit = tonumber(ARGV[2])
local period = tonumber(ARGV[3])
local userInfo = redis.call('HGETALL', key)
local reset = tonumber(userInfo[4])
local result = {}
if #userInfo == 0 or reset < now then
reset = now + period
redis.call('HMSET', key, "count", 1, "reset", reset)
result[1] = ipLimit - 1
result[2] = reset
return result
end
local count = tonumber(userInfo[2])
if count < ipLimit then
local newCount = redis.call('HINCRBY', key, "count", 1)
result[1] = ipLimit - newCount
result[2] = reset
return result
else
result[1] = -1
result[2] = reset
return result
end
key、now、ipLimit、period,為外部參數,由Golang程式丟進去,如此可以客製化,而不是寫死在Lua腳本,特別注意key是由API URL + API Method + IP位址 + userId所組成的。
講解該兩種if判斷含意:
- 檢查userInfo是否為0 或是 到期時間 < 現今時間
- userInfo為0代表第一次的訪問,Redis沒有該使用者之前的資料
- 到期時間 < 現今時間,代表Redis內該使用者的資料要重設訪問次數以及到期時間
- 檢查count < ipLimit
- 如果count < ipLimit,代表使用者還可以繼續訪問
- 反之,代表使用者訪問次數已經超量
Lua腳本執行完會回傳一個陣列,長度為二,第一格資料是使用者還可訪問的次數(如果回傳-1代表超量訪問),第二格資料是到期時間。如此以來,計算訪問次數及到期時間都不是在Golang程式進行計算,更防止Race Condition的可能性。
本專案是將/v1/pairs/(POST 抽卡配對使用者)、/v1/pairs/(GET 取得配對對象)前面都加了IP Limit檢查middleware,因為我覺得這兩隻API是高流量的,因此在運行的時候會看到Redis資料裡面會有這兩隻API所對應的使用者拜訪次數。