iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
Modern Web

Golang x Echo 30 天:零基礎GO , 後端入門系列 第 28

以 Go + Echo 打造部落格|第 12 集 全文搜尋 & Redis 快取

  • 分享至 

  • xImage
  •  

「吼唷,部落格文章一多,查個東西慢得跟烏龜爬一樣?快取又老是搞雪崩是怎樣?」

免驚!這次我們要請出三位超級英雄:

「ILIKE / trigram」: 讓你的搜尋像神探柯南一樣,找到你打的任何關鍵字!

「Redis 快取」: 準備一鍋「黃金高湯」,客人來只要舀一碗,不用每次都重煮!

「singleflight 防雪崩」: 廚房救星!就算一萬個人同時叫外賣,廚師也只煮一鍋,大家分著吃,資料庫才不會氣到爆炸!


  1. 為什麼要做這個?
    想像一下,你開了一間超人氣餐廳:

「搜尋」 就像你的點餐小幫手:客人打「咖哩」,小幫手立刻把所有咖哩菜單丟出來,不用翻完整本。我們用 ILIKE(不分大小寫模糊查)或更厲害的 pg_trgm(三元組,查得更快更準,連錯字都可能找到!)。

「Redis 快取」 就像先煮好的黃金高湯:這是公開文章列表。大家同時來喝,不用每次都叫廚師(資料庫 DB)從頭開始煮一鍋湯。直接從高湯桶(Redis)舀,超快!

「singleflight 防雪崩」 就像廚房合併訂單系統:萬一高湯桶空了(快取失效),然後一千個客人同時喊「我要高湯!」如果廚師真的煮一千鍋,廚房會炸掉(DB 會掛)。singleflight 會說:「安靜!我只叫廚師煮一鍋,你們一百個先排隊喝一樣的!😎」


  1. 專案變更樹(新增/修改一覽)
    這次變動有點多,但都是為了飛得更快、搜得更準!
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                              # 新增快取的設定
└─ ...

  1. 新增環境變數(.env.example)
    高湯桶在哪?能放多久?有沒有要請神探柯南(pg_trgm)出馬?都在這裡設!
# 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;

  1. Redis 快取小包裝(含「版本號」失效策略)
    這是我們的高湯桶管理員。它最厲害的是用「版本號」來讓快取無痛失效!

核心概念: 每次你新增/修改/刪除文章,我們就對一個叫 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),這樣列表快取才會「自動」失效喔!


  1. Repo:列表+搜尋(支援 ILIKE / pg_trgm)
    這是 DB 廚師的菜單。現在廚師多了兩個功能:

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
}

  1. API:公開列表+搜尋(掛上 Redis 快取與 singleflight)
    這是客戶點餐的櫃檯(/api/posts)。

讀取流程:

查版本號 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))
}

  1. 前台 HTML 搜尋頁(簡易)
    這個是給瀏覽器看的 HTML 頁面,很簡單,就是把搜尋結果秀出來。

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,
	})
}

  1. 主程式接線:Redis、singleflight、路由
    把所有英雄都請進主程式,讓他們開始工作!

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()) 讓列表快取「自然」失效。


  1. 快速驗收(最接地氣的 cURL)
    來,拿起你的 cURL(就像是無情的測試機器人),感受一下飛快的速度吧!
# 第一次(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'

  1. 排雷清單(程式不會動?來看看是不是這些小調皮蛋在搞鬼!)

⏱️ TTL 設太長:CACHE_TTL 建議先設為 60 秒 保守,等流量大了再慢慢調整。

🧰 Redis 掛了:程式要能「優雅退化」(像上面那樣忽略錯誤、直接查 DB,不讓使用者感覺壞掉)。

🔍 搜尋無結果:ILIKE 本身大小寫不敏感,但如果用了 pg_trgm,要確保 Migration 有成功執行。

🔄 快取沒失效:記得確認後台寫入路徑有呼叫 BumpVersion(),讓版本號更新後自動失效快取。

🧊 雪崩了:一定要用 singleflight 包住每一把 key 的 miss,避免快取同時失效時大量請求直接打爆資料庫。


上一篇
以 Go + Echo 打造部落格|第 11 集 RSS、Sitemap、SEO Meta:讓搜尋引擎找到你
下一篇
以 Go + Echo 打造部落格|第 13 集 總結
系列文
Golang x Echo 30 天:零基礎GO , 後端入門29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言