Go實(shí)戰(zhàn) | 記一次降低30%的CPU使用率的優(yōu)化
大家好,我是漁夫子。今天聊聊在項(xiàng)目中通過(guò)優(yōu)化redis寫入而降低cpu使用率的一次經(jīng)歷。
01 背景
本文是項(xiàng)目中基于redis記錄實(shí)時(shí)請(qǐng)求量的一個(gè)功能,因流量上漲造成redis服務(wù)器的CPU高于80%而觸發(fā)了自動(dòng)報(bào)警機(jī)制,經(jīng)分析將實(shí)時(shí)寫入redis的方式變更成批量寫入的方式,從而將CPU使用率降低了30%左右的經(jīng)歷。
具體業(yè)務(wù)需求是這樣的:我們會(huì)將接受到的請(qǐng)求按地域?qū)傩赃M(jìn)行劃分。目標(biāo)是針對(duì)具體的國(guó)家請(qǐng)求進(jìn)行總數(shù)的控制。當(dāng)達(dá)到預(yù)設(shè)的最大請(qǐng)求數(shù)時(shí),就不再處理該流量,直接給客戶端返回204響應(yīng)。如果沒(méi)有達(dá)到最大請(qǐng)求數(shù),則需要對(duì)實(shí)時(shí)請(qǐng)求數(shù)+1。如下圖所示:

02 實(shí)現(xiàn)版本一
第一個(gè)版本很簡(jiǎn)單,就是將最大值存放在redis中,然后按天的維度記錄每個(gè)國(guó)家流量的實(shí)時(shí)請(qǐng)求數(shù)量。每次流量來(lái)了之后,先查詢出該國(guó)家流量的最大值,以及當(dāng)天的實(shí)時(shí)請(qǐng)求數(shù),然后做比較,如果實(shí)時(shí)數(shù)已經(jīng)超過(guò)了最大值,就直接返回,否則就對(duì)實(shí)時(shí)數(shù)進(jìn)行+1操作即可。
下面我們以來(lái)自中國(guó)(用CN表示)流量為例進(jìn)行說(shuō)明。首先,我們存在redis中的key的規(guī)則如下:
代表某個(gè)國(guó)家最大請(qǐng)求數(shù)的key表示規(guī)則:國(guó)家:max:req
代表某個(gè)國(guó)家當(dāng)天已產(chǎn)生的請(qǐng)求數(shù)的key表示規(guī)則:國(guó)家:YYYYMMDD:req ,有效期為N天。
第一個(gè)版本的實(shí)現(xiàn)代碼如下:
func HasExceedLimitReq() bool {
key := "CN:max:req"
maxReq := redis.Get(key)
day := time.Now().Format("20060102")
dailyKey := "CN:"+day+":req"
dailyReq := redis.Get(dailyKey)
if dailyReq > maxReq {
return true
}
redis.Incr(dailyKey, dailyReq)
redis.Expire(dailyKey, 7*24*time.Hour)
return false
}
在上面的實(shí)現(xiàn)中,對(duì)于dailyKey我們不需要長(zhǎng)期保留,實(shí)際上只要過(guò)了當(dāng)天,該key的值就沒(méi)用了,出于查詢歷史數(shù)據(jù)的原因,我們就設(shè)置了7天的有效期。但redis的Incr操作不帶過(guò)期時(shí)間,所以就在Incr操作后增加了一個(gè)Expire的操作。
好了,我們看下這個(gè)實(shí)現(xiàn)會(huì)有什么問(wèn)題。首先邏輯上沒(méi)什么問(wèn)題。當(dāng)一個(gè)請(qǐng)求進(jìn)來(lái)之后,在沒(méi)有超量的情況下,我們會(huì)對(duì)redis有4次操作:兩次查詢操作和兩次寫操作(incr和expire)。也就是說(shuō),redis扛的QPS是流量本身的4倍。如果當(dāng)流量QPS不斷增長(zhǎng)的時(shí)候,比如達(dá)到了10萬(wàn),那么redis收到的請(qǐng)求量就是40萬(wàn)。redis的CPU消耗自然也就上來(lái)了。
那么我們看看哪些地方是可以優(yōu)化的呢?首先就是Expire操作看起來(lái)不是每次都需要,理論上只要設(shè)置一次過(guò)期時(shí)間就可以了,不需要每次都設(shè)置,這樣就可以減少一次寫操作。如下實(shí)現(xiàn)版本二
03 實(shí)現(xiàn)版本二:減少Expire的執(zhí)行次數(shù)
我們通過(guò)使用一個(gè)hasUpdateExpire的map類型,來(lái)記錄某個(gè)key是否已經(jīng)被設(shè)置了有效期的標(biāo)識(shí)。如下:
var hasUpdateExpire = make(map[string]struct{}) //全局變量
func HasExceedLimitReq() bool {
key := "CN:max:req"
maxReq := redis.Get(key)
day := time.Now().Format("20060102")
dailyKey := "CN:"+day+":req"
dailyReq := redis.Get(dailyKey)
if dailyReq > maxReq {
return true
}
redis.Incr(dailyKey, dailyReq)
if hasUpdateExpire[dailyKey]; !ok {
redis.Expire(dailyKey, 7*24*time.Hour)
hasUpdateExpire[dailyKey] = struct{}{}
}
return false
}
我們知道在Go中,map是非并發(fā)安全的。那么下面這段代碼是存在并發(fā)安全的:
if hasUpdateExpire[dailyKey]; !ok {
redis.Expire(dailyKey, 7*24*time.Hour)
hasUpdateExpire[dailyKey] = struct{}{}
}
也就是說(shuō)有可能有多個(gè)協(xié)程同時(shí)執(zhí)行到了if hasUpdateExpire[dailyKey]這里,并且都獲取到了ok為false的值,那么這時(shí)就會(huì)有多個(gè)協(xié)程都會(huì)執(zhí)行如下兩行代碼:
redis.Expire(dailyKey, 7*24*time.Hour)
hasUpdateExpire[dailyKey] = struct{}{}
但這里根據(jù)我們業(yè)務(wù)的場(chǎng)景,即使多執(zhí)行幾次Expire操作也沒(méi)關(guān)系,在QPS高的情況下,比起總的請(qǐng)求次數(shù)來(lái)說(shuō)多設(shè)置expire幾次可以忽略。
那如果qps再繼續(xù)增加怎么辦?那就是異步批量寫入。這種寫入方式適合于那種對(duì)計(jì)數(shù)不要求準(zhǔn)確的場(chǎng)景。我們來(lái)看看版本三。
04 實(shí)現(xiàn)版本三:異步批量寫入
在該版本中,我們的技術(shù)不直接寫入redis,而是寫在內(nèi)存緩存中,即一個(gè)全局變量中,同時(shí)啟動(dòng)一個(gè)定時(shí)器,每隔一段時(shí)間就將內(nèi)存中的數(shù)據(jù)批量寫入到redis中。如下圖所示:?
所以 我們定義了如下數(shù)據(jù)結(jié)構(gòu):
import (
"sync"
"time"
"github.com/go-redis/redis"
)
const (
DefaultExpiration = 86400 * time.Second * 7
)
type CounterCache struct {
rwMu sync.RWMutex
redisClient redis.Cmdable
countCache map[string]int64
hasUpdateExpire map[string]struct{}
}
func NewCounterCache(redisClient redis.Cmdable) *CounterCache {
c := &CounterCache{
redisClient: redisClient,
countCache: make(map[string]int64),
}
go c.startFlushTicker()
return c
}
func (c *CounterCache) IncrBy(key string, value int64) int64 {
val := c.incrCacheBy(key, value)
redisCount, _ := c.redisClient.Get(key).Int64()
return val + redisCount
}
func (c *CounterCache) incrCacheBy(key string, value int64) int64 {
c.rwMu.Lock()
defer c.rwMu.Unlock()
count := c.countCache[key]
count += value
c.countCache[key] = count
return count
}
func (c *CounterCache) Get(key string) (int64, error) {
cacheVal := c.get(key)
redisValue, err := c.redisClient.Get(key).Int64()
if err != nil && err != redis.Nil {
return cacheVal, err
}
return redisValue + cacheVal, nil
}
func (c *CounterCache) get(key string) int64 {
c.rwMu.RLock()
defer c.rwMu.RUnlock()
return c.countCache[key]
}
func (c *CounterCache) startFlushTicker() {
ticker := time.NewTicker(time.Second * 5)
for {
select {
case <-ticker.C:
c.flush()
}
}
}
func (c *CounterCache) flush() {
var oldCountCache map[string]int64
c.rwMu.Lock()
oldCountCache = c.countCache
c.countCache = make(map[string]int64)
c.rwMu.Unlock()
for key, value := range oldCountCache {
c.redisClient.IncrBy(key, value)
if _, ok := c.hasUpdateExpire[key]; !ok {
err := c.redisClient.Expire(key, DefaultExpiration)
if err == nil {
c.hasUpdateExpire[key] = struct{}{}
}
}
}
}
這里主要的思想就是在寫入數(shù)據(jù)的時(shí)候先暫存在結(jié)構(gòu)體的countCache中。然后每個(gè)CounterCache實(shí)例都會(huì)啟動(dòng)一個(gè)定時(shí)器ticker,該定時(shí)器每隔一段時(shí)間就將countCache中的數(shù)據(jù)更新到redis中。我們看下這的使用方式:
package main
import (
"net/http"
"sync"
"time"
"github.com/go-redis/redis"
)
var counterCache *CounterCache
func main() {
redisClient := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
})
counterCache = NewCounterCache(redisClient)
http.HandleFunc("/", IndexHandler)
http.ListenAndServe(":8080", nil)
}
func IndexHandler(w http.ResponseWriter, r *http.Request) {
if HasExceedLimitReq() {
return
}
//處理正常邏輯
}
func HasExceedLimitReq() bool {
maxKey := "CN:max:req"
maxCount, _ := counterCache.Get(maxKey)
dailyKey := "CN:" + time.Now().Format("20060102") + ":req"
dailyCount, _ := counterCache.Get(dailyKey)
if dailyCount > maxCount {
return true
}
counterCache.IncrBy(dailyKey, 1)
return false
}
這里的使用場(chǎng)景就是在對(duì)計(jì)數(shù)不要求準(zhǔn)確的情況下使用的。比如說(shuō)如果服務(wù)器異常退出了,那么暫存在countCache中還沒(méi)來(lái)得及刷新到redis中的數(shù)據(jù)就會(huì)造成丟失。
另外一點(diǎn)需要注意的就是countCache變量是一個(gè)map,我們知道,在Go中map是非并發(fā)安全的操作,所以要注意加讀寫鎖。
05 總結(jié)
隨著服務(wù)qps的增長(zhǎng),我們?cè)诓幌拗苢ps的前提下,各種資源的使用率都會(huì)增長(zhǎng)。我們的優(yōu)化思路就是減少不必要的寫次數(shù)、由實(shí)時(shí)寫更改成批量寫的思想,從而達(dá)到減少對(duì)redis操作的目的。這種計(jì)數(shù)方式使用的場(chǎng)景是在對(duì)計(jì)數(shù)要求不那么準(zhǔn)確的情況,例如視頻的播放量、微博大V的閱讀量等等。
想要了解關(guān)于 Go 的更多資訊,還可以通過(guò)掃描的方式,進(jìn)群一起探討哦~
