背景
在開發(fā)微信公眾號(hào)的時(shí)候烹俗,會(huì)和access_token打交道诽偷,參照微信的文檔
access_token是公眾號(hào)的全局唯一票據(jù)侥祭,公眾號(hào)調(diào)用各接口時(shí)都需使用access_token。開發(fā)者需要進(jìn)行妥善保存夫偶。access_token的存儲(chǔ)至少要保留512個(gè)字符空間蛉拙。access_token的有效期目前為2個(gè)小時(shí)尸闸,需定時(shí)刷新,重復(fù)獲取將導(dǎo)致上次獲取的access_token失效孕锄。
按照文檔上推薦的做法,需要一個(gè)用來獲取和刷新access_token的中控服務(wù)器苞尝,其他業(yè)務(wù)邏輯服務(wù)器所使用的access_token均來自該中控服務(wù)器畸肆。
出于安全的考慮,微信對于獲取access_token的調(diào)用有一定的次數(shù)限制宙址,超過這個(gè)限制轴脐,就無法再刷新token。
問題
在實(shí)際開發(fā)中抡砂,中控服務(wù)器的做法如下圖
假設(shè)這樣一種情景大咱,在redis中緩存的token剛好過期時(shí),第三方向中控服務(wù)器同時(shí)發(fā)送了大量的請求注益。為了讓問題簡化碴巾,這里假設(shè)收到了A和B兩條請求。
中控服務(wù)收到請求A時(shí)丑搔,查詢緩存厦瓢,沒有命中,于是調(diào)用微信api啤月,重新獲取token煮仇,然后寫入緩存,實(shí)測這個(gè)過程大概需要0.1到0.2秒(這個(gè)值和所處的網(wǎng)絡(luò)環(huán)境也有關(guān)系)谎仲。在請求A將token寫入緩存前浙垫,請求B來了,查詢r(jià)edis郑诺,也沒有命中夹姥,也會(huì)調(diào)用微信的api來重新獲取token。
實(shí)際上在業(yè)務(wù)中间景,只需要調(diào)用一次微信api來獲取token即可佃声。可是在上面的例子中倘要,卻調(diào)用了兩次圾亏。如果并發(fā)量足夠大十拣,讓中控服務(wù)反復(fù)去調(diào)用微信的api,很有可能就會(huì)超出微信的限制志鹃,一旦這種情況發(fā)生夭问,對于業(yè)務(wù)的運(yùn)營將是災(zāi)難性的。
測試
為了說明上面的問題曹铃,筆者編寫了一個(gè)小的例子來模擬這種情況缰趋。
服務(wù)端采用Django,客戶端使用go語言來高并發(fā)調(diào)用服務(wù)端的接口陕见。
服務(wù)端
服務(wù)端代碼秘血,這里只是列出關(guān)鍵代碼,其他一些配置項(xiàng)之類的代碼在這里略過不計(jì)评甜。
創(chuàng)建項(xiàng)目
django-admin startproject accessTokenTest
python manage.py startapp index
編寫返回token的api
# view函數(shù)
def index(request):
cache.incr(settings.CounterKey)
token = cache.get(settings.TokenKey)
if token is None:
token = create_access_token()
cache.set(settings.TokenKey, token, 5 * 60)
return HttpResponse(json.dumps({'token': token}), content_type='text/json')
# 模擬調(diào)用微信api生成access token
def create_access_token():
time.sleep(0.3)
cache.incr(settings.CreateKey)
return str(uuid4())
測試的例子采用redis作為緩存灰粮,通過sleep來模擬一個(gè)網(wǎng)絡(luò)請求,并且將請求的次數(shù)和生成token的次數(shù)存在redis里忍坷,便于我們得到測試結(jié)果粘舟。
使用gunicorn,啟用4個(gè)進(jìn)程來模擬服務(wù)端
gunicorn accessTokenTest.wsgi --workers 4
[2016-11-04 13:04:29 +0800] [12720] [INFO] Starting gunicorn 19.6.0
[2016-11-04 13:04:29 +0800] [12720] [INFO] Listening at: http://127.0.0.1:8000 (12720)
[2016-11-04 13:04:29 +0800] [12720] [INFO] Using worker: sync
[2016-11-04 13:04:29 +0800] [12723] [INFO] Booting worker with pid: 12723
[2016-11-04 13:04:29 +0800] [12724] [INFO] Booting worker with pid: 12724
[2016-11-04 13:04:29 +0800] [12725] [INFO] Booting worker with pid: 12725
[2016-11-04 13:04:29 +0800] [12726] [INFO] Booting worker with pid: 12726
客戶端通過GET請求http://127.0.0.1:8000 來請求token
客戶端
客戶端代碼如下
// filename accessToken.go
package main
import(
"net/http"
"encoding/json"
)
type AccessToken struct {
Token string
}
func main(){
channel := make(chan error)
for ;;{
token := new(AccessToken)
go func(){
channel <- getJson("http://127.0.0.1:8000", token)
}()
}
}
func getJson(url string, target interface{}) error {
r, err := http.Get(url)
if err != nil {
return err
}
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(target)
}
編譯后生成可執(zhí)行文件accessToken
在測試開始前佩研,啟動(dòng)redis服務(wù)柑肴,設(shè)置對應(yīng)的key
127.0.0.1:6379[1]> persist ":1:counter"
(integer) 0
127.0.0.1:6379[1]> persist ":1:create"
(integer) 0
啟動(dòng)客戶端,進(jìn)行測試旬薯,運(yùn)行一段時(shí)間后晰骑,手動(dòng)殺死
./tokenTest
^C
查看測試數(shù)據(jù),可以看到在測試的時(shí)間內(nèi)袍暴,服務(wù)端一共收到了客戶端1522次請求些侍,4次生成了新的token。
127.0.0.1:6379[1]> get ":1:create"
"4"
127.0.0.1:6379[1]> get ":1:counter"
"1522"
分析問題
這個(gè)場景要求獲取access token這個(gè)操作必須是原子的政模。
可以進(jìn)一步得抽象為在某段時(shí)間內(nèi)對"access_token"這個(gè)資源只能有一個(gè)進(jìn)程進(jìn)行訪問岗宣。
解決方法
說到原子操作,筆者第一反應(yīng)就是信號(hào)量淋样。下面我們將使用信號(hào)量來解決這個(gè)問題耗式。
采用posix_ipc模塊,只修改服務(wù)端的代碼
為了確保每次運(yùn)行項(xiàng)目趁猴,信號(hào)量的狀態(tài)保持一致刊咳,修改index/apps.py這個(gè)文件,在啟動(dòng)時(shí)初始化信號(hào)量儡司。
服務(wù)端v2
from posix_ipc import Semaphore, ExistentialError, O_CREAT
class IndexConfig(AppConfig):
name = 'index'
def ready(self):
try:
sem = Semaphore(settings.TokenSemaphoreName)
sem.unlink()
except ExistentialError:
pass
finally:
Semaphore(settings.TokenSemaphoreName, flags=O_CREAT, initial_value=1)
修改視圖函數(shù)娱挨,如下
def index(request):
cache.incr(settings.CounterKey)
sem = Semaphore(settings.TokenSemaphoreName)
sem.acquire()
token = cache.get(settings.TokenKey)
if token is None:
token = create_access_token()
cache.set(settings.TokenKey, token, 5 * 60)
sem.release()
return HttpResponse(json.dumps({'token': token}), content_type='text/json')
測試2
在測試前,清空之前的數(shù)據(jù)捕犬,并刪除緩存的token
127.0.0.1:6379[1]> del ":1:token"
(integer) 1
127.0.0.1:6379[1]> set ":1:counter" 0
OK
127.0.0.1:6379[1]> set ":1:create" 0
OK
和之前一樣啟動(dòng)服務(wù)端和客戶端跷坝,在運(yùn)行一段時(shí)間后酵镜,退出客戶端。
查看結(jié)果柴钻,可以看到客戶端請求了980次淮韭,服務(wù)端只生成了一次token,這個(gè)結(jié)果正是我們想要的贴届。
127.0.0.1:6379[1]> get ":1:counter"
"980"
127.0.0.1:6379[1]> del ":1:create"
(integer) 1
多主機(jī)場景
看起來問題好像得到解決了靠粪?并沒有!
在實(shí)際的生產(chǎn)環(huán)境中毫蚓,為了保持服務(wù)的高可用占键,經(jīng)常會(huì)使用負(fù)載均衡這樣的技術(shù)。
在這樣的場景下使用上面的方案绍些,每臺(tái)服務(wù)器都會(huì)生成自己的信號(hào)量捞慌,在高并發(fā)的情況下依然會(huì)出現(xiàn)多次請求access token的情況。
測試
這里使用nginx來實(shí)現(xiàn)負(fù)載均衡柬批,使用docker來模擬多主機(jī)。
nginx的相關(guān)配置如下
...
upstream back {
server 127.0.0.1:8080;
server 127.0.0.1:8087;
}
...
...
location / {
proxy_pass http://back;
}
...
啟動(dòng)container
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c845d3123a08 python:2.7 "python2" 40 minutes ago Up 10 minutes 0.0.0.0:8080->8000/tcp python
在container中啟動(dòng)線程
gunicorn accessTokenTest.wsgi --workers 4 -b 0.0.0.0:8000
同時(shí)在宿主機(jī)上也啟動(dòng)線程
gunicorn accessTokenTest.wsgi --workers 4 -b 0.0.0.0:8087
和之前一樣袖订,測試前清除緩存中的token氮帐,并將counter和create設(shè)置為0
在宿主機(jī)上啟動(dòng)客戶端,在token緩存失效前斷掉
查看結(jié)果洛姑,可以看到客戶端一共請求了2062次上沐,生成了2次token,和預(yù)期的一致楞艾。
127.0.0.1:6379[1]> get ":1:create"
"2"
127.0.0.1:6379[1]> get ":1:counter"
"2062"
在多主機(jī)的情況下参咙,如果要確保請求access token的原子性,需要一種“分布式鎖”硫眯。
新的解決方案
采用redis來輔助實(shí)現(xiàn)分布式鎖蕴侧。盡管有著一定的爭論,但是能滿足現(xiàn)在的需求两入。
實(shí)現(xiàn)的算法來自redis作者的文章净宵,這里直接采用redlock-py。
服務(wù)端v3
def index(request):
cache.incr(settings.CounterKey)
dlm = Redlock([{"host": "your-host-ip", "port": 6379, "db": 0}, ])
my_lock = dlm.lock("my_resource_name", 1000)
token = cache.get(settings.TokenKey)
if token is None:
token = create_access_token()
cache.set(settings.TokenKey, token, 5 * 60)
dlm.unlock(my_lock)
return HttpResponse(json.dumps({'token': token}), content_type='text/json')
測試3
測試環(huán)境和之前一樣裹纳。更新代碼后择葡,重啟啟動(dòng)服務(wù)端,處理之前的redis緩存
啟動(dòng)客戶端一段時(shí)間后斷掉剃氧。
查看測試結(jié)果, 客戶端一共請求了88次敏储,生成了1次token,和預(yù)期也是一致的
127.0.0.1:6379[1]> get ":1:counter"
"88"
127.0.0.1:6379[1]> get ":1:create"
"1"