當一個資料很少變動、但讀取頻繁的 API(例如「獲取使用者個人資料」)被大量呼叫時,每一次都去查詢資料庫會造成巨大的浪費和壓力。快取,就是將這些熱點資料暫時存放在一個讀取速度更快的儲存(通常是記憶體)中,以加速回應並降低後端資料庫的負載。
Redis 是一個開源的、高效能的、基於記憶體的 Key-Value 儲存,是實現快取服務的絕佳選擇。
我們將採用最常用、最經典的 Cache-Aside(旁路快取) 模式。其工作流程如下:
這我們已經在先前的環境設定中完成了,就不在這邊贅述了。[Day 17] Dockerize 你的 Go 應用:使用 Docker Compose 打造一致的開發環境
我們這裡會將架構分成兩層:
我們先來實作一個簡單的 Redis 客戶端,負責與 Redis 進行互動。
這個客戶端會提供基本的 Get
和 Set
和 Del
以及 SetModel
和 GetModel
方法,分別用於讀取和寫入快取資料。
// internal/database/redis/redis.go
package redis
import (
"github.com/redis/go-redis/v9"
)
type Client struct {
client *redis.Client
}
func (c *Client) Set(ctx context.Context, key string, value string, expiration time.Duration) error {
return c.client.Set(ctx, key, value, expiration).Err()
}
func (c *Client) Get(ctx context.Context, key string) (string, error) {
return c.client.Get(ctx, key).Result()
}
func (c *Client) SetModel(ctx context.Context, key string, value any, expiration time.Duration) error {
return c.client.Set(ctx, key, value, expiration).Err()
}
func (c *Client) GetModel(ctx context.Context, key string, value any) error {
v := c.client.Get(ctx, key)
if v.Err() != nil {
return v.Err()
}
err := v.Scan(value)
if err != nil {
return err
}
return nil
}
func (c *Client) Del(ctx context.Context, keys ...string) error {
return c.client.Del(ctx, keys...).Err()
}
在我們使用的 Redis Go 客戶端中,Set 會自動將傳入的資料轉換為位元組陣列([]byte
)如果傳入的型別符合 encoding.BinaryMarshaler 介面,
而 Scan 則會將讀取到的位元組陣列轉換回原本的型別,如果傳入的型別符合 encoding.BinaryUnmarshaler 介面。
但是如果每一個型別都要實作這兩個介面,會非常麻煩。
因此,我們可以實作一個型別,讓它內嵌我們的資料型別,並實作這兩個介面,來達到自動轉換的效果。
這邊以 gob 為例,實作一個泛型結構 Container[T],用來包裝任意型別 T 的資料,並實作編碼和解碼功能。
當然也可以使用 JSON 或其他編碼方式。
// pkg/gob/gob.go
package gob
import (
"bytes"
"encoding/gob"
)
// Container 是一個泛型結構,用於包裝任意型別的資料,並實作編碼和解碼功能。
type Container[T any] struct {
RawValue T
}
// 實作 encoding.BinaryMarshaler 介面
func (c Container[T]) MarshalBinary() ([]byte, error) {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
if err := enc.Encode(c.RawValue); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// 實作 encoding.BinaryUnmarshaler 介面
func (c *Container[T]) UnmarshalBinary(data []byte) error {
buf := bytes.NewReader(data)
dec := gob.NewDecoder(buf)
if err := dec.Decode(&c.RawValue); err != nil {
return err
}
return nil
}
這裏會實作一些提供外部存取的 function,這些 function 會負責快取名稱的定義和快取失效的處理,以及快取內容的型別轉換等等。
建立一個 Service 結構,內含 Redis 客戶端。
// internal/service/cache/cache.go
package service
type Service struct {
client *redis.Client
}
func NewService(client *redis.Client) *Service {
return &Service{client: client}
}
建立一些協助轉換的 function。
這裏因為使用了 Generic,所以會變成把 service 當作參數傳入。
// internal/service/cache/help.go
func setGOBModel[T any](ctx context.Context, key string, model *T, expire time.Duration, s *Service) error {
value := gob.Container[T]{RawValue: *model}
return s.client.SetModel(ctx, key, value, expire)
}
func getGOBModel[T any](ctx context.Context, key string, s *Service) (*T, error) {
var value gob.Container[T]
err := s.client.GetModel(ctx, key, &value)
if err != nil {
return nil, err
}
return &value.RawValue, nil
}
建立一些快取相關的 function。
// internal/service/cache/cache.go
const expirationBranchInfosKey = 24 * 60 * 60 // 1 day
func (s *Service) getBranchInfosKey() string {
return "branch_infos"
}
func (s *Service) GetBranchInfos(ctx context.Context) (*[]domain.BranchInfo, error) {
key := s.getBranchInfosKey()
return getGOBModel[[]domain.BranchInfo](ctx, key, s)
}
func (s *Service) SetBranchInfos(ctx context.Context, model *[]domain.BranchInfo) error {
key := s.getBranchInfosKey()
return setGOBModel(ctx, key, model, expirationBranchInfosKey, s)
}
func (s *Service) VoidBranchInfos(ctx context.Context) error {
key := s.getBranchInfosKey()
return s.client.Del(ctx, key)
}
這時候,我們已經很明確地把快取的邏輯和名稱都封裝在服務層中了,
這樣就能確保其他程式碼不會直接操作 Redis,而是透過這些服務層的 function 來進行快取的讀寫和失效處理。
到這裡,我們已經完成了快取服務的基本架構和實作。
我們實作了一個簡單的 Redis 客戶端,並且在服務層中封裝了快取的邏輯和名稱。
我們會在下一篇,繼續 Cache-Aside Pattern 的實作,並探討一些快取的最佳實踐和注意事項。