iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Cloud Native

Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟系列 第 15

Go 語言搶票煉金術 Day 15 - 提供服務:用 Gin 暴露你的搶票 API

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250915/20124462YARMcXUyfa.png

Go 語言搶票煉金術 Day 15 - 提供服務:用 Gin 暴露你的搶票 API

在 Day 10~Day 14,我們把「主要熱點」搬進 Redis,並用 Lua Script 保證原子性與語義完整。

今天該把這顆引擎裝進一台能跑的車:用 Gin 把「搶票」暴露為一個乾淨、可維護、可觀測的 HTTP API。

這不是單純把 client.DecrBy 包起來丟給網路。API 是系統的「契約 (Contract)」與「承諾 (SLO)」。
在這裡做的每個決定——路徑、輸入、輸出、狀態碼、超時、錯誤映射、日誌、追蹤——都會直接影響可維護性與風險暴露面。

目標與原則

  • 單一職責:API 只負責校驗請求、扣減 Redis、投遞消息(第二階段才引入),快速返回。
  • 協議清晰:請求/回應結構穩定;錯誤碼、HTTP 狀態碼一一對應。
  • Fail-Fast:對下游(Redis)設置嚴格超時;任何阻塞都不允許在 API 線程內拖延。
  • 可觀測性:每次請求必有關聯 ID (Correlation ID),一致化日誌欄位,預留延伸到消息佇列的元數據欄位。
  • 零共享狀態:Handler 不持有可變全域狀態;依賴透過組合注入。

API 契約 (Contract)

  • 路徑:POST /api/v1/purchase
  • 請求:
{
  "ticket_id": 12345,
  "user_id": 67890
}
  • 回應成功(同步路徑,當前階段仍直接扣減):
{
  "request_id": "2a3b4c...",
  "status": "success",
  "remaining": 998
}
  • 回應失敗(語義化錯誤碼):
{
  "request_id": "2a3b4c...",
  "status": "error",
  "error": {
    "code": "SOLD_OUT",
    "message": "票券已售罄"
  }
}

HTTP 狀態碼策略:

  • 200:業務成功。
  • 400:輸入驗證失敗(ticket_id/user_id 缺漏或非法)。
  • 409:業務衝突(重複購買、售罄)。
  • 429:速率限制(之後 Day 26 實作)。
  • 500:非預期錯誤(Redis/系統異常)。

關鍵點:不要把所有錯誤都 200 包起來。HTTP 狀態碼是協議的一部分,它讓前端和觀測系統能快速判斷行為,不需要解析字串。

錯誤映射 (Domain -> HTTP)

領域錯誤到 HTTP 的對照表:

Domain Error 說明 HTTP code
ErrSoldOut 票券無庫存 409 SOLD_OUT
ErrAlreadyPurchased 用戶已購買 409 DUPLICATE_PURCHASE
ErrInvalidArgument 參數不合法 400 INVALID_ARGUMENT
其他 error 非預期 500 INTERNAL

堅持「單一映射來源」:在 Use Case 層用 errors.Is 判斷並回傳對應的 ApiError,Handler 只做翻譯與輸出,避免到處散落 switch。

最小可用版本 (MVP) 架構

  • server:Gin 啟動、路由、中介層(Correlation ID、Recovery、JSON Content-Type)。
  • handler:HTTP 參數綁定與回應格式化。
  • usecase:購買流程協調(呼叫 TicketService)。
  • serviceTicketService 基於 Redis + Lua 執行原子扣減與去重。
  • infra:Redis Client 建置與生命周期管理。

這樣分層的意義是把「IO、協議、領域規則」分開:可測、可替換、不相互污染。

https://ithelp.ithome.com.tw/upload/images/20250929/20124462f4RLNi7wSz.png

程式碼

依賴:go get github.com/gin-gonic/gin github.com/redis/go-redis/v9 github.com/google/uuid

package main

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/redis/go-redis/v9"
)

// ===== Domain errors =====
var (
    ErrSoldOut          = errors.New("sold out")
    ErrAlreadyPurchased = errors.New("already purchased")
    ErrInvalidArgument  = errors.New("invalid argument")
)

// ===== Infra: Redis client =====
func newRedisClient() *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:         "localhost:6379",
        PoolSize:     200,
        MinIdleConns: 20,
        ReadTimeout:  500 * time.Millisecond,
        WriteTimeout: 500 * time.Millisecond,
        PoolTimeout:  1 * time.Second,
    })
}

// ===== Service: TicketService (Lua 保證原子性) =====
type TicketService struct{ rdb *redis.Client }

func NewTicketService(rdb *redis.Client) *TicketService { return &TicketService{rdb: rdb} }

var purchaseScript = redis.NewScript(`
local ticketKey = KEYS[1]
local userKey = KEYS[2]
local ticketID = ARGV[1]
local quantity = tonumber(redis.call('GET', ticketKey) or '0')
if quantity <= 0 then return 0 end
if redis.call('SISMEMBER', userKey, ticketID) == 1 then return -1 end
redis.call('DECRBY', ticketKey, 1)
redis.call('SADD', userKey, ticketID)
return 1
`)

func (s *TicketService) Purchase(ctx context.Context, ticketID, userID int64) (int64, error) {
    ticketKey := fmt.Sprintf("ticket:%d", ticketID)
    userKey := fmt.Sprintf("user:%d:purchased", userID)
    res, err := purchaseScript.Run(ctx, s.rdb, []string{ticketKey, userKey}, ticketID).Int64()
    if err != nil { return 0, err }
    switch res {
    case 1:
        // 回傳剩餘量(再取一次避免業務和腳本糾纏)
        remain, err := s.rdb.Get(ctx, ticketKey).Int64()
        if err != nil { return 0, err }
        return remain, nil
    case 0:
        return 0, ErrSoldOut
    case -1:
        return 0, ErrAlreadyPurchased
    default:
        return 0, errors.New("unknown script result")
    }
}

// ===== Usecase =====
type PurchaseUsecase struct{ svc *TicketService }

func NewPurchaseUsecase(svc *TicketService) *PurchaseUsecase { return &PurchaseUsecase{svc: svc} }

func (u *PurchaseUsecase) Execute(ctx context.Context, ticketID, userID int64) (int64, error) {
    if ticketID <= 0 || userID <= 0 { return 0, ErrInvalidArgument }
    return u.svc.Purchase(ctx, ticketID, userID)
}

// ===== HTTP layer =====
type purchaseReq struct {
    TicketID int64 `json:"ticket_id" binding:"required"`
    UserID   int64 `json:"user_id" binding:"required"`
}

func correlationID() gin.HandlerFunc {
    return func(c *gin.Context) {
        rid := c.GetHeader("X-Request-ID")
        if rid == "" { rid = uuid.NewString() }
        c.Set("request_id", rid)
        c.Writer.Header().Set("X-Request-ID", rid)
        c.Next()
    }
}

func main() {
    rdb := newRedisClient()
    defer rdb.Close()
    if err := rdb.Ping(context.Background()).Err(); err != nil { panic(err) }

    svc := NewTicketService(rdb)
    uc := NewPurchaseUsecase(svc)

    r := gin.New()
    r.Use(gin.Recovery(), correlationID())
    r.POST("/api/v1/purchase", func(c *gin.Context) {
        var req purchaseReq
        if err := c.ShouldBindJSON(&req); err != nil {
            respondError(c, http.StatusBadRequest, code("INVALID_ARGUMENT"), err.Error())
            return
        }
        ctx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
        defer cancel()

        remaining, err := uc.Execute(ctx, req.TicketID, req.UserID)
        if err != nil {
            switch {
            case errors.Is(err, ErrInvalidArgument):
                respondError(c, http.StatusBadRequest, code("INVALID_ARGUMENT"), "參數不合法")
            case errors.Is(err, ErrSoldOut):
                respondError(c, http.StatusConflict, code("SOLD_OUT"), "票券已售罄")
            case errors.Is(err, ErrAlreadyPurchased):
                respondError(c, http.StatusConflict, code("DUPLICATE_PURCHASE"), "用戶已購買此票券")
            default:
                respondError(c, http.StatusInternalServerError, code("INTERNAL"), "系統錯誤")
            }
            return
        }

        c.JSON(http.StatusOK, gin.H{
            "request_id": c.GetString("request_id"),
            "status":     "success",
            "remaining":  remaining,
        })
    })

    _ = r.Run(":8080")
}

type errPayload struct {
    RequestID string      `json:"request_id"`
    Status    string      `json:"status"`
    Error     interface{} `json:"error"`
}

func code(c string) map[string]string { return map[string]string{"code": c} }

func respondError(c *gin.Context, httpStatus int, meta map[string]string, msg string) {
    payload := errPayload{
        RequestID: c.GetString("request_id"),
        Status:    "error",
        Error: gin.H{
            "code":    meta["code"],
            "message": msg,
        },
    }
    c.JSON(httpStatus, payload)
}

說明:

  • API 超時 800ms,嚴格小於 Redis 500ms*2 的保險絲,保證最壞情境也能 Fail-Fast。
  • X-Request-ID 可從前端傳入或後端生成,回寫回應,支撐後續日誌與追蹤。
  • purchaseScriptredis.Script,自動處理 NOSCRIPT(快取失效)情形。
  • remaining 透過二次 GET 取得,避免把輸出耦合進腳本(可測性更好)。

中介層 (Middleware) 要點

  • Recovery:所有 panic 都要被攔截並回 500。
  • Correlation ID:如上,標準欄位 X-Request-ID,向下游(之後的 MQ)傳遞。
  • JSON 序列化:統一 Content-Type: application/json,禁用非 JSON 回應。
  • (Day 23 前置)結構化日誌 (Structured Logging):在 Handler 中記錄:request_id、route、ticket_id、user_id、latency_ms、status、code

壓力測試建議(最少集)

使用 k6 針對 POST /api/v1/purchase

  • 觀察 P50/P95/P99 延遲、成功率。
  • Redis PoolStatsMisses/Timeouts/TotalConns/IdleConns 必須落在期望範圍。
  • 錯誤分佈:409(預期業務衝突)應高於 500(非預期)。

常見錯誤與反模式

  • 把資料庫寫入放在 API 內同步完成 → 阻塞慢路徑,違反 Fail-Fast。
  • 用 200 包所有錯誤 → 前端與監控失明。
  • 在 Handler 內直接操作 Redis → 測試與替換困難。
  • 忽略 X-Request-ID → 事件難以串起。

上一篇
Go 語言搶票煉金術 Day 14 - Redis 的終極武器:Lua Script 的實踐
下一篇
Go 語言搶票煉金術 Day 16 - 流程解耦:為什麼你需要消息佇列
系列文
Go 語言搶票煉金術:解鎖千萬級併發下的原子交易奇蹟16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言