返回首页

Golang工程化:5.7 稳定性设计之超时、重试、熔断、幂等

在分布式系统里,功能能跑通只是起点,稳定地跑下去才是硬指标。一次慢调用、一次依赖抖动、一次重复提交,都可能把一个看似正常的 Go 服务拖进雪崩。

这一节我们聚焦稳定性设计里最常用也最容易踩坑的几个点:超时、重试、熔断、限流、降级、幂等。文章不仅讲概念,也给出完整可运行的代码示例,帮助你把设计原则真正落到工程实践里。

一、为什么稳定性设计不能靠“出问题再补”

很多线上事故并不是因为核心业务逻辑写错,而是因为没有把异常场景当成默认场景来设计:

  • 下游接口偶发变慢,没有超时控制,协程大量堆积
  • 请求失败后直接无限重试,导致雪上加霜
  • 依赖服务已经异常,调用方还在持续打流量
  • 突发流量进入系统,没有限流和降级措施
  • 用户重复提交订单,服务端没有幂等保护

稳定性设计的目标,不是“绝不失败”,而是:

  • 失败要可控
  • 故障要可隔离
  • 性能退化时要有保护手段
  • 重复请求不能产生脏数据

二、超时设计:先让请求有边界

超时是稳定性设计的第一道防线。没有超时,系统就没有“止损点”。

2.1 context 超时传递

在 Go 里,context.Context 是跨函数、跨服务传递超时、取消信号、链路信息的标准方式。

一个常见原则是:

  • 上游入口创建超时边界
  • 下游函数只接收 ctx,不要偷偷重新造无限期上下文
  • 所有 I/O 操作都尽量感知 ctx.Done()

下面是一个完整的可运行示例,演示请求级超时如何一路传递到下游调用。

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
	defer cancel()

	if err := CreateOrder(ctx); err != nil {
		fmt.Println("create order failed:", err)
		return
	}

	fmt.Println("create order success")
}

func CreateOrder(ctx context.Context) error {
	if err := checkInventory(ctx); err != nil {
		return fmt.Errorf("checkInventory: %w", err)
	}
	if err := createPayment(ctx); err != nil {
		return fmt.Errorf("createPayment: %w", err)
	}
	return nil
}

func checkInventory(ctx context.Context) error {
	select {
	case <-time.After(400 * time.Millisecond):
		fmt.Println("inventory checked")
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func createPayment(ctx context.Context) error {
	select {
	case <-time.After(1300 * time.Millisecond):
		fmt.Println("payment created")
		return nil
	case <-ctx.Done():
		if errors.Is(ctx.Err(), context.DeadlineExceeded) {
			return fmt.Errorf("payment timeout: %w", ctx.Err())
		}
		return ctx.Err()
	}
}

运行结果通常会是支付阶段超时,因为整个链路总预算只有 1.5 秒。

这段代码体现了两个关键点:

  • 超时预算由入口统一管理
  • 下游逻辑不自行决定“等多久”,而是服从上游预算

2.2 客户端超时 vs 服务端超时

很多人只给 HTTP 客户端设置一个 Timeout,觉得这样就够了。其实不够。

客户端超时和服务端超时关注的是两个不同问题。

维度 客户端超时 服务端超时
作用位置 调用方 被调用方
核心目标 避免调用方无限等待 避免服务端连接与处理资源被长期占用
常见配置 http.Client.Timeout、请求 context.WithTimeout ReadHeaderTimeoutReadTimeoutWriteTimeout、业务处理超时
解决的问题 下游太慢、网络异常、响应迟迟不返回 慢连接攻击、客户端上传过慢、处理时间失控
是否可互相替代 不能 不能

客户端超时解决“我不能一直等”;服务端超时解决“我不能一直被占着”。线上系统通常需要两边都配置。

2.3 HTTP 客户端超时示例

下面的示例演示了如何同时使用请求级 contexthttp.Client.Timeout

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"time"
)

func main() {
	client := &http.Client{
		Timeout: 2 * time.Second,
	}

	ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://httpbin.org/delay/3", nil)
	if err != nil {
		panic(err)
	}

	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Println("status:", resp.Status)
	fmt.Println(string(body))
}

经验上可以这样理解:

  • context.WithTimeout 更适合表达“本次业务请求预算”
  • http.Client.Timeout 更像一个客户端总保险丝
  • 如果要细粒度控制连接建立、TLS 握手、响应头等待,还可以继续配置 Transport

2.4 HTTP 服务端超时示例

下面是一个带有服务端超时配置的 HTTP 服务。

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/work", workHandler)

	server := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 2 * time.Second,
		ReadTimeout:       5 * time.Second,
		WriteTimeout:      3 * time.Second,
		IdleTimeout:       30 * time.Second,
	}

	log.Println("server listening on :8080")
	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatal(err)
	}
}

func workHandler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 1200*time.Millisecond)
	defer cancel()

	if err := doBusiness(ctx); err != nil {
		http.Error(w, err.Error(), http.StatusGatewayTimeout)
		return
	}

	fmt.Fprintln(w, "ok")
}

func doBusiness(ctx context.Context) error {
	select {
	case <-time.After(2 * time.Second):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

这里要注意两层超时:

  • http.Server 的超时是网络层、连接层保护
  • workHandler 内的 context.WithTimeout 是业务处理保护

生产实践里,这两层一般都要有。

三、重试策略:不是所有失败都值得立刻重来

重试的核心问题不是“要不要重试”,而是“什么错误可以重试、重试几次、间隔多久、会不会把下游压垮”。

3.1 什么场景适合重试

适合重试的典型场景:

  • 网络瞬时抖动
  • 临时性超时
  • 返回 502、503、504 等短暂错误
  • 读请求或天然幂等操作

不适合盲目重试的场景:

  • 参数错误
  • 业务校验失败
  • 非幂等写操作且没有幂等保护
  • 下游已经明显过载

3.2 固定间隔、指数退避、抖动(Jitter)

三种常见重试等待策略如下。

策略 公式示意 优点 风险
固定间隔 每次等 200ms 实现简单 多实例同时重试,容易形成重试风暴
指数退避 100ms、200ms、400ms、800ms 能快速拉开重试节奏 多实例仍可能同频重试
抖动 Jitter 指数退避基础上增加随机性 最适合分布式场景 实现略复杂

结论很明确:

  • 小规模简单系统可以先用固定间隔
  • 分布式系统优先指数退避 + Jitter

3.3 可运行的重试实现

下面给出一个完整示例,支持固定间隔、指数退避和 Jitter。

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"
)

type BackoffStrategy int

const (
	Fixed BackoffStrategy = iota
	Exponential
)

type RetryConfig struct {
	MaxAttempts int
	BaseDelay   time.Duration
	MaxDelay    time.Duration
	UseJitter   bool
	Strategy    BackoffStrategy
}

func main() {
	rand.Seed(time.Now().UnixNano())

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	attempt := 0
	err := Retry(ctx, RetryConfig{
		MaxAttempts: 5,
		BaseDelay:   200 * time.Millisecond,
		MaxDelay:    2 * time.Second,
		UseJitter:   true,
		Strategy:    Exponential,
	}, func() error {
		attempt++
		fmt.Println("do attempt", attempt)
		if attempt < 4 {
			return errors.New("temporary network error")
		}
		return nil
	})

	fmt.Println("final result:", err)
}

func Retry(ctx context.Context, cfg RetryConfig, fn func() error) error {
	if cfg.MaxAttempts <= 0 {
		return errors.New("MaxAttempts must be > 0")
	}
	if cfg.BaseDelay <= 0 {
		cfg.BaseDelay = 100 * time.Millisecond
	}
	if cfg.MaxDelay <= 0 {
		cfg.MaxDelay = time.Second
	}

	var lastErr error
	for i := 1; i <= cfg.MaxAttempts; i++ {
		if err := fn(); err != nil {
			lastErr = err
			if i == cfg.MaxAttempts {
				break
			}

			delay := nextDelay(cfg, i)
			fmt.Println("retry after", delay)

			select {
			case <-time.After(delay):
			case <-ctx.Done():
				return ctx.Err()
			}
			continue
		}
		return nil
	}
	return fmt.Errorf("all retries failed: %w", lastErr)
}

func nextDelay(cfg RetryConfig, attempt int) time.Duration {
	delay := cfg.BaseDelay
	if cfg.Strategy == Exponential {
		delay = cfg.BaseDelay * time.Duration(1<<(attempt-1))
	}
	if delay > cfg.MaxDelay {
		delay = cfg.MaxDelay
	}
	if cfg.UseJitter {
		jitter := time.Duration(rand.Int63n(int64(delay / 2)))
		delay = delay/2 + jitter
	}
	return delay
}

这段代码里最值得注意的点有三个:

  • 重试必须受 context 控制,不能无限重试
  • 间隔应有上限,避免指数退避无限增大
  • Jitter 能显著减少多个实例同时打爆下游的概率

3.4 重试的工程建议

实践中建议遵循下面几条:

  • 只对临时性错误重试
  • 写操作必须先保证幂等,再考虑自动重试
  • 重试次数一般不宜过大,2 到 5 次通常就够了
  • 重试要结合熔断与限流一起看,不能单独设计

四、熔断器模式:不要在故障时持续放大故障

当下游依赖已经明显异常时,继续请求只会让问题更严重。熔断器的本质,就是在故障期快速失败,保护调用方和被调用方。

4.1 熔断器状态机

典型熔断器有三个状态:

状态 含义 行为
Closed 闭合 正常放行请求,同时统计成功失败情况
Open 打开 直接拒绝请求,快速失败
Half-Open 半开 允许少量探测请求,判断依赖是否恢复

状态流转通常是:

  • Closed 下失败率或连续失败次数达到阈值,进入 Open
  • Open 到达冷却时间后,进入 Half-Open
  • Half-Open 探测成功,回到 Closed
  • Half-Open 探测失败,重新回到 Open

4.2 使用 gobreaker 的完整示例

gobreaker 比较轻量,适合很多 Go 服务直接接入。

package main

import (
	"errors"
	"fmt"
	"math/rand"
	"time"

	"github.com/sony/gobreaker"
)

func main() {
	rand.Seed(time.Now().UnixNano())

	cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
		Name:        "inventory-service",
		MaxRequests: 2,
		Interval:    10 * time.Second,
		Timeout:     3 * time.Second,
		ReadyToTrip: func(counts gobreaker.Counts) bool {
			return counts.ConsecutiveFailures >= 3
		},
		OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
			fmt.Printf("breaker %s state change: %v -> %v\n", name, from, to)
		},
	})

	for i := 1; i <= 12; i++ {
		result, err := cb.Execute(func() (interface{}, error) {
			return callRemoteService()
		})

		fmt.Printf("request %02d, result=%v, err=%v\n", i, result, err)
		time.Sleep(700 * time.Millisecond)
	}
}

func callRemoteService() (string, error) {
	// 模拟 70% 概率失败
	if rand.Intn(100) < 70 {
		return "", errors.New("remote service failed")
	}
	return "success", nil
}

运行前先初始化依赖:

go mod init gobreaker-demo
go get github.com/sony/gobreaker
go run .

4.3 使用 go-hystrix 的完整示例

如果你的团队更偏向命令隔离、回退函数、熔断统计一体化方案,可以看 hystrix-go

package main

import (
	"errors"
	"fmt"
	"math/rand"
	"time"

	"github.com/afex/hystrix-go/hystrix"
)

func main() {
	rand.Seed(time.Now().UnixNano())

	hystrix.ConfigureCommand("user-profile", hystrix.CommandConfig{
		Timeout:                1000,
		MaxConcurrentRequests:  100,
		SleepWindow:            3000,
		RequestVolumeThreshold: 5,
		ErrorPercentThreshold:  50,
	})

	for i := 1; i <= 15; i++ {
		err := hystrix.Do("user-profile", func() error {
			return callUserProfile()
		}, func(err error) error {
			fmt.Println("fallback triggered, err:", err)
			return nil
		})

		fmt.Printf("request %02d finished, err=%v\n", i, err)
		time.Sleep(500 * time.Millisecond)
	}
}

func callUserProfile() error {
	cost := time.Duration(300+rand.Intn(1200)) * time.Millisecond
	time.Sleep(cost)

	if cost > 900*time.Millisecond {
		return errors.New("downstream timeout")
	}
	if rand.Intn(100) < 40 {
		return errors.New("downstream internal error")
	}
	fmt.Println("call success, cost:", cost)
	return nil
}

运行前先初始化依赖:

go mod init hystrix-demo
go get github.com/afex/hystrix-go/hystrix
go run .

4.4 熔断不是替代重试,而是限制重试边界

重试和熔断经常一起出现,但职责不同:

  • 重试解决瞬时失败
  • 熔断解决持续失败

如果没有熔断,重试可能把故障放大。 如果没有重试,很多瞬时抖动本来可以自动恢复。

正确姿势通常是:

  • 先判断错误是否值得重试
  • 重试次数有限
  • 超过阈值后通过熔断快速失败
  • 再通过降级兜底

五、限流算法:不要让系统被流量一次打穿

限流的本质是控制进入系统的请求速率,防止资源被瞬时耗尽。

5.1 令牌桶

令牌桶适合“允许一定突发,但要限制平均速率”的场景,是服务端最常见的限流算法之一。

下面用 golang.org/x/time/rate 给出一个完整示例。

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"golang.org/x/time/rate"
)

func main() {
	limiter := rate.NewLimiter(2, 4) // 每秒 2 个令牌,桶容量 4

	http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			http.Error(w, "too many requests", http.StatusTooManyRequests)
			return
		}
		fmt.Fprintf(w, "accepted at %s\n", time.Now().Format(time.RFC3339Nano))
	})

	log.Println("listen :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

运行前先初始化依赖:

go mod init token-bucket-demo
go get golang.org/x/time/rate
go run .

5.2 漏桶

漏桶更强调“以固定速率流出”,适合需要平滑处理速度的场景,比如异步任务消费。

下面示例模拟一个简单漏桶。

package main

import (
	"fmt"
	"time"
)

func main() {
	bucket := make(chan int, 5)

	go func() {
		for i := 1; i <= 10; i++ {
			select {
			case bucket <- i:
				fmt.Println("accepted request", i)
			default:
				fmt.Println("dropped request", i)
			}
			time.Sleep(150 * time.Millisecond)
		}
		close(bucket)
	}()

	ticker := time.NewTicker(500 * time.Millisecond)
	defer ticker.Stop()

	for range ticker.C {
		item, ok := <-bucket
		if !ok {
			fmt.Println("all requests handled")
			return
		}
		fmt.Println("processed request", item)
	}
}

5.3 滑动窗口

滑动窗口比固定窗口更平滑,适合按“最近 N 秒内请求数”来判断是否超限。

下面是一个基于时间戳切片的简单滑动窗口示例。

package main

import (
	"fmt"
	"net/http"
	"sync"
	"time"
)

type SlidingWindowLimiter struct {
	mu      sync.Mutex
	window  time.Duration
	limit   int
	records []time.Time
}

func NewSlidingWindowLimiter(window time.Duration, limit int) *SlidingWindowLimiter {
	return &SlidingWindowLimiter{
		window: window,
		limit:  limit,
	}
}

func (l *SlidingWindowLimiter) Allow() bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	cutoff := now.Add(-l.window)

	idx := 0
	for _, t := range l.records {
		if t.After(cutoff) {
			l.records[idx] = t
			idx++
		}
	}
	l.records = l.records[:idx]

	if len(l.records) >= l.limit {
		return false
	}

	l.records = append(l.records, now)
	return true
}

func main() {
	limiter := NewSlidingWindowLimiter(3*time.Second, 5)

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			http.Error(w, "rate limited", http.StatusTooManyRequests)
			return
		}
		fmt.Fprintln(w, "ok")
	})

	fmt.Println("listen on :8080")
	http.ListenAndServe(":8080", nil)
}

5.4 三种限流算法如何选

算法 适合场景 特点
令牌桶 API 网关、在线服务 支持突发流量,工程实践最常见
漏桶 异步消费、平滑处理 输出速率稳定,削峰效果好
滑动窗口 精细限流、统计最近时间段请求数 控制更平滑,但实现和存储成本更高

六、降级策略:先保核心,再保体验

降级不是“系统坏了”,而是“系统在压力或故障下主动收缩能力,优先保住核心路径”。

6.1 服务降级的常见触发条件

降级一般在下面这些情况下触发:

  • 下游接口超时率过高
  • 熔断器已打开
  • 错误率持续上升
  • CPU、内存、线程池、连接池等资源逼近阈值
  • 非核心功能流量过大,影响主链路

一个很实用的优先级原则是:

  • 核心链路必须保住,比如下单、支付、登录
  • 非核心能力优先降级,比如推荐、画像、排行榜、埋点增强能力

6.2 降级实现思路

降级的常见实现包括:

  • 返回默认值
  • 返回缓存数据
  • 返回静态兜底文案
  • 关闭非核心功能模块
  • 将同步调用改为异步补偿

6.3 可运行的降级示例

下面示例演示一个商品详情接口:推荐服务正常时返回实时推荐,失败或超时时返回缓存兜底数据。

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"math/rand"
	"net/http"
	"time"
)

type ProductPage struct {
	ProductID      string   `json:"product_id"`
	Title          string   `json:"title"`
	Recommendations []string `json:"recommendations"`
	Degraded       bool     `json:"degraded"`
}

func main() {
	rand.Seed(time.Now().UnixNano())

	http.HandleFunc("/product", productHandler)
	fmt.Println("listen on :8080")
	http.ListenAndServe(":8080", nil)
}

func productHandler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
	defer cancel()

	page := ProductPage{
		ProductID: "sku-1001",
		Title:     "Go 微服务实战",
	}

	recs, err := fetchRecommendations(ctx, page.ProductID)
	if err != nil {
		page.Recommendations = []string{"编辑精选", "基础入门", "稳定性专题"}
		page.Degraded = true
	} else {
		page.Recommendations = recs
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(page)
}

func fetchRecommendations(ctx context.Context, productID string) ([]string, error) {
	cost := time.Duration(200+rand.Intn(1200)) * time.Millisecond

	select {
	case <-time.After(cost):
		if rand.Intn(100) < 50 {
			return nil, fmt.Errorf("recommend service error")
		}
		return []string{"推荐 A", "推荐 B", "推荐 C"}, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

这个例子体现了降级设计的关键思想:

  • 主体页面仍然可用
  • 非核心推荐信息允许退化
  • 用户拿到的是“可接受结果”,而不是 500

七、幂等设计:让重复请求只产生一次有效结果

幂等是写操作稳定性的核心。网络重试、消息重复投递、用户重复点击,都会导致服务端收到重复请求。没有幂等保护,就可能出现重复扣款、重复下单、重复发货。

7.1 什么是幂等

幂等可以简单理解为:

  • 同一个请求执行一次和执行多次,最终结果一致

这里的“结果一致”,不是响应文案一模一样,而是业务状态不能被重复破坏。

7.2 幂等 key

最常见做法是让客户端或服务端生成一个幂等 Key,例如:

  • 支付请求号
  • 订单提交唯一流水号
  • 消息唯一 ID
  • 业务侧请求去重号

处理逻辑一般是:

  1. 请求进入服务时先读取幂等 Key
  2. 查询该 Key 是否已处理
  3. 已处理则直接返回历史结果或当前状态
  4. 未处理则执行业务,并记录结果

7.3 唯一约束

仅靠内存去重远远不够,生产环境一般还需要数据库唯一约束兜底,因为多实例部署下,请求可能落在不同节点。

一个典型表示例:

CREATE TABLE payments (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    idem_key VARCHAR(64) NOT NULL,
    order_id VARCHAR(64) NOT NULL,
    amount BIGINT NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_idem_key (idem_key)
);

这样即使并发重复请求同时进入,也只有一个事务能成功插入。

7.4 状态机校验

幂等不仅是“是否处理过”,还包括“状态是否允许重复推进”。

比如订单状态流转可以约束为:

  • INIT -> PAID -> SHIPPED -> FINISHED

那么:

  • 已经 PAID 的订单不能再次支付
  • 已经 SHIPPED 的订单不能回退到 PAID

也就是说,状态机校验是幂等和一致性的第二层保护。

7.5 可运行的幂等示例

下面给出一个完整可运行的 HTTP 示例,演示幂等 Key + 唯一记录 + 状态机校验的思路。

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"sync"
)

type PaymentRecord struct {
	IdemKey string `json:"idem_key"`
	OrderID string `json:"order_id"`
	Amount  int64  `json:"amount"`
	Status  string `json:"status"`
}

type PaymentStore struct {
	mu      sync.Mutex
	records map[string]PaymentRecord
	orders  map[string]string
}

func NewPaymentStore() *PaymentStore {
	return &PaymentStore{
		records: make(map[string]PaymentRecord),
		orders:  make(map[string]string),
	}
}

func (s *PaymentStore) CreatePayment(idemKey, orderID string, amount int64) (PaymentRecord, bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if rec, ok := s.records[idemKey]; ok {
		return rec, true, nil
	}

	if status, ok := s.orders[orderID]; ok {
		if status == "PAID" {
			return PaymentRecord{}, false, fmt.Errorf("order already paid")
		}
		return PaymentRecord{}, false, fmt.Errorf("invalid order state: %s", status)
	}

	rec := PaymentRecord{
		IdemKey: idemKey,
		OrderID: orderID,
		Amount:  amount,
		Status:  "PAID",
	}

	s.records[idemKey] = rec
	s.orders[orderID] = rec.Status
	return rec, false, nil
}

func main() {
	store := NewPaymentStore()

	http.HandleFunc("/pay", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}

		idemKey := r.Header.Get("Idempotency-Key")
		if idemKey == "" {
			http.Error(w, "missing Idempotency-Key", http.StatusBadRequest)
			return
		}

		orderID := r.URL.Query().Get("order_id")
		if orderID == "" {
			http.Error(w, "missing order_id", http.StatusBadRequest)
			return
		}

		rec, duplicated, err := store.CreatePayment(idemKey, orderID, 19900)
		if err != nil {
			http.Error(w, err.Error(), http.StatusConflict)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		if duplicated {
			w.Header().Set("X-Idempotent-Replay", "true")
		}
		json.NewEncoder(w).Encode(rec)
	})

	fmt.Println("listen on :8080")
	http.ListenAndServe(":8080", nil)
}

你可以这样测试:

curl -X POST "http://localhost:8080/pay?order_id=order-1" -H "Idempotency-Key: pay-001"
curl -X POST "http://localhost:8080/pay?order_id=order-1" -H "Idempotency-Key: pay-001"
curl -X POST "http://localhost:8080/pay?order_id=order-1" -H "Idempotency-Key: pay-002"

预期现象:

  • 第一次请求成功创建支付
  • 第二次使用相同幂等 Key,会返回第一次结果
  • 第三次虽然幂等 Key 不同,但订单已经支付过,会被状态机校验拦住

八、把这些能力串起来,才是完整的稳定性方案

实际工程中,这些机制不是孤立存在的,而是要形成一套配合关系:

  1. 超时:给每个请求设定时间边界,避免无限等待
  2. 重试:对短暂故障做有限次恢复
  3. 熔断:在持续故障时快速失败,阻止故障放大
  4. 限流:在流量冲击时保护系统容量
  5. 降级:优先保核心链路,牺牲非核心能力
  6. 幂等:保证重试和重复请求不会写出脏数据

如果只做其中一个,往往不够。

举个常见链路:

  • 用户发起支付请求
  • 网关先做限流
  • 业务服务为支付链路设置超时
  • 调用下游支付渠道时做有限重试
  • 渠道持续异常时熔断
  • 熔断后触发降级,比如提示“支付通道繁忙,请稍后重试”
  • 所有支付请求都带幂等 Key,避免重复扣款

这才是一条完整的稳定性防线。

九、总结

稳定性设计不是某一个中间件,也不是某一个框架参数,而是一整套故障控制思维。

在 Go 服务中,你至少应该建立下面这几个基础习惯:

  • 每个外部调用都带超时
  • 每个重试都有边界,并优先使用指数退避 + Jitter
  • 每个关键依赖都考虑熔断保护
  • 每个入口都考虑限流
  • 每个非核心能力都准备降级方案
  • 每个关键写操作都设计幂等机制

当你把这些能力逐步落地之后,系统面对慢请求、突发流量、依赖异常和重复提交时,就不再是“碰运气扛住”,而是“按设计稳定运行”。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang工程化:5.6 缓存、消息队列、事务一致性设计

下一篇

Golang工程化:5.8 一个中型服务的架构设计案例拆解