「吼唷,部落格文章一多,查個東西慢得跟烏龜爬一樣?快取又老是搞雪崩是怎樣?」
免驚!這次我們要請出三位超級英雄:
「ILIKE / trigram」: 讓你的搜尋像神探柯南一樣,找到你打的任何關鍵字!
「Redis 快取」: 準備一鍋「黃金高湯」,客人來只要舀一碗,不用每次都重煮!
「singleflight 防雪崩」: 廚房救星!就算一萬個人同時叫外賣,廚師也只煮一鍋,大家分著吃,資料庫才不會氣到爆炸!
「搜尋」 就像你的點餐小幫手:客人打「咖哩」,小幫手立刻把所有咖哩菜單丟出來,不用翻完整本。我們用 ILIKE(不分大小寫模糊查)或更厲害的 pg_trgm(三元組,查得更快更準,連錯字都可能找到!)。
「Redis 快取」 就像先煮好的黃金高湯:這是公開文章列表。大家同時來喝,不用每次都叫廚師(資料庫 DB)從頭開始煮一鍋湯。直接從高湯桶(Redis)舀,超快!
「singleflight 防雪崩」 就像廚房合併訂單系統:萬一高湯桶空了(快取失效),然後一千個客人同時喊「我要高湯!」如果廚師真的煮一千鍋,廚房會炸掉(DB 會掛)。singleflight 會說:「安靜!我只叫廚師煮一鍋,你們一百個先排隊喝一樣的!😎」
go-echo-blog/
├─ cmd/server/main.go # 加入 Redis 連線、singleflight、掛快取到 handler (主廚上線)
├─ internal/http/handlers/
│ ├─ api_public.go # /api/posts 列表+搜尋(掛上 Redis 高湯桶與 singleflight 廚房合併系統)
│ └─ front_posts.go # /search 頁面(HTML,前端看的)
├─ internal/storage/postgres/
│ ├─ posts.go # 新增 SearchPublished、ListPublishedWithTotal(DB 廚師的新菜單)
│ └─ db.go #(已存在)pgxpool
├─ internal/cache/
│ └─ redis_cache.go # Redis 包裝(高湯桶的取、放、還有「版本號」無痛失效魔法)
├─ migrations/
│ ├─ 20251011_enable_pg_trgm.sql # (可選)啟用 pg_trgm + 建索引 (讓神探柯南變更快)
├─ .env.example # 新增快取的設定
└─ ...
# Redis 連線 (你的黃金高湯桶在哪裡?)
REDIS_ADDR=127.0.0.1:6379
REDIS_PASSWORD=
REDIS_DB=0
# 快取 TTL(秒)(高湯可以放多久?先保守設 60 秒)
CACHE_TTL=60
# 搜尋是否啟用 pg_trgm(true/false)(要不要請神探柯南?如果沒跑 Migration 就先設 false)
USE_TRIGRAM=false
3.(可選)pg_trgm 三元組加速 Migration
如果你想要「更快更準」的模糊搜尋,Postgres 的 pg_trgm 超級好用!如果只是跑 MVP(最小可行產品),跳過也沒關係,先用純 ILIKE 跑。
migrations/20251011_enable_pg_trgm.sql
-- +goose Up
-- 啟用 pg_trgm 這個外掛
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 用 trigram 幫 title + content 兩者「全文」做 GIN 索引(加速模糊比對)
-- 就像幫書庫裡的書都做了超快速的索引卡一樣
CREATE INDEX IF NOT EXISTS idx_posts_trgm
ON posts USING GIN ( (lower(title || ' ' || content_md)) gin_trgm_ops )
WHERE published = true; -- 只針對已發佈文章做索引
-- +goose Down
DROP INDEX IF EXISTS idx_posts_trgm;
DROP EXTENSION IF EXISTS pg_trgm;
核心概念: 每次你新增/修改/刪除文章,我們就對一個叫 posts:ver 的數字 INCR(加一)。
快取的 Key 會變成:posts:v{版本號}:q={query}:p={page}。
版本號一變,舊的 Key 就再也搜不到了,新的 Key 自動產生,舊快取就等著過期被 Redis 清掉,不用自己慢慢刪!超讚!
internal/cache/redis_cache.go
package cache
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings" // 新增
"time"
"github.com/redis/go-redis/v9"
)
type RedisCache struct {
Client *redis.Client
TTL time.Duration
}
func NewRedis() *RedisCache {
addr := os.Getenv("REDIS_ADDR")
if addr == "" { addr = "127.0.0.1:6379" }
// 國中生:Atoi 是把文字變數字的意思
db, _ := strconv.Atoi(os.Getenv("REDIS_DB"))
pw := os.Getenv("REDIS_PASSWORD")
ttlSec, _ := strconv.Atoi(os.Getenv("CACHE_TTL"))
if ttlSec <= 0 { ttlSec = 60 }
rc := &RedisCache{
Client: redis.NewClient(&redis.Options{Addr: addr, Password: pw, DB: db}),
TTL: time.Duration(ttlSec) * time.Second,
}
// 順便檢查一下連線有沒有通(雖然程式碼沒寫,但精神上要檢查XD)
return rc
}
const postsVersionKey = "posts:ver" // 列表快取的版本號的 Key
// 版本號:列表類快取的命名空間(問現在高湯桶的版本是多少?)
func (r *RedisCache) Version(ctx context.Context) (int64, error) {
v, err := r.Client.Get(ctx, postsVersionKey).Int64()
if err == redis.Nil { // 如果是第一次用,版本號設 1
_ = r.Client.Set(ctx, postsVersionKey, 1, 0).Err()
return 1, nil
}
return v, err
}
// 當後台有寫入動作時呼叫(新增/修改/刪除/發佈切換):版本號 +1,舊快取就失效了!
func (r *RedisCache) BumpVersion(ctx context.Context) error {
return r.Client.Incr(ctx, postsVersionKey).Err() // Incr 就是加 1
}
// 組合出快取的 Key(高湯桶的名字)
func (r *RedisCache) KeyForList(ver int64, q string, page int) string {
// 正規化:小寫 + 去空白(確保 'APPLE' 和 'apple' 查到同一個快取)
return fmt.Sprintf("posts:v%d:q=%s:p=%d", ver, normalize(q), page)
}
func normalize(s string) string {
// 簡單處理:去掉頭尾空白 + 轉小寫
return strings.TrimSpace(strings.ToLower(s))
}
// Get:去高湯桶撈資料
func (r *RedisCache) GetJSON(ctx context.Context, key string, out any) (bool, error) {
b, err := r.Client.Get(ctx, key).Bytes()
if err == redis.Nil {
return false, nil // 沒找到快取,正常的
}
if err != nil {
return false, err // 撈資料失敗
}
return json.Unmarshal(b, out) == nil, nil // 找到,然後解 JSON
}
// Set:把剛煮好的高湯放回高湯桶
func (r *RedisCache) SetJSON(ctx context.Context, key string, v any) error {
b, err := json.Marshal(v)
if err != nil { return err }
return r.Client.Set(ctx, key, b, r.TTL).Err() // 設定 TTL (過期時間)
}
記得: 後台 CRUD 完成後,要在對應的 Handler 裡呼叫 cache.BumpVersion(ctx),這樣列表快取才會「自動」失效喔!
ListPublishedWithTotal:列出全部文章,順便算出總共有幾篇(給快取用)。
SearchPublished:處理搜尋請求,如果環境變數有設,就用 pg_trgm 加速版的 SQL。
internal/storage/postgres/posts.go
package postgres
import (
"context"
"os"
"strings"
"github.com/jackc/pgx/v5" // 新增
"github.com/jackc/pgx/v5/pgxpool"
)
// ... Post 結構體、PostsRepo 結構體、NewPostsRepo 函數(跟以前一樣,略)
// 這是為了快取列表加的功能:多回傳一個 Total 總數
type ListResult struct {
Items []Post
Total int
}
func (r *PostsRepo) ListPublishedWithTotal(ctx context.Context, page, perPage int) (ListResult, error) {
if page <= 0 { page = 1 }
if perPage <= 0 { perPage = 10 }
offset := (page - 1) * perPage
// 查文章列表
rows, err := r.db.Query(ctx, `
SELECT id,title,slug,content_md,summary,cover_image,created_at,updated_at
FROM posts
WHERE published = true
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, perPage, offset)
if err != nil { return ListResult{}, err }
defer rows.Close()
var items []Post
for rows.Next() {
var p Post
err = rows.Scan(&p.ID,&p.Title,&p.Slug,&p.ContentMD,&p.Summary,&p.CoverImage,&p.CreatedAt,&p.UpdatedAt)
if err != nil { return ListResult{}, err }
items = append(items, p)
}
// 再查總數 (很單純的 SELECT count(*))
var total int
// 這裡先忽略錯誤,萬一算不出來至少列表是正常的
_ = r.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE published=true`).Scan(&total)
return ListResult{Items: items, Total: total}, nil
}
// 處理搜尋:如果 q 是空的,就回傳 ListPublishedWithTotal
func (r *PostsRepo) SearchPublished(ctx context.Context, q string, page, perPage int) (ListResult, error) {
if page <= 0 { page = 1 }
if perPage <= 0 { perPage = 10 }
offset := (page - 1) * perPage
q = strings.TrimSpace(q)
if q == "" {
// 沒輸入關鍵字,就當作是查列表
return r.ListPublishedWithTotal(ctx, page, perPage)
}
useTrgm := strings.ToLower(os.Getenv("USE_TRIGRAM")) == "true" // 檢查是否啟用神探柯南
var rows pgx.Rows
var err error
var countSQL string // 總數要用的 SQL
if useTrgm {
// 神探柯南模式:用 trigram 加速
// 我們只查 title 和 content_md 欄位
// 注意:ILIKE + 索引 已經夠快了
rows, err = r.db.Query(ctx, `
SELECT id,title,slug,content_md,summary,cover_image,created_at,updated_at
FROM posts
WHERE published = true
AND lower(title || ' ' || content_md) ILIKE '%' || lower($1) || '%' -- $1 是關鍵字
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, q, perPage, offset)
// 總數 SQL:條件要跟上面一樣
countSQL = `
SELECT count(*) FROM posts
WHERE published = true
AND lower(title || ' ' || content_md) ILIKE '%' || lower($1) || '%'
`
} else {
// MVP 模式:純 ILIKE 模糊搜尋
// (title ILIKE ... OR content_md ILIKE ...)
rows, err = r.db.Query(ctx, `
SELECT id,title,slug,content_md,summary,cover_image,created_at,updated_at
FROM posts
WHERE published = true
AND (title ILIKE '%' || $1 || '%' OR content_md ILIKE '%' || $1 || '%') -- $1 是關鍵字
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, q, perPage, offset)
// 總數 SQL:條件要跟上面一樣
countSQL = `
SELECT count(*) FROM posts
WHERE published = true
AND (title ILIKE '%' || $1 || '%' OR content_md ILIKE '%' || $1 || '%')
`
}
if err != nil { return ListResult{}, err }
defer rows.Close()
var items []Post
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID,&p.Title,&p.Slug,&p.ContentMD,&p.Summary,&p.CoverImage,&p.CreatedAt,&p.UpdatedAt); err != nil {
return ListResult{}, err
}
items = append(items, p)
}
// 查總數 (這次條件會比較複雜,要帶 q)
var total int
if err := r.db.QueryRow(ctx, countSQL, q).Scan(&total); err != nil {
total = len(items) // 查總數失敗,至少回傳當頁的筆數
}
return ListResult{Items: items, Total: total}, nil
}
讀取流程:
查版本號 ver。
組出 Key:posts:v{ver}:q={query}:p={page}。
去 Redis 高湯桶撈。有? -> HIT -> 馬上回傳。
沒有? -> MISS ->
用 singleflight.Group.Do(key, ...) 把所有對同一個 Key 的請求合併成一個!
只有一個請求會去資料庫(DB)慢慢查。
查完,寫回 Redis 高湯桶。
所有在 singleflight 排隊的人,都拿到同一份結果!(成功防雪崩!)
internal/http/handlers/api_public.go
package handlers
import (
"context"
"net/http"
"strconv"
"strings"
"time" // 雖然沒直接用到 time,但 package 引用可能需要
"your/module/internal/cache"
"your/module/internal/storage/postgres"
"golang.org/x/sync/singleflight"
"github.com/labstack/echo/v4"
)
// PublicAPI 承載了 Repo、Cache 和 singleflight,是這次的主角!
type PublicAPI struct {
Repo *postgres.PostsRepo
Cache *cache.RedisCache
Group *singleflight.Group
}
func NewPublicAPI(repo *postgres.PostsRepo, c *cache.RedisCache, g *singleflight.Group) *PublicAPI {
return &PublicAPI{Repo: repo, Cache: c, Group: g}
}
// 回傳給客戶的 JSON 格式
type listResp struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
Items []postgres.Post `json:"items"`
}
// 輔助函數:把字串轉數字,轉不了或小於等於 0 就回傳預設值
func atoiDefault(s string, def int) int {
if v, err := strconv.Atoi(s); err == nil && v > 0 { return v }
return def
}
func (h *PublicAPI) List(c echo.Context) error {
q := strings.TrimSpace(c.QueryParam("q"))
page := atoiDefault(c.QueryParam("page"), 1)
perPage := atoiDefault(c.QueryParam("per_page"), 10)
// ---- 步驟 1: 準備快取 Key (含版本號)
ctx := c.Request().Context()
ver, err := h.Cache.Version(ctx)
if err != nil { ver = 1 } // Redis 掛了也不要整個壞掉,版本號先用 1 頂著
key := h.Cache.KeyForList(ver, q, page)
var out listResp
// ---- 步驟 2: 檢查快取 (去高湯桶撈)
// 這裡忽略快取讀取錯誤 (例如 Redis 突然掛了),會走到底下的 DB 流程 (優雅退化)
if ok, _ := h.Cache.GetJSON(ctx, key, &out); ok {
// HIT! 快取命中,開心!直接回傳
return c.JSON(http.StatusOK, out)
}
// ---- 步驟 3: 快取 MISS! 啟動 singleflight 防雪崩機制
// 同一把 key 的 miss 會合併成一次查詢 (只有一個人會去 DB 查)
// Do 的第一個參數就是 key
v, err, _ := h.Group.Do(key, func() (any, error) {
// 再檢一次:避免 N+1 競速,可能有人剛好寫進快取了
if ok, _ := h.Cache.GetJSON(ctx, key, &out); ok {
return out, nil
}
// 真的沒快取,去 DB 慢慢查(只會有一個請求進來)
var res postgres.ListResult
if q == "" {
res, err = h.Repo.ListPublishedWithTotal(ctx, page, perPage)
} else {
res, err = h.Repo.SearchPublished(ctx, q, page, perPage)
}
if err != nil { return nil, err } // 查詢失敗
// 查到資料了,組合結果,然後寫回快取
out = listResp{Page: page, PerPage: perPage, Total: res.Total, Items: res.Items}
_ = h.Cache.SetJSON(ctx, key, out) // 寫快取(失敗就算了,下次再寫)
return out, nil
})
// singleflight 執行失敗 (例如 DB 炸了)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "load failed"})
}
// 成功!v.(listResp) 就是 DB 查到或快取撈到的結果 (是介面要轉型)
return c.JSON(http.StatusOK, v.(listResp))
}
internal/http/handlers/front_posts.go(新增一個 /search 頁面輸出)
package handlers
import (
"net/http"
"strings"
"your/module/internal/storage/postgres"
"github.com/labstack/echo/v4"
)
type FrontSearch struct { Repo *postgres.PostsRepo }
func NewFrontSearch(r *postgres.PostsRepo) *FrontSearch { return &FrontSearch{Repo: r} }
func (h *FrontSearch) Page(c echo.Context) error {
q := strings.TrimSpace(c.QueryParam("q")) // 取得關鍵字
page := 1 // 範例簡化,只查第一頁
// 直接查 DB (這個頁面沒做快取,因為通常流量較小且需求較即時)
res, err := h.Repo.SearchPublished(c.Request().Context(), q, page, 10)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "search failed")
}
// 丟給 HTML 模板去渲染
return c.Render(http.StatusOK, "pages/search.html", map[string]any{
"title": "搜尋:" + q,
"q": q,
"items": res.Items,
"total": res.Total,
})
}
cmd/server/main.go(節錄)
package main
import (
"log/slog"
"net/http"
"os"
"golang.org/x/sync/singleflight" // 我們的廚房合併訂單系統
"github.com/labstack/echo/v4"
"your/module/internal/cache"
"your/module/internal/http/handlers"
"your/module/internal/storage/postgres"
)
func main() {
e := echo.New()
// 你原本的 Recover / Logger / CORS / Session / ErrorHandler ... 都在這裡
// ---- DB / Repo
// mustBuildPoolFromEnv() 是你既有的 DB 連線初始化函數
db := mustBuildPoolFromEnv()
repo := postgres.NewPostsRepo(db)
// ---- Redis + singleflight (請出兩位重量級英雄!)
rc := cache.NewRedis() // 高湯桶管理員
var group singleflight.Group // 廚房合併訂單系統
// ---- 公開 API:列表 + 搜尋(有快取、有防雪崩)
pub := handlers.NewPublicAPI(repo, rc, &group)
e.GET("/api/posts", pub.List) // 支援 ?q= &page= &per_page=
// ---- 前台搜尋頁(HTML)
search := handlers.NewFrontSearch(repo)
e.GET("/search", search.Page)
// 健檢 (確認程式還活著)
e.GET("/health", func(c echo.Context) error { return c.String(http.StatusOK, "ok") })
e.Logger.Fatal(e.Start(":1323"))
}
重點提醒: 後台 CRUD(新增/修改/刪除/發佈切換)做完後,記得呼叫 rc.BumpVersion(c.Request().Context()) 讓列表快取「自然」失效。
# 第一次(MISS,會查 DB,速度會比較慢一點)
curl -s 'http://localhost:1323/api/posts?page=1' | jq '.items | length'
# 再打一次(HIT,速度飛起來!高湯直接舀出來!)
curl -s 'http://localhost:1323/api/posts?page=1' | jq '.items | length'
# 搜尋(ILIKE 或 trigram 啟動)
curl -s 'http://localhost:1323/api/posts?q=echo&page=1' | jq '.total'
# 後台發佈一篇 → 讓快取失效
# 假設你的後台 API 是 /api/admin/posts
# (當你的後台 Handler 呼叫了 rc.BumpVersion(ctx) 後...)
# 舊的快取失效了,再打一次(會 MISS,然後重新產生一個新版本快取)
curl -s 'http://localhost:1323/api/posts?page=1' | jq '.items | length'
⏱️ TTL 設太長:CACHE_TTL 建議先設為 60 秒 保守,等流量大了再慢慢調整。
🧰 Redis 掛了:程式要能「優雅退化」(像上面那樣忽略錯誤、直接查 DB,不讓使用者感覺壞掉)。
🔍 搜尋無結果:ILIKE 本身大小寫不敏感,但如果用了 pg_trgm,要確保 Migration 有成功執行。
🔄 快取沒失效:記得確認後台寫入路徑有呼叫 BumpVersion(),讓版本號更新後自動失效快取。
🧊 雪崩了:一定要用 singleflight 包住每一把 key 的 miss,避免快取同時失效時大量請求直接打爆資料庫。