通过Toxiproxy从原理到实践理解混沌工程

image

凌晨三点,告警群被打爆。订单系统响应时间从 30ms 飙升到 30 秒,数据库连接池告警,下游支付服务超时雪崩。事后复盘发现根因平淡得令人无奈:云厂商某可用区网络抖动 200ms。这个数字在开发环境从未出现过,开发者的笔记本到本地数据库通常 < 1ms,CI 流水线的容器之间通常 < 5ms。我们写代码时假设的"网络是可靠的",这是分布式计算 8 大谬误之首。

  1. The Network is Reliable.(网络是可靠的)
  2. Latency is Zero.(延迟为零)
  3. Bandwidth is Infinite.(带宽无限)
  4. The Network is Secure.(网络是安全的)
  5. Topology Doesn't Change.(拓扑不变)
  6. There is One Administrator.(只有一个管理员)
  7. Transport Cost is Zero.(传输成本为零)
  8. The Network is Homogeneous.(网络是同构的)

— L. Peter Deutsch & James Gosling, Sun Microsystems, 1994

Toxiproxy 的存在意义,就是让这些"谬误"在你的开发笔记本上提前发生,对系统进行混沌工程的实践。Netflix《Principles of Chaos Engineering》定义了什么是混动工程:

Chaos Engineering is the discipline of experimenting on a system in order to build confidence in the system's capability to withstand turbulent conditions in production.

注意几个关键词:

  • experimenting(实验)— 不是测试,是科学实验,要有假设和验证
  • build confidence(建立信心)— 目的不是发现 bug,而是产生可信度
  • turbulent conditions(动荡条件)— 接受混乱无法消除,只能验证容忍

混沌工程的终极目标不是构建"鲁棒"系统(能扛住故障),而是构建反脆弱系统每次故障都让系统变得更强(被识别的弱点 → 自动化测试 → 永久免疫)。

在混乱不可避免的世界,目标不是预测危机,而是构建能从危机中获益的系统。— Nassim Nicholas Taleb

Toxiproxy 内部源码原理

理解工具的内部机制是用好工具的前提。Toxiproxy 用 Go 编写,源码精炼,值得深入。整体架构如下:

graph LR Client[客户端App] Upstream[上游服务] subgraph TP["Toxiproxy 进程"] direction TB Listener[Listener<br/>监听端口] Proxy[Proxy 实例] TC[ToxicCollection] subgraph REQ["请求方向 (Upstream Stream)"] US[Upstream Link] Pipeline1[Toxic 链<br/>latency → bandwidth → ...] end subgraph RESP["响应方向 (Downstream Stream)"] DS[Downstream Link] Pipeline2[Toxic 链<br/>latency → slicer → ...] end Listener --> Proxy --> TC TC --> US --> Pipeline1 Pipeline2 --> DS --> Listener end Client -->|① 请求 TCP| Listener Pipeline1 -->|② 请求转发| Upstream Upstream -->|③ 响应 TCP| Pipeline2 Listener -->|④ 响应回送| Client API[HTTP API :8474] -.动态注入/修改.-> TC style TC fill:#fcc style Pipeline1 fill:#cfc style Pipeline2 fill:#cfc style REQ fill:#fef style RESP fill:#eff

Proxy 核心数据结构源自 proxy.go

  • sync.Mutex 保证多 goroutine 安全访问
  • tomb.Tomb 来自 Canonical 的库,管理 goroutine 生命周期
  • ToxicCollection 是 toxic 的容器,支持运行时增删改
type Proxy struct {
    sync.Mutex
    Name     string `json:"name"`
    Listen   string `json:"listen"`
    Upstream string `json:"upstream"`
    Enabled  bool   `json:"enabled"`
    listener net.Listener
    started  chan error
    tomb     tomb.Tomb              // 优雅生命周期管理
    connections ConnectionList       // 活跃连接列表
    Toxics   *ToxicCollection `json:"-"`  // toxic 集合
    apiServer *ApiServer
    Logger    *zerolog.Logger
}

每当客户端建立 TCP 连接,Toxiproxy 创建两个独立的 Link这就是为什么注入故障时要指定 stream

  • stream: upstream — 影响请求方向(客户端发送时延迟/丢包)
  • stream: downstream — 影响响应方向(服务器响应时延迟/丢包),通常注入响应方向更接近"服务慢"的语义。
// 来自 proxy.go (https://github.com/Shopify/toxiproxy/blob/main/proxy.go#L180-L195)
name := client.RemoteAddr().String()
proxy.connections.Lock()
proxy.connections.list[name+"upstream"] = upstream
proxy.connections.list[name+"downstream"] = client
proxy.connections.Unlock()

proxy.Toxics.StartLink(proxy.apiServer, name+"upstream", client, upstream, stream.Upstream)
proxy.Toxics.StartLink(proxy.apiServer, name+"downstream", upstream, client, stream.Downstream)
graph LR
    subgraph "TCP 连接"
        Client[客户端]
        Server[上游服务]
    end

    subgraph "Toxiproxy 内部"
        UpLink[Upstream Link<br/>req 方向]
        DownLink[Downstream Link<br/>resp 方向]
    end

    Client -->|请求数据| UpLink
    UpLink -->|经过 toxic 处理| Server
    Server -->|响应数据| DownLink
    DownLink -->|经过 toxic 处理| Client

    style UpLink fill:#fcf
    style DownLink fill:#cff

Toxiproxy 不是按字节处理,而是按 StreamChunk 处理:

  • 时间戳这是延迟计算的核心。sleep := t.delay() - time.Since(c.Timestamp)定义了已经在 Toxiproxy 内停留的时间。这保证了端到端的精确延迟,无论 toxic 链有多长。
// stream/io_chan.go
type StreamChunk struct {
    Data      []byte
    Timestamp time.Time  // ← 数据包进入 Toxiproxy 的时刻
}

每个 toxic 通过 Go channel 串成流水线:

graph LR Input[网络数据<br/>原始字节] --> SC1[StreamChunk] SC1 --> T1[Toxic 1: Latency<br/>等待 1000ms] T1 --> T2[Toxic 2: Bandwidth<br/>限速 100KB/s] T2 --> T3[Toxic 3: Slicer<br/>切片传输] T3 --> Output[输出到下游] style T1 fill:#fc6 style T2 fill:#fc6 style T3 fill:#fc6

Toxic 实现深度解析

Latency Toxic

  1. Buffered channel:实现 GetBufferSize() int { return 1024 },避免延迟限制吞吐量
  2. 可中断 sleepselect { time.After / Interrupt } 让动态修改立即生效
  3. Jitter 实现rand.Int63n(jitter*2) - jitter[-jitter, +jitter] 均匀分布
// 来自 toxics/latency.go
func (t *LatencyToxic) Pipe(stub *ToxicStub) {
    for {
        select {
        case <-stub.Interrupt:    // 收到中断信号 (toxic被修改)
            return
        case c := <-stub.Input:
            if c == nil {
                stub.Close()
                return
            }
            // 关键:精确延迟计算
            sleep := t.delay() - time.Since(c.Timestamp)
            select {
            case <-time.After(sleep):           // 等待计算出的时间
                c.Timestamp = c.Timestamp.Add(sleep)
                stub.Output <- c                // 转发到下一级
            case <-stub.Interrupt:              // 等待期间收到中断
                stub.Output <- c
                return
            }
        }
    }
}

func (t *LatencyToxic) delay() time.Duration {
    delay := t.Latency
    jitter := t.Jitter
    if jitter > 0 {
        delay += rand.Int63n(jitter*2) - jitter   // ±jitter 范围抖动
    }
    return time.Duration(delay) * time.Millisecond
}

Timeout Toxic

  • timeout=0 ≠ "无超时",而是 "接受连接但永远不响应,也不关闭"
  • 客户端表现:连接保持,但永远收不到任何数据
  • 应用必须有自己的客户端超时(如 requests.get(timeout=10)),否则永远卡住
// 来自 toxics/timeout.go
func (t *TimeoutToxic) Pipe(stub *ToxicStub) {
    timeout := time.Duration(t.Timeout) * time.Millisecond
    if timeout > 0 {
        for {
            select {
            case <-time.After(timeout):
                stub.Close()      // ← 超时后强制关闭连接
                return
            case <-stub.Interrupt:
                return
            case c := <-stub.Input:
                if c == nil { stub.Close(); return }
                // ⚠️ 关键:数据被静默丢弃 ("Drop the data on the ground")
            }
        }
    } else {
        // timeout=0:永远丢弃数据,永不关闭
        for {
            select {
            case <-stub.Interrupt: return
            case c := <-stub.Input:
                if c == nil { stub.Close(); return }
                // 静默丢弃
            }
        }
    }
}

Bandwidth Toxic

将数据切成 100ms 一片的小包,用 sleep 控制每片之间的间隔,实现近似精确的带宽限制。

// 来自 toxics/bandwidth.go
func (t *BandwidthToxic) Pipe(stub *ToxicStub) {
    var sleep time.Duration = 0
    for {
        select {
        case <-stub.Interrupt: return
        case p := <-stub.Input:
            if p == nil { stub.Close(); return }

            if t.Rate <= 0 {
                sleep = 0
            } else {
                // 核心公式:传输 N 字节需要的毫秒数 = N / rate(KB/s)
                sleep += time.Duration(len(p.Data)) * time.Millisecond / time.Duration(t.Rate)
            }

            // 大数据包分片:每 100ms 发送 rate*100 字节
            for int64(len(p.Data)) > t.Rate*100 {
                select {
                case <-time.After(100 * time.Millisecond):
                    stub.Output <- &stream.StreamChunk{
                        Data:      p.Data[:t.Rate*100],
                        Timestamp: p.Timestamp,
                    }
                    p.Data = p.Data[t.Rate*100:]
                    sleep -= 100 * time.Millisecond
                case <-stub.Interrupt:
                    stub.WriteOutput(p, 5*time.Second)
                    return
                }
            }

            // 剩余小包发送
            start := time.Now()
            select {
            case <-time.After(sleep):
                sleep -= time.Since(start)   // 时间补偿,提高精度
                stub.Output <- p
            case <-stub.Interrupt:
                stub.WriteOutput(p, 5*time.Second)
                return
            }
        }
    }
}

Slicer Toxic

数据包切片模拟 TCP 分片在不同 MTU 网络中的行为,测试应用是否正确处理"半包"问题(HTTP body 分多个 TCP 包到达)。

// 来自 toxics/slicer.go
// 递归二分切片算法
func (t *SlicerToxic) chunk(start int, end int) []int {
    if (end-start)-t.AverageSize <= t.SizeVariation {
        return []int{start, end}
    }
    mid := start + (end-start)/2
    if t.SizeVariation > 0 {
        mid += rand.Intn(t.SizeVariation*2) - t.SizeVariation  // 随机化分割点
    }
    left := t.chunk(start, mid)
    right := t.chunk(mid, end)
    return append(left, right...)
}

Limit Data Toxic

实现了 NewState() interface{} 接口,每个连接独立的状态,避免多连接互相干扰。

// 来自 toxics/limit_data.go
type LimitDataToxicState struct {
    bytesTransmitted int64    // 累计传输字节
}

func (t *LimitDataToxic) Pipe(stub *ToxicStub) {
    state := stub.State.(*LimitDataToxicState)
    bytesRemaining := t.Bytes - state.bytesTransmitted

    for {
        select {
        case <-stub.Interrupt: return
        case c := <-stub.Input:
            if c == nil { stub.Close(); return }

            // 数据截断
            if bytesRemaining < int64(len(c.Data)) {
                c = &stream.StreamChunk{
                    Timestamp: c.Timestamp,
                    Data:      c.Data[0:bytesRemaining],   // ← 只保留剩余配额
                }
            }

            stub.Output <- c
            state.bytesTransmitted += int64(len(c.Data))
            bytesRemaining = t.Bytes - state.bytesTransmitted

            if bytesRemaining <= 0 {
                stub.Close()    // ← 配额用完,关闭连接
                return
            }
        }
    }
}

Toxicity 概率参数

每个 toxic 都支持 toxicity 参数(0.0 - 1.0),表示故障发生的概率:测试间歇性故障,比"100% 故障"或"100% 正常"都更接近真实生产。

# 30% 的请求会被延迟 2 秒,70% 正常通过
curl -X POST http://localhost:8474/proxies/payment-proxy/toxics \
  -d '{
    "name":"flaky",
    "type":"latency",
    "toxicity":0.3,
    "attributes":{"latency":2000}
  }'

tenacity重试库

tenacity 是 Python 生态最流行的重试库,由 Julien Danjou 维护,用于给函数添加重试能力。它的前身是 retrying 库,2016 年因原作者维护中断而 fork 重写。核心特性如下

  • 装饰器语法,零侵入业务代码
  • 多种停止条件(次数、总时间、异常类型)
  • 多种等待策略(固定、指数退避、随机)
  • 异常分类(哪些异常重试,哪些不重试)
  • 钩子函数(重试前/后/失败时的回调)

核心三要素如下

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

@retry(
    stop=stop_after_attempt(3),                            # ① 何时停止
    wait=wait_exponential(multiplier=1, min=1, max=10),    # ② 等多久再试
    retry=retry_if_exception_type((ConnectionError,))      # ③ 什么异常才重试
)
def call_api():
    return requests.get('http://api.example.com')

整体的逻辑图图下

graph LR Call[函数调用] --> Try1[尝试1] Try1 -->|成功| Done[返回结果] Try1 -->|可重试异常| W1[等待 1s] W1 --> Try2[尝试2] Try2 -->|成功| Done Try2 -->|可重试异常| W2[等待 2s] W2 --> Try3[尝试3] Try3 -->|成功| Done Try3 -->|失败| RaiseRE[抛 RetryError] Try1 -->|不可重试异常| Raise[直接抛异常] style Done fill:#9f9 style RaiseRE fill:#f99 style Raise fill:#f99

等待策略(wait)

from tenacity import (
    wait_fixed,            # 固定等待
    wait_random,           # 随机等待
    wait_exponential,      # 指数退避
    wait_random_exponential  # 指数退避 + 抖动 (推荐)
)

# 指数退避:1s, 2s, 4s, 8s, 16s... 上限 60s
@retry(wait=wait_exponential(multiplier=1, max=60))
def call(): pass

# 指数退避 + 抖动:避免蜂群效应
@retry(wait=wait_random_exponential(multiplier=1, max=60))
def call(): pass

为什么需要抖动(jitter)呢?

sequenceDiagram participant C1 as 客户端1 participant C2 as 客户端2 participant C3 as 客户端3 participant S as 服务 Note over S: 服务故障 C1->>S: 请求失败 C2->>S: 请求失败 C3->>S: 请求失败 Note over C1,C3: 大家都退避 1s par 同时重试 C1->>S: 重试 (T+1s) C2->>S: 重试 (T+1s) C3->>S: 重试 (T+1s) end Note over S: 雷鸣群效应<br/>同时打过来,服务再次崩溃

加了抖动后,3 个客户端会在 [0.5s, 1.5s] 范围内随机分散重试,避免同步冲击。

重试条件(retry)

from tenacity import retry, retry_if_exception_type, retry_if_result

# 仅特定异常重试
@retry(retry=retry_if_exception_type((ConnectionError, Timeout)))
def call(): pass

# 根据返回值决定是否重试
@retry(retry=retry_if_result(lambda r: r is None))
def call(): return None  # 返回 None 时重试

同步异常类型需要区分是否应当重试:

异常类型 是否应该重试
ConnectionError(网络问题) 应该重试
Timeout(超时) 通常重试
404 Not Found 不要重试(资源不存在)
400 Bad Request 不要重试(请求本身错误)
401 Unauthorized 不要重试(凭证问题)
500 Internal Server Error 视情况(可能是临时问题)

钩子函数

from tenacity import retry, stop_after_attempt, before_sleep_log
import logging

logger = logging.getLogger(__name__)

@retry(
    stop=stop_after_attempt(3),
    before_sleep=before_sleep_log(logger, logging.WARNING)  # 重试前打印日志
)
def call_api():
    pass

在重试前会输出输出日志:

WARNING:root:Retrying call_api in 1.0 seconds as it raised ConnectionError: ...

tenacity 应用

本文实验中订单服务的 process_payment 函数:

  • timeout=10:单次 HTTP 请求最多等 10 秒
  • stop_after_attempt(2):失败最多重试 1 次(共 2 次)
  • 总最坏时间:10s + 0.5s + 10s = 20.5s

注意:总等待时间 = 单次超时 × 重试次数 + 退避总时间,因此必须确保此值 < 上游调用方的超时,否则你重试还没完,调用方已经超时了。

@retry(
    stop=stop_after_attempt(2),                        # 最多 2 次
    wait=wait_exponential(multiplier=1, min=0.5, max=2)  # 0.5s, 1s, 2s
)
def process_payment(user_id, amount):
    try:
        r = requests.post(
            f"{PAYMENT_SERVICE_URL}/api/payments/charge",
            json={'user_id': user_id, 'amount': amount},
            timeout=10  # ← 单次超时 10 秒
        )
        r.raise_for_status()
        return True
    except requests.Timeout:
        logger.error("Payment timeout")
        raise   # ← 重新抛出,触发 tenacity 重试
    except Exception as e:
        logger.warning(f"Payment failed: {e}")
        return False

环境初始化

本次测试的完整架构如下,所有跨服务的 TCP 通信都经过 Toxiproxy。

graph TB Client[客户端 curl] -->|HTTP :5000| OS[Order Service] subgraph "Toxiproxy 代理层 (4个代理)" TP_DB[postgres-proxy<br/>:5433] TP_RD[redis-proxy<br/>:6380] TP_PAY[payment-proxy<br/>:9001] TP_INV[inventory-proxy<br/>:8001] end OS -->|psycopg2| TP_DB OS -->|redis client| TP_RD OS -->|HTTP| TP_PAY OS -->|HTTP| TP_INV TP_DB -.-> PG[(PostgreSQL)] TP_RD -.-> RD[(Redis)] TP_PAY -.-> PAY[Payment Service] TP_INV -.-> INV[Inventory Service] INV -->|psycopg2| TP_DB API[Toxiproxy API :8474] -.动态控制.-> TP_DB API -.动态控制.-> TP_RD API -.动态控制.-> TP_PAY API -.动态控制.-> TP_INV style TP_DB fill:#fc6 style TP_RD fill:#fc6 style TP_PAY fill:#fc6 style TP_INV fill:#fc6

使用docker-compose.yml部署服务,环境变量必须指向 toxiproxy。这是最容易出错的点。应用直连真实服务时,故障注入完全无效

services:
  order-service:
    build: { context: ./webapp, dockerfile: Dockerfile }
    ports: ["5000:5000"]
    environment:
      # ↓ 所有外部依赖都指向 toxiproxy
      - DATABASE_URL=postgresql://orderuser:orderpass@toxiproxy:5433/orderdb
      - REDIS_URL=redis://toxiproxy:6380/0
      - PAYMENT_SERVICE_URL=http://toxiproxy:9001
      - INVENTORY_SERVICE_URL=http://toxiproxy:8001
    depends_on: [postgres, redis, toxiproxy, payment-service, inventory-service]
    networks: [chaos-net]

  inventory-service:
    build: { context: ./webapp, dockerfile: Dockerfile.inventory }
    ports: ["5001:5001"]
    environment:
      # ↓ 库存服务的数据库调用也走 toxiproxy
      - DATABASE_URL=postgresql://invuser:invpass@toxiproxy:5433/inventorydb
    depends_on: [postgres, toxiproxy]
    networks: [chaos-net]

  payment-service:
    build: { context: ./webapp, dockerfile: Dockerfile.payment }
    ports: ["7001:7001"]
    networks: [chaos-net]

  postgres:
    image: postgres:15
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
    networks: [chaos-net]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U superuser"]

  redis:
    image: redis:7.2-bookworm
    networks: [chaos-net]

  toxiproxy:
    image: ghcr.io/shopify/toxiproxy:2.5.0
    ports:
      - "8474:8474"     # HTTP API
      - "5433:5433"     # postgres-proxy
      - "6380:6380"     # redis-proxy
      - "9002:9001"     # payment-proxy (9001被占用映射到9002)
      - "8001:8001"     # inventory-proxy
    command: ["-host", "0.0.0.0"]
    networks: [chaos-net]

networks:
  chaos-net: { driver: bridge }
volumes:
  postgres-data:

数据库初始化

-- scripts/init-db.sql
CREATE DATABASE orderdb;
CREATE DATABASE inventorydb;
CREATE USER orderuser WITH PASSWORD 'orderpass';
CREATE USER invuser WITH PASSWORD 'invpass';
GRANT ALL PRIVILEGES ON DATABASE orderdb TO orderuser;
GRANT ALL PRIVILEGES ON DATABASE inventorydb TO invuser;

\c orderdb;
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    order_id VARCHAR(50) UNIQUE NOT NULL,
    user_id VARCHAR(50) NOT NULL,
    total_amount DECIMAL(10, 2) DEFAULT 0,
    status VARCHAR(20) DEFAULT 'created',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO orderuser;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO orderuser;

\c inventorydb;
CREATE TABLE products (
    id VARCHAR(50) PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    stock INTEGER DEFAULT 0
);
INSERT INTO products VALUES
    ('PROD-001', '精品咖啡豆 500g', 128.00, 100),
    ('PROD-002', '手冲咖啡壶套装', 299.00, 50),
    ('PROD-003', '进口全脂牛奶 1L', 18.00, 200);
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO invuser;

Toxiproxy 代理初始化

服务启动后 Toxiproxy 是空的,没有任何代理,必须手动创建。

# 创建 4 个代理
curl -X POST http://localhost:8474/proxies \
  -d '{"name":"postgres-proxy","listen":"0.0.0.0:5433","upstream":"postgres:5432"}'

curl -X POST http://localhost:8474/proxies \
  -d '{"name":"redis-proxy","listen":"0.0.0.0:6380","upstream":"redis:6379"}'

curl -X POST http://localhost:8474/proxies \
  -d '{"name":"payment-proxy","listen":"0.0.0.0:9001","upstream":"payment-service:7001"}'

curl -X POST http://localhost:8474/proxies \
  -d '{"name":"inventory-proxy","listen":"0.0.0.0:8001","upstream":"inventory-service:5001"}'

确认创建:

$ curl -s http://localhost:8474/proxies | python3 -m json.tool
{
    "inventory-proxy": {"listen": "[::]:8001", "upstream": "inventory-service:5001", ...},
    "payment-proxy":   {"listen": "[::]:9001", "upstream": "payment-service:7001", ...},
    "postgres-proxy":  {"listen": "[::]:5433", "upstream": "postgres:5432", ...},
    "redis-proxy":     {"listen": "[::]:6380", "upstream": "redis:6379", ...}
}

应用韧性模式实现

订单服务模拟一个真实的电商下单流程,业务调用链如下,每一步都涉及不同等级的故障容忍策略

graph LR Req[POST /api/orders<br/>用户下单] --> Inv[① 检查库存<br/>Inventory Service] Inv --> Pay[② 处理支付<br/>Payment Service] Pay --> DB[③ 写入订单<br/>PostgreSQL] DB --> Cache[④ 缓存订单<br/>Redis] Cache --> Resp[返回订单号] style Inv fill:#cfe style Pay fill:#fcc style DB fill:#fec style Cache fill:#cef

韧性 4 大支柱示意图

mindmap root((韧性系统)) Timeout P99 × 3 连接超时 ≠ 读超时 上下文传播 Retry 指数退避 + 抖动 最大次数限制 幂等性前提 仅瞬态错误 Circuit Breaker 快速失败 避免雪崩 自动半开试探 Fallback 静态默认值 缓存兜底 降级响应

配置层:让流量都走 Toxiproxy,这样 Toxiproxy 才能作为"中间人"拦截到流量。

# webapp/app.py
import os
import psycopg2
import redis
import requests
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://orderuser:orderpass@toxiproxy:5433/orderdb')
REDIS_URL = os.getenv('REDIS_URL', 'redis://toxiproxy:6380/0')
PAYMENT_SERVICE_URL = os.getenv('PAYMENT_SERVICE_URL', 'http://toxiproxy:9001')
INVENTORY_SERVICE_URL = os.getenv('INVENTORY_SERVICE_URL', 'http://toxiproxy:8001')

用户点击"提交订单"后,最忌讳的是白屏等待。这里 connect_timeout=3 意味着,数据库超时设计的考量,如果 3 秒内连不上数据库,立即放弃,向用户报错而不是让浏览器转圈 30 秒。

def get_db_connection():
    return psycopg2.connect(DATABASE_URL, connect_timeout=3)

常见的数据库连接延迟如下,因此3 秒是同机房场景下"绝对不可能正常但还没到雪崩"的阈值。

场景 数据库正常连接耗时 推荐 connect_timeout
同机房内网 < 5ms 1-3 秒
跨可用区 5-20ms 3-5 秒
跨地域 50-200ms 5-10 秒

库存检查:为什么"失败也算成功"

这段代码体现了业务驱动的容错决策,即库存检查在订单流程中是"软依赖"。

  • stop_after_attempt(3):库存服务允许偶发抖动,给 3 次机会
  • wait_exponential(min=1, max=10):1s → 2s → 4s,避免重试雪崩
  • retry_if_exception_type((Timeout, ConnectionError)):只对网络问题重试,HTTP 4xx 不重试(4xx 是请求本身有问题,重试也是错)
@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    retry=retry_if_exception_type((requests.Timeout, requests.ConnectionError))
)
def check_inventory(items):
    try:
        r = requests.get(f"{INVENTORY_SERVICE_URL}/api/inventory", timeout=5)
        r.raise_for_status()
        return True
    except Exception as e:
        logger.warning(f"Inventory check degraded: {e}")
        return True   # ← 注意这里!失败也返回 True

为什么库存可以降级?因为

  • 超卖损失:100 个订单中可能 1-2 单超卖,造成几百元额外发货成本
  • 拒绝下单损失:100 个订单全部失败,损失数万元 GMV + 客户体验

绝大多数电商选择"放行 + 事后对账"。这就是为什么代码里 except 分支也返回 True

graph TB Decision{库存服务挂了<br/>该怎么办?} Decision -->|拒绝下单| Bad1[用户流失] Decision -->|拒绝下单| Bad2[GMV损失] Decision -->|拒绝下单| Bad3[库存服务变成单点] Decision -->|放行下单| Good1[订单照常完成] Decision -->|放行下单| Good2[⚠️ 可能少量超卖] Decision -->|放行下单| Good3[后续异步对账可补救] style Bad1 fill:#fcc style Bad2 fill:#fcc style Bad3 fill:#fcc style Good1 fill:#cfc style Good2 fill:#fec style Good3 fill:#cfc

支付处理:为什么"失败必须是失败"

支付是订单流程的"关键路径",任何不确定都必须报错让用户知情。

@retry(stop=stop_after_attempt(2), wait=wait_exponential(min=0.5, max=2))
def process_payment(user_id, amount):
    try:
        r = requests.post(
            f"{PAYMENT_SERVICE_URL}/api/payments/charge",
            json={'user_id': user_id, 'amount': amount},
            timeout=10
        )
        r.raise_for_status()
        return True
    except requests.Timeout:
        raise          # ← 抛出,让 tenacity 接管重试
    except Exception as e:
        return False   # ← 返回失败,不能假装成功

对比库存检查的关键差异

维度 check_inventory process_payment
失败时返回 True(降级) False(如实报告)
重试次数 3 次(更宽容) 2 次(更保守)
退避时间 1-10s(慢) 0.5-2s(快)
业务影响 少量超卖 用户重复扣款风险

为什么支付重试要保守?超时不等于失败,有可能上游已经成功,只是响应丢了。重试就会变成重复操作。

sequenceDiagram participant U as 用户 participant O as Order Service participant P as Payment Service participant Bank as 银行 U->>O: 下单 (¥299) O->>P: 扣款 (¥299) P->>Bank: 扣款指令 Bank-->>P: 扣款成功 Note over P,O: 网络抖动,响应丢失 P--x O: ⏱ Timeout Note over O: tenacity 触发重试 O->>P: 扣款 (¥299) [重试] P->>Bank: 扣款指令 [⚠️ 重复!] Bank-->>P: 扣款成功 Note over U: 用户被扣款 ¥598!

正确的做法是配合幂等性,支付服务收到相同的 key 时直接返回上次结果,不会重复扣款

import uuid
idempotency_key = str(uuid.uuid4())   # 客户端生成,本次订单全程不变

r = requests.post(
    f"{PAYMENT_SERVICE_URL}/api/payments/charge",
    json={'user_id': user_id, 'amount': amount},
    headers={'X-Idempotency-Key': idempotency_key},   # 关键
    timeout=10
)

订单查询:缓存降级的优雅退化

订单查询是高频读操作(用户反复刷新订单页面),用 Redis 缓存提升性能。但 Redis 不是权威数据源,最终数据在 PostgreSQL。

@app.route('/api/orders/<order_id>')
def get_order(order_id):
    if redis_client:
        try:
            cached = redis_client.get(f"order:{order_id}")
            if cached:
                return jsonify({'data': str(cached), 'source': 'cache'})
        except Exception as e:
            logger.warning(f"Cache read failed, falling back to DB: {e}")
            # 注意:这里没有 raise,流程继续往下走

    try:
        conn = get_db_connection()
        # ... 从 DB 查询并返回
    except Exception:
        return jsonify({'error': 'database unavailable'}), 503

降级路径的业务图景如下

graph TB Req[GET /api/orders/123] --> CheckRedis{Redis 可用?} CheckRedis -->|是| TryCache[读取缓存] TryCache --> CacheHit{命中?} CacheHit -->|是| FastPath[返回 source:cache<br/>响应时间 ~5ms] CacheHit -->|否| FallbackDB[回源数据库] CheckRedis -->|否| LogWarn[记录警告日志] LogWarn --> FallbackDB FallbackDB --> DBOk{DB 可用?} DBOk -->|是| SlowPath[返回 source:database<br/>响应时间 ~50ms] DBOk -->|否| Fail[503 服务不可用<br/>真正的失败] style FastPath fill:#cfc style SlowPath fill:#fec style Fail fill:#fcc

层次化的故障耐受度

故障组合 用户体验 实现
Redis 正常 + DB 正常 极快(5ms) 缓存命中
Redis 异常 + DB 正常 稍慢(50ms) 自动降级到 DB,用户无感知
Redis 正常 + DB 异常 缓存命中可用,未命中失败 部分功能
Redis 异常 + DB 异常 503 错误 真正的故障

关键设计原则永远不要让缓存的故障变成业务的故障

代码里 except Exception as e: logger.warning(...) 这一行没有 raise,因为:

  • 缓存读失败 ≠ 业务失败
  • 主流程继续走 DB 查询,用户可能感知到"慢了一点"但不会看到错误页

韧性设计的三层防御

graph TB Req[请求] --> L1[第1层: 超时] L1 -->|超时| L2[第2层: 重试 + 退避] L2 -->|重试耗尽| L3[第3层: 降级 / 熔断] L3 -->|降级失败| Final[返回错误<br/>记录告警] L1 -.成功.-> Done[返回结果] L2 -.成功.-> Done L3 -.降级响应.-> Done style L1 fill:#fc6 style L2 fill:#fc6 style L3 fill:#fc6 style Done fill:#9f9 style Final fill:#f99

故障注入

每个测试前后需要保持环境洁净,避免上一个实验残留的 toxic 干扰下一个

# 实验开始前: 清除某个代理的所有 toxic
$ curl -X DELETE http://localhost:8474/proxies/payment-proxy/toxics/<toxic_name>
# 查看所有当前注入的 toxic
$ curl -s http://localhost:8474/proxies | python3 -m json.tool | grep -A2 toxics

故障注入的通用模板如下

  • 每次注入会立即返回 toxic 配置 JSON,可作为成功凭证。注入是即时生效的,下一个 TCP 数据包就会被影响。
curl -X POST http://localhost:8474/proxies/<代理名>/toxics \
  -H "Content-Type: application/json" \
  -d '{
    "name":     "<toxic唯一名>",       # 用于后续引用/删除
    "type":     "<latency|timeout|bandwidth|...>",  # toxic 类型
    "stream":   "<upstream|downstream>",            # 默认 downstream
    "toxicity": <0.0-1.0>,                          # 默认 1.0,即 100% 触发
    "attributes": { ... }                           # 类型特定参数
  }'

基线测试

建立"无故障"性能基准,作为后续所有实验的对照组。基线保持代理完全透明。

$ time curl -X POST http://localhost:5000/api/orders \
  -H "Content-Type: application/json" \
  -d '{"user_id":"user_baseline","items":[{"product_id":"PROD-001","quantity":2,"price":128}]}'

响应(70ms):

{
  "order_id": "ORD-1780121661-4456",
  "user_id": "user_baseline",
  "items": [{"price":128, "product_id":"PROD-001", "quantity":2}],
  "total": 256,
  "status": "created",
  "created_at": "2026-05-30T06:14:21.193508"
}

应用日志:

2026-05-30 06:14:21,123 [INFO] Creating order ORD-1780121661-4456 for user user_baseline, total=256
2026-05-30 06:14:21,124 [INFO] Checking inventory via toxiproxy...
2026-05-30 06:14:21,140 [INFO] Processing payment via toxiproxy for user_baseline: 256
2026-05-30 06:14:21,180 [INFO] Order ORD-1780121661-4456 saved to PostgreSQL via toxiproxy
2026-05-30 06:14:21,193 [INFO] Order ORD-1780121661-4456 cached
2026-05-30 06:14:21,193 [INFO] Order ORD-1780121661-4456 created successfully

链路分解(共 70ms):

  • Inventory 检查:~16ms(HTTP → toxiproxy → inventory → toxiproxy → postgres)
  • Payment 处理:~40ms(HTTP → toxiproxy → payment)
  • DB 写入:~13ms(psycopg2 → toxiproxy → postgres)
  • Redis 写入:~1ms

Inventory 服务注入延迟 2 秒

模拟库存服务因 GC 暂停或慢查询导致响应慢 2 秒的真实场景,验证应用是否能在容忍范围内正常完成下单。

注入命令

$ curl -X POST http://localhost:8474/proxies/inventory-proxy/toxics \
  -d '{"name":"inv_lat","type":"latency","stream":"downstream","attributes":{"latency":2000}}'

{"attributes":{"latency":2000,"jitter":0},"name":"inv_lat",
 "type":"latency","stream":"downstream","toxicity":1}

注入了什么

字段 含义
name inv_lat toxic 唯一标识,用于后续删除
type latency toxic 类型——网络延迟
stream downstream 方向:响应方向(库存服务 → 订单服务)
latency 2000 每个数据包延迟 2000 毫秒(2秒)
jitter 0(默认) 无抖动,固定延迟
toxicity 1(默认) 100% 触发率,每次请求都被延迟

底层数据流详情

  • downstream是因为我们要模拟"服务响应慢",而不是"请求发送慢"。下行延迟模拟的是"上游处理 + 网络回程"的总耗时。
订单服务请求 → toxiproxy:8001 (inventory-proxy)
        ↓
   ① toxiproxy 立刻转发请求到 inventory-service:5001
   ② inventory-service 正常处理(~10ms)并返回响应
   ③ 响应数据进入 downstream toxic 链
   ④ LatencyToxic 让每个数据包等待 2000ms 后才输出
   ⑤ 订单服务最终收到响应,总耗时 ≈ 2000ms + 正常耗时

注入后的状态查询:

$ curl -s http://localhost:8474/proxies/inventory-proxy
{
  "name": "inventory-proxy",
  "listen": "[::]:8001",
  "upstream": "inventory-service:5001",
  "enabled": true,
  "toxics": [
    {
      "name": "inv_lat",
      "type": "latency",
      "stream": "downstream",
      "toxicity": 1,
      "attributes": {"latency": 2000, "jitter": 0}
    }
  ]
}

发起订单:

$ time curl -X POST http://localhost:5000/api/orders \
  -d '{"user_id":"user_inv","items":[{"product_id":"PROD-002","quantity":1,"price":299}]}'

{"order_id":"ORD-1780121685-8614","status":"created", ...}

响应时间:2060ms(基线 70ms + 注入 2000ms)

关键观察

指标 基线 注入后 变化
响应时间 70ms 2060ms +2000ms ≈ 注入值
业务结果 成功 成功 应用容忍了延迟
错误日志 5s timeout > 2s latency

这印证了 tenacity 配置timeout=5 大于注入延迟 2000ms,所以单次成功,不触发重试。

PostgreSQL 连接累计超时

模拟"数据库网络抖动延迟 1.5 秒",验证 connect_timeout=3 在协议握手累积下被触发的真实场景——这是云环境中最常见的故障模式之一

注入命令与参数解读:

$ curl -X POST http://localhost:8474/proxies/postgres-proxy/toxics \
  -d '{"name":"db_lat","type":"latency","stream":"downstream","attributes":{"latency":1500}}'

注入了什么

字段 含义
name db_lat toxic 标识
type latency 延迟类型
stream downstream 响应方向(PostgreSQL → 应用)
latency 1500 每个 TCP 包延迟 1.5 秒

查询订单:

$ time curl http://localhost:5000/api/orders/ORD-1780121661-4456
{"error":"database unavailable"}

real    0m3.015s

1.5 秒乍看"还能接受",但 PostgreSQL 协议不是单次往返。它的连接建立是一个多阶段的状态机。为什么 1500ms 延迟会导致失败? 让我们看 psycopg2 的连接过程:

注意:Toxiproxy 的 toxic 工作在 TCP 连接建立之后的数据流上。TCP 三次握手(SYN/SYN-ACK/ACK)是网络层操作,由 net.Listener.Accept()net.Dial() 完成,不经过 toxic pipelinestream: downstream 只影响连接内从 PostgreSQL 返回的协议响应数据

sequenceDiagram participant App as psycopg2 participant TP as Toxiproxy<br/>(downstream 延迟1500ms) participant PG as PostgreSQL Note over App,PG: ① TCP 三次握手(网络层,不经过 toxic) App->>TP: TCP SYN TP->>PG: TCP SYN PG->>TP: TCP SYN-ACK TP->>App: TCP SYN-ACK Note over App,PG: 握手完成,~1ms,toxic 未介入 Note over App,PG: ② PostgreSQL 协议握手(downstream toxic 生效) App->>PG: StartupMessage (user=orderuser, db=orderdb) Note over App: → upstream 方向,无 toxic,瞬间到达 PG->>TP: AuthenticationOk Note over TP: ⏱ downstream toxic: 延迟 1500ms TP->>App: AuthenticationOk (+1500ms) Note over App: 累计: 1500ms App->>PG: PasswordMessage (md5) PG->>TP: AuthenticationOk Note over TP: ⏱ downstream toxic: 延迟 1500ms TP->>App: AuthenticationOk (+1500ms) Note over App: 累计: 3000ms PG->>TP: ParameterStatus × N Note over TP: ⏱ 下行数据继续延迟... Note over App: connect_timeout=3 已触发<br/>psycopg2 放弃连接

根因分析

阶段 方向 是否被 toxic 影响 延迟
TCP 三次握手 双向 不经过 toxic ~1ms
StartupMessage upstream (App→PG) 无 upstream toxic ~1ms
AuthenticationOk downstream (PG→App) 被延迟 1500ms +1500ms
PasswordMessage upstream (App→PG) 无 upstream toxic ~1ms
AuthenticationOk downstream (PG→App) 被延迟 1500ms +1500ms
ParameterStatus 等 downstream (PG→App) 继续被延迟 没等到就超时了

connect_timeout=3 计的是从开始连接到收到 ReadyForQuery 的总时间。每个 PostgreSQL 返回的协议消息都 +1500ms,两次认证响应就已达 3000ms,刚好卡在超时阈值上。单次 downstream 延迟看起来"还能接受",但在 PostgreSQL 协议的多次认证往返中累积后变成致命。

Payment 服务严重延迟

注入超过应用层超时(timeout=10s)的延迟,精确触发 tenacity 重试逻辑,观察重试时间线和最终失败行为。这复现了"下游服务卡死,重试反而让用户等更久"的痛点场景。

注入命令与参数解读:

$ curl -X POST http://localhost:8474/proxies/payment-proxy/toxics \
  -d '{"name":"payment_long","type":"latency","attributes":{"latency":15000}}'

注入了什么

字段 含义
name payment_long toxic 标识
type latency 延迟类型
stream (未指定,默认 downstream 响应方向
latency 15000 延迟 15 秒

为什么选 15 秒?这是精心设计的实验值:

应用配置: timeout=10s (process_payment 函数)
注入延迟: 15s
关系: 15s > 10s
结果: 必然触发 ReadTimeout,然后被 tenacity 接住

底层数据流示意图如下:

sequenceDiagram participant App as Order Service participant TP as Toxiproxy<br/>(latency:15000) participant Pay as Payment Service App->>TP: POST /charge (建立TCP连接) TP->>Pay: 转发请求 Pay->>TP: 响应(<10ms 完成) Note over TP: ⏱ LatencyToxic 持有响应数据<br/>等待 15000ms App->>App: ⏱ 等待中... Note over App: 10秒后,requests库 ReadTimeout App-xTP: 主动关闭 TCP Note over App: tenacity 接住异常,触发重试 Note over TP: 5秒后才会真正放行响应<br/>但客户端已断开,数据被丢弃

发起订单,请求失败,实践为21 秒

$ time curl -X POST http://localhost:5000/api/orders \
  -d '{"user_id":"user_retry","items":[{"product_id":"PROD-001","quantity":1,"price":128}]}'

{"error":"RetryError[<Future at 0x7f8641affe30 state=finished raised ReadTimeout>]"}

real    0m21.032s

完整重试时间线(应用日志):

  1. tenacity 配置 stop_after_attempt(2) 表示总共 2 次(不是"额外重试 2 次")
  2. 总耗时 = 单次超时 × 尝试次数 + 退避总和
  3. 用户感知:21 秒后才看到失败——这往往比快速失败更糟糕
06:14:00.000 [INFO] Creating order ORD-... for user user_retry
06:14:00.005 [INFO] Checking inventory via toxiproxy...    [16ms - inventory正常]
06:14:00.021 [INFO] Processing payment via toxiproxy for user_retry: 128
06:14:10.025 [ERROR] Payment timeout                       ← ① 第1次超时(10s)
06:14:10.526 [INFO] Processing payment via toxiproxy for user_retry: 128  ← ② 退避0.5s后重试
06:14:20.530 [ERROR] Payment timeout                       ← ③ 第2次超时(10s)
06:14:20.531 [ERROR] Order creation failed: RetryError[...] ← ④ 重试耗尽

Timeout Toxic vs Latency Toxic

timeout toxic 不是"无限延迟",而是完全不同的故障语义。理解它能避免混沌实验设计错误。

注入命令与参数解读

$ curl -X POST http://localhost:8474/proxies/payment-proxy/toxics \
  -d '{"name":"payment_to","type":"timeout","attributes":{"timeout":0}}'

注入了什么

字段 含义
name payment_to toxic 标识
type timeout 超时丢包类型(注意:不是 latency)
timeout 0 特殊值:永远不触发主动关闭

关键源码回顾(来自 3.6 节 timeout.go):

if timeout > 0 {
    // 模式A: 等 timeout 毫秒后强制关闭连接
} else {
    // 模式B: timeout=0,进入"永远丢弃数据"模式
    for {
        select {
        case <-stub.Interrupt: return
        case c := <-stub.Input:
            if c == nil { stub.Close(); return }
            // 静默丢弃所有数据,永不响应,永不主动关闭
        }
    }
}

timeout=0 实际行为

  • 建立TCP连接不受影响,是因为Toxics.StartLink() 在 TCP 握手之后才被调用。Toxic Pipeline 工作在已建立的 TCP 连接之上,处理的是连接内的数据流,而不是连接建立过程本身。
sequenceDiagram participant App participant TP as Toxiproxy<br/>(timeout:0) participant Pay as Payment Service App->>TP: ① 建立 TCP 连接 ✅ Note over TP: 连接建立成功(toxic不影响TCP握手) App->>TP: ② 发送 HTTP 请求数据 Note over TP: 🗑️ 数据被丢弃<br/>(Drop on the ground) Note over TP,Pay: 请求永远不会到达 Payment Service Note over App: 应用等待响应... Note over TP: TCP 层因双方都不发数据<br/>OS 触发 RST 或 keepalive 超时 TP-xApp: TCP RST / Connection Reset Note over App: ⚡ 立即收到 RemoteDisconnected<br/>不到 30ms

对比三种"超时"故障模式

注入方式 应用看到的 触发时间 应用日志特征
latency: 99999999(接近无限延迟) ReadTimeout 应用层 timeout 触发(如 10s) "Timeout while reading"
timeout: 0(本实验) ConnectionError TCP RST 立即返回(~30ms) "RemoteDisconnected"
timeout: 5000 先发数据,5s 后被强制断 5 秒 "Connection aborted"

生产场景对应

  • latency 极大值 → 模拟"服务僵死,慢得像挂了"
  • timeout: 0 → 模拟"防火墙黑洞、负载均衡器丢包"
  • timeout: N → 模拟"主动断连,如 Nginx upstream timeout"

结果:

$ time curl -X POST http://localhost:5000/api/orders ...
{"error":"payment failed"}
real    0m0.036s

应用日志:

  • timeout=0 让 toxic 进入"永远丢弃数据"模式
  • 但 toxic 自己不主动关闭连接
  • 客户端发请求 → 数据被丢 → TCP 层最终因为 keepalive 或 buffer 满而 reset
  • 表现为 RemoteDisconnected不触发 ReadTimeout
06:15:00.438 [INFO] Creating order ORD-...
06:15:00.460 [WARNING] Payment failed:
  ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

对比表

Toxic 应用看到的错误 触发时间 tenacity 是否重试
timeout ConnectionError / RemoteDisconnected 立即 (~30ms) 是(ConnectionError 在重试列表)
latency: 15000 Timeout / ReadTimeout 单次超时 (10s) 是(Timeout 在重试列表)

Toxiproxy API 速查

# 代理管理
GET    /proxies                         # 列出所有代理
POST   /proxies                         # 创建代理
GET    /proxies/{name}                  # 查看代理
POST   /proxies/{name}/enable           # 启用
POST   /proxies/{name}/disable          # 禁用
DELETE /proxies/{name}                  # 删除

# Toxic 管理
POST   /proxies/{name}/toxics           # 添加 toxic
GET    /proxies/{name}/toxics           # 列出 toxics
POST   /proxies/{name}/toxics/{tname}   # 修改 toxic
DELETE /proxies/{name}/toxics/{tname}   # 删除 toxic

# Toxic 通用参数
{
  "name": "my_toxic",          # 唯一标识
  "type": "latency",           # toxic 类型
  "stream": "downstream",      # 方向 upstream/downstream
  "toxicity": 1.0,             # 触发概率 0.0-1.0
  "attributes": {...}          # toxic 特定参数
}
posted @ 2026-05-30 17:11  zhaojie10  阅读(33)  评论(0)    收藏  举报