前往
大廳
主題 達人專欄

上手 Go 語言的 map,實作簡單快取機制

解凍豬腳 | 2023-03-12 15:39:05 | 巴幣 3434 | 人氣 984

 
本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版



我們來試想一個情景:你想要替你經營的量販店開發一套商品資訊管理系統。

在你的店裡,每一種商品都有獨立的編號——D 開頭的是飲品類(drink)、F 開頭的是食物類(food)、T 開頭的是工具類(tool),像是:
。D0001:牛奶($50)
。D0002:綠茶($25)
。D0003:果汁($45)
。F0001:披薩($120)
。F0002:火鍋料組合($70)
。F0003:牛肉($260)
。T0001:螺絲起子($30)
。T0002:電池($25)

當然你的店裡販售的也許遠遠不只這些。你的後台系統利用 SQL 資料庫存放了上萬種不同商品的資料,一般來說我們只要讓程式連結資料庫,執行像這樣的 SQL 語句:

SELECT gid, price, name FROM goods WHERE gid = 'F0002'

就可以從資料庫撈出編號為 F0002(也就是火鍋料組合)的商品資訊了。

然而,從資料庫查詢總是需要花費時間的,畢竟我們並不是把資料直接從記憶體抓出來。再說如果我們每天都賣出很多組同一個廠牌的火鍋料,那麼收銀系統就需要頻繁地向資料庫執行相同的查詢、得到每次都一樣的結果,同時還佔用了資料庫的運算資源,這種做法顯然沒有效率。

為了避免這樣的浪費,我們在抓取資料以後把這些得到的資料留在變數裡備用,當我們下次再遇到相同的編號,直接把資料從變數抓出來就好了,額外犧牲一點儲存空間來換取效率、獲得更好的使用體驗,這就是簡單的快取(cache)機制。

於是我們把這個查詢資料庫的步驟改成:

1. 從快取專用的變數當中檢查是不是有 F0002 這筆資料
2. 如果有,直接從變數取得 F0002 的商品資訊,並且跳到第四步;如果沒有,從資料庫執行查詢
3. 把從資料庫查詢到的資料存到快取專用的變數裡
4. 完成


► 低效率的實作方法

問題來了:我們該如何存放它呢?

如果只根據這系列先前所有文章的知識,當我們想要存放很多筆相同型態的資料,我們會知道可以利用 struct 來包裝商品資訊,然後再用 slice 把它們串在一起:

type goods struct {
    ID    string
    Name  string
    Price int
}

var goodsCache []*goods

func getGoodsFromCache(id string) (goodsInfo *goods, exists bool) {
    for _, goods := range goodsCache {
        if goods.ID == id {
            return goods, true
        }
    }
    return nil, false
}

func addGoodsIntoCache(goodsInfo *goods) {
    goodsCache = append(goodsCache, goodsInfo)
}

func getGoodsFromDatabase(id string) (result *goods) {
    // ... do query from database
    return nil
}

func getGoodsInfo(id string) (result *goods) {
    goodsInfo, exists := getGoodsFromCache(id)
    if !exists { // if cannot find the goods from the goodsCache
        return getGoodsFromDatabase(id) // do query from database
    }
    return goodsInfo
}

只要從資料庫執行了查詢語句,就直接把得到的資料用 addGoodsIntoCache 函數把資料往 goodsCache 這個池子裡丟。

之後,每次想取得資料的時候執行 getGoodsInfo 函式,讓程式從 goodsCache 裡面一個一個檢查是否有編號相同的商品——如果存在(if exists)就直接把資料從變數抓出來用,不存在(if !exists)的話就執行資料庫的查詢語句。

這就是一個簡單的快取機制。


► 遍歷整個 slice 造成的效能問題

但是上面的方法不太聰明,因為它需要逐一檢查 cache 裡面的元素,而不是一口氣指出目標資料的位置。假如當下的 cache 裡面有 7000 筆不同的商品資料,而我們想抓取的商品被放在 cache 表的第 5000 位,那麼我們每一次在找這項商品的時候,都會有 4999 次的讀取是沒有意義的。

沒錯,雖然陣列和切片可以存放大量同性質的變數,但它們只適合用來放些連續的、時常需要一起使用的資料。

我們需要的是「給它一個獨特編號,讓它快速地找到對應的內容」的功能,這種時候就適合使用 Golang 的 map。比如現在有 John、Alex、Emily 三個不同名字的人,我想要簡單快速地儲存他們身上的存款數值資料,並且我希望能夠直接用名字得到對應的存款數值,我們可以這麼做:

money := map[string]int{}
money["John"] = 150
money["Alex"] = 800
money["Emily"] = 480
fmt.Println(money["Alex"]) // -> 800

以上面為例,我們定義這個 map 是輸入一個 string、得到一個 int,這個輸入值(key)必須是獨一無二的,就好像電腦作業系統裡同一個資料夾底下的檔案名字不能重複(否則會被覆蓋)。

你可以依據自己的需求來選擇 map 輸入輸出值的資料型態,譬如你今天想要用一張 map 儲存這三個人的地址,那麼我們當然是使用「輸入一個 string、得到一個 string」的 map:

address := map[string]string{}
address["John"] = "高雄市阿姨洗鐵路 103 號"
address["Alex"] = "臺北市天龍路 27 號"
address["Emily"] = "屏東縣豬腳街 10 號"

這個 map 功能在不同的程式語言有不同的名字,你可能會聽到雜湊表、哈希表、關聯式陣列、hashmap、hashtable、dictionary……實際上它們的原理大同小異。

你可以把它們理解成是一種離散的 slice,只不過 slice 的資料都是依據有序的整數 index 來排列,而 map 則可以靈活地寫入 key-value 形式的對應關係。

懂得使用 map 以後,我們就可以把剛才寫得比較醜的功能改寫,把 cache 設計成一個輸入 string(商品編號)、輸出 *goods(商品資訊)的 map:

type goods struct {
    ID    string
    Name  string
    Price int
}

var goodsCache = map[string]*goods{}

func getGoodsFromCache(id string) (goodsInfo *goods, exists bool) {
    goods, exists := goodsCache[id]
    return goods, exists
}

func addGoodsIntoCache(goodsInfo *goods) {
    goodsCache[goodsInfo.ID] = goodsInfo
}

func getGoodsFromDatabase(id string) (result *goods) {
    // ... do query from database
    return nil
}

func getGoodsInfo(id string) (result *goods) {
    goodsInfo, exists := getGoodsFromCache(id)
    if !exists { // if cannot find the goods from the goodsCache
        return getGoodsFromDatabase(id) // do query from database
    }
    return goodsInfo
}

利用 map 來做一層快取,我們就可以省下不少查詢所需的時間了。除了能夠改善使用者體驗之外,對於資料庫的伺服端來說也能減輕很多壓力。

當然,這只是一個簡單的 key-value 形式資料的讀寫應用範例,如果是已經熟悉 Golang 的朋友們在這方面有更大需求的話,不妨去研究看看 Redis。


► Map 的原理簡述

像 map 這樣的功能在某些語言裡被稱作「hashmap」、「hashtable」不是沒有原因。

先前我曾經在幾篇文章介紹過「雜湊函數」(hash function)的不同應用場景:
巴哈帳密可能外流?快來了解你的帳號風險!(防止明文密碼被直接外洩)
如何舉辦公平又公開的抽獎?用雜湊函數來做吧!(可重現且被信任的偽隨機數)
比特幣的「挖礦」是怎麼回事?它是如何憑空出現的?(動態調整挖礦難度、提高攻擊成本)

hashmap 正是利用了雜湊函數作為它的核心,用來分發資料的儲存位置——資料的 key 名稱可以有千千萬萬種,但只要利用雜湊函數就可以轉換成數字,藉此把資料快速定位到分散的不同位置。因為它的底層原理涉及到針對哈希碰撞問題的各種優化方案,真要細說篇幅會很恐怖,所以用很簡單的方式舉例就好了。

就拿我們剛才提到用 map 儲存身上金錢多寡的例子:

money := map[string]int{}
money["John"] = 150
money["Alex"] = 800
money["Emily"] = 480

我們可以把 money 這張 map 理解成是一個整齊的置物櫃,置物櫃上面有編號 0~15 總共 16 個抽屜。

在寫入的時候,電腦把字串 John 經由某個雜湊函數 h 計算得到數字 164201,然後再把 164201 除以 16 得到一個餘數 9,接下來就可以知道 9 號抽屜是 John 分配到的存放位置了。當我們想讀取 money[“John”] 的時候也一樣,電腦做相同的步驟得到結果是 9,這樣只需要找 9 號抽屜就可以很快地確定這筆資料的內容了。

以上面例子來說,因為抽屜只有 16 個,所以我們往 map 裡儲存的資料筆數越多,就越可能出現多筆資料被放在同一個抽屜裡的情況,這就是所謂的哈希碰撞(hash collision)。如果資料實在太多的話,為了避免在抽屜裡翻找的時間過久,這個置物櫃就會自動長出更多不同編號的抽屜,以縮小資料的定位範圍。

這就是 hashmap 的基本原理。


► Map 的使用小技巧和注意事項

就和 struct pointer 一樣,如果只有宣告變數類型,那麼實際上這個變數的值就會是 nil,什麼都沒有。這就好像你在房間角落準備了一個可以放得下置物櫃的空間,但你還沒有真的組裝置物櫃,那這個地方自然就沒有置物櫃的功能。

這種情況下若是直接把它拿來用,會發生 panic:

func main() {
    var m map[string]string
    m["A123"] = "B456"
    fmt.Println(m["A123"]) // -> panic: assignment to entry in nil map
}

我們必須用 make 函數,讓它在宣告 map 型態的同時初始化,產生 map 實體:

func main() {
    var m = make(map[string]string)
    m["A123"] = "B456"
    fmt.Println(m["A123"]) // -> B456
}

也可以使用簡化的方法,產生一個已經初始化的 map[string]string 實體:

func main() {
    m := map[string]string{}
    m["A123"] = "B456"
    fmt.Println(m["A123"]) // -> B456
}

當然,map 可以在宣告的時候定義初始值:

func main() {
    m := map[string]string{
        "C789": "D987",
        "E654": "F321",
    }
    fmt.Println(m["C789"]) // -> D987
}

使用 map 的時候,也要注意值的「存在」不應該用 zero-value(型態預設值)來判斷。

比方說,如果宣告了一個 map[string]int,試圖從中取得一個不存在的值,我們會得到 0,因為這是 int 型態變數的預設值:

func main() {
    money := map[string]int{
        "Alex": 100,
        "Jack": 0,
    }
    fmt.Println(money["Jack"]) // -> 0
    fmt.Println(money["Tim"]) // -> 0
}

從上面的 code 我們會發現,無論是身上沒錢的 Jack 還是沒有資料的 Tim,我們都會得到 0 的結果,「是否等於 0」和「鍵值是否存在」完全是兩回事。

在 Golang 設計的 map 當中,如果我們想要確認這個值是否存在,就要用兩個變數來處理。如果這個 key 存在的話,第二個變數就會是 true(反之則 false):

func main() {
    money := map[string]int{
        "Alex": 100,
        "Jack": 0,
    }
    valueJ, existsJ := money["Jack"]
    valueT, existsT := money["Tim"]
    fmt.Println(valueJ, existsJ) // -> 0 true
    fmt.Println(valueT, existsT) // -> 0 false
}

也就是說,如果我們想要判斷 money 裡面是否有 Tim 這個 key,正確的做法是這樣:

_, exists := money["Tim"]
if exists {
    fmt.Println("在 map money 當中找到了 Tim 的資料")
}

在 Golang 也有個特殊的寫法,我們可以把賦值和條件判斷混合到同一句裡:

if _, exists := money["Tim"]; exists {
    fmt.Println("在 map money 當中找到了 Tim 的資料")
}

這麼做的話,exists 這個變數只在 if 語句裡面有效,這可以避免多餘的變數作用域(這是好習慣),我會推薦使用後者的混合寫法。

如果想要從 map 當中刪除某個值,只要使用內建的 delete(map, key) 函數就可以了:

func main() {
    money := map[string]int{
        "Alex": 100,
        "Jack": 0,
    }
    valueJ, existsJ := money["Jack"]
    fmt.Println(valueJ, existsJ) // -> 0 true
    delete(money, "Jack")
    valueJ, existsJ = money["Jack"]
    fmt.Println(valueJ, existsJ) // -> 0 false
}

到這裡為止,基本上就是 Golang 的 map 使用方法了。


► Golang 原生 map 的缺點

剛才提到了 Golang 的 map 底層機制是很複雜的,它涉及資料地址重新分配的問題,這當中會需要很多個步驟——用 Golang 的術語來說,Golang 的 map 寫入操作是「非原子」的,假如我們開發的程式在多個不同的執行緒對同一個 map 執行讀寫操作,那就會發生 race condition。

針對這種執行緒之間的讀寫衝突問題,一般我們會用 sync.Mutex 的獨佔鎖來保證當前只有一個執行緒可以存取它。說到這個,我之前閒來沒事故意拿了會發生 race condition 的程式碼給 ChatGPT 檢查,沒想到它不只能夠檢測得出問題,還能給我修正後的版本,真的很恐怖:



遇到這種需要多執行緒讀寫的需求下,直接使用內建的 sync.Map 或是效能更佳的 concurrent-map 來取代原生 map,也都是不錯的方案。

不過,因為細講下去的話就會涉及超大的 concurrency 議題,這可以講的東西非常多(e.g. goroutine, channel, atomic, sync),關於這部分我們就留到之後再來發一系列的文章吧。




縮圖素材原作者:Renée French(CC BY-SA 3.0)
送禮物贊助創作者 !
0
留言

創作回應

幻焰舞空
豬腳好厲害..
2023-03-12 15:44:30
很安全
hashtable最近也在找這個探索法。
2023-03-14 06:37:14

更多創作