并發(fā)下資源的訪問控制

背景

在開發(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ù)器的做法如下圖

請求access token流程

假設(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ù)。

負(fù)載均衡

在這樣的場景下使用上面的方案绍些,每臺(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"
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末朋鞍,一起剝皮案震驚了整個(gè)濱河市已添,隨后出現(xiàn)的幾起案子妥箕,更是在濱河造成了極大的恐慌,老刑警劉巖酝碳,帶你破解...
    沈念sama閱讀 216,997評論 6 502
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件矾踱,死亡現(xiàn)場離奇詭異,居然都是意外死亡疏哗,警方通過查閱死者的電腦和手機(jī)呛讲,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,603評論 3 392
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來返奉,“玉大人贝搁,你說我怎么就攤上這事⊙科” “怎么了雷逆?”我有些...
    開封第一講書人閱讀 163,359評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長污尉。 經(jīng)常有香客問我膀哲,道長,這世上最難降的妖魔是什么被碗? 我笑而不...
    開封第一講書人閱讀 58,309評論 1 292
  • 正文 為了忘掉前任某宪,我火速辦了婚禮,結(jié)果婚禮上锐朴,老公的妹妹穿的比我還像新娘兴喂。我一直安慰自己,他們只是感情好焚志,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,346評論 6 390
  • 文/花漫 我一把揭開白布衣迷。 她就那樣靜靜地躺著,像睡著了一般酱酬。 火紅的嫁衣襯著肌膚如雪壶谒。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,258評論 1 300
  • 那天岳悟,我揣著相機(jī)與錄音佃迄,去河邊找鬼。 笑死贵少,一個(gè)胖子當(dāng)著我的面吹牛呵俏,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播滔灶,決...
    沈念sama閱讀 40,122評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼普碎,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了录平?” 一聲冷哼從身側(cè)響起麻车,我...
    開封第一講書人閱讀 38,970評論 0 275
  • 序言:老撾萬榮一對情侶失蹤缀皱,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后动猬,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體啤斗,經(jīng)...
    沈念sama閱讀 45,403評論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,596評論 3 334
  • 正文 我和宋清朗相戀三年赁咙,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了钮莲。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,769評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡彼水,死狀恐怖崔拥,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情凤覆,我是刑警寧澤链瓦,帶...
    沈念sama閱讀 35,464評論 5 344
  • 正文 年R本政府宣布,位于F島的核電站盯桦,受9級(jí)特大地震影響慈俯,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜拥峦,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,075評論 3 327
  • 文/蒙蒙 一肥卡、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧事镣,春花似錦、人聲如沸揪胃。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,705評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽喊递。三九已至随闪,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間骚勘,已是汗流浹背铐伴。 一陣腳步聲響...
    開封第一講書人閱讀 32,848評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留俏讹,地道東北人当宴。 一個(gè)月前我還...
    沈念sama閱讀 47,831評論 2 370
  • 正文 我出身青樓,卻偏偏與公主長得像泽疆,于是被迫代替她去往敵國和親户矢。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,678評論 2 354

推薦閱讀更多精彩內(nèi)容