Posted in

【Go SSE生产事故TOP3】:从携程订单实时同步故障看客户端EventSource重试机制设计缺陷

第一章:SSE协议原理与Go语言实现基础

Server-Sent Events(SSE)是一种基于 HTTP 的单向实时通信协议,专为服务器向客户端持续推送文本事件而设计。它采用长连接机制,复用标准 HTTP 协议,无需额外握手或复杂状态管理,相比 WebSocket 更轻量、更易穿透代理与 CDN。SSE 规范要求响应头必须包含 Content-Type: text/event-streamCache-Control: no-cache,并以特定格式分隔事件块:每个事件以 data: 开头,可选配 event:id:retry: 字段,各字段后跟两个换行符(\n\n)表示一个完整事件。

SSE 与常见协议对比

特性 SSE WebSocket HTTP 轮询
通信方向 单向(服务端→客户端) 双向 单向(请求→响应)
连接持久性 长连接,自动重连 长连接,需手动维护 短连接
兼容性 主流浏览器均支持(IE 除外) 广泛支持但需兼容处理 完全兼容
数据格式 UTF-8 文本(纯文本流) 二进制/文本任意 任意(JSON/XML等)

Go 语言实现 SSE 服务端

使用 Go 标准库 net/http 即可构建符合规范的 SSE 服务。关键在于设置正确响应头、禁用缓冲,并保持连接活跃:

func sseHandler(w http.ResponseWriter, r *http.Request) {
    // 设置 SSE 必需响应头
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("Access-Control-Allow-Origin", "*") // 支持跨域

    // 禁用 HTTP 响应缓冲,确保数据即时写出
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }

    // 每秒推送一个带时间戳的事件
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        // 构造标准 SSE 事件:data: 后接 JSON 字符串,结尾双换行
        fmt.Fprintf(w, "data: %s\n\n", time.Now().UTC().Format(`{"time":"2006-01-02T15:04:05Z"}`))
        flusher.Flush() // 强制刷新输出缓冲区
    }
}

启动服务后,可通过 curl -N http://localhost:8080/sse 实时观察流式输出。客户端 JavaScript 可直接使用 EventSource API 订阅该端点,无需引入第三方库。

第二章:EventSource客户端重试机制的理论缺陷剖析

2.1 SSE标准规范中重试策略的模糊性与实现分歧

SSE(Server-Sent Events)规范仅以一句话定义重试行为:“用户代理应使用响应头 Retry: 指定的毫秒数,若未提供则使用默认值(通常为3000)”。这一表述未明确重试触发条件、退避机制及错误边界,导致实现显著分化。

重试触发条件差异

  • Chrome:仅在连接意外中断(如网络闪断)时应用 Retry:
  • Firefox:对 HTTP 5xx 响应也触发重试,且忽略 Retry: 头而固定使用 5000ms
  • Safari:完全忽略 Retry:,始终使用内置 3s 间隔

典型服务端重试头设置

HTTP/1.1 200 OK
Content-Type: text/event-stream
Retry: 5000

此头仅建议客户端重连延迟,不保证执行;Chrome 会遵守,但若前次连接因 net::ERR_CONNECTION_REFUSED 中断,则立即重试(无视该值),体现规范与现实的脱节。

重试行为对比表

实现 遵守 Retry: 5xx 触发重试 支持指数退避
Chrome
Firefox
Safari ⚠️(仅部分)
graph TD
    A[连接关闭] --> B{关闭原因}
    B -->|网络中断| C[Chrome:用Retry值]
    B -->|5xx响应| D[Firefox:强制5s]
    B -->|任意错误| E[Safari:固定3s]

2.2 Go net/http服务端对retry字段的解析偏差与响应截断实践

Go 标准库 net/http 在处理客户端携带的 Retry-After 响应头时,不主动解析其值,仅作透传;但部分中间件或自定义 handler 误将其当作 retry(小写)字段解析,导致语义丢失。

常见解析偏差场景

  • 客户端发送 Retry-After: 60,服务端日志误记为 retry=0(未匹配字段名)
  • 自定义限流中间件读取 r.Header.Get("retry") → 返回空字符串 → 默认转为

HTTP/1.1 规范要求对照

字段名 规范定义位置 Go net/http 行为
Retry-After RFC 7231 §7.1.3 ✅ 原样保留于 Header
retry(小写) ❌ 不识别,Get("retry") 永远为空
// 错误示范:忽略HTTP规范大小写约定
retryStr := r.Header.Get("retry") // 总是"",应使用"Retry-After"
if retry, err := strconv.Atoi(retryStr); err == nil {
    log.Printf("Parsed retry: %d", retry) // 实际永不执行
}

该代码因字段名不匹配始终跳过解析逻辑,造成下游重试策略失效。正确方式应调用 r.Header.Get("Retry-After") 并支持秒数/HTTP-date两种格式。

graph TD A[Client sends Retry-After: 120] –> B[net/http stores in Header map] B –> C{Handler calls Get\quot;retry\quot;} C –> D[Returns empty string] C –> E[Actual value accessible via \quot;Retry-After\quot;]

2.3 客户端EventSource在断连场景下的指数退避失效实测分析

数据同步机制

EventSource 规范要求实现指数退避重连(初始 retry: 1000,失败后翻倍),但主流浏览器实际行为存在偏差。

实测现象对比

浏览器 首次重试延迟 第3次重试延迟 是否严格遵循 2ⁿ×1000ms
Chrome 125 1000ms 3000ms ❌ 否(上限截断为5s)
Firefox 126 1000ms 4000ms ❌ 否(引入随机抖动)

关键复现代码

const es = new EventSource('/stream');
es.onopen = () => console.log('connected');
es.onerror = () => console.log('reconnect attempt at', Date.now());
// 注意:无法通过 es.readyState 控制退避逻辑——浏览器内部硬编码

逻辑分析EventSourceretry 值仅影响首次连接失败后的初始间隔;后续退避由浏览器私有算法决定,且不暴露 getRetryDelay() 接口。参数 retry 在 HTTP 响应头中设置(如 retry: 2000),但仅对下一次连接生效,无法动态调整。

退避失效根因

graph TD
    A[网络中断] --> B{浏览器触发重连}
    B --> C[读取上次响应的 retry 头]
    C --> D[应用退避算法]
    D --> E[强制上限/抖动/忽略]
    E --> F[实际延迟偏离预期]

2.4 携程订单同步故障复现:基于Chrome/Firefox/Edge的重试行为对比实验

数据同步机制

携程前端通过 fetch 发起带幂等头 X-Request-ID 的 POST 请求同步订单,后端依赖该头去重。但浏览器对失败请求的自动重试策略存在显著差异。

浏览器重试行为差异

浏览器 网络中断(TCP RST) 502/504 响应 是否携带原始请求体
Chrome ✅ 自动重试(1次) ❌ 不重试 ✅(仅GET重试带体)
Firefox ❌ 不重试 ❌ 不重试
Edge ✅ 重试(最多2次) ✅ 重试(1次) ⚠️ 首次重试带体,后续丢弃
// 同步请求核心逻辑(含显式防重)
const syncOrder = async (order) => {
  const idempotencyKey = crypto.randomUUID(); // 客户端生成幂等键
  try {
    const res = await fetch('/api/v1/orders/sync', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Idempotency-Key': idempotencyKey, // 后端据此拒绝重复提交
      },
      body: JSON.stringify(order),
      // 注意:未设置 keepalive,故页面卸载时 Chrome 可能静默丢弃请求
    });
    return await res.json();
  } catch (e) {
    console.warn('Sync failed, but browser may have retried:', e);
  }
};

此代码中 X-Idempotency-Key 由客户端生成并透传,但 Chrome 在网络层异常时仍会重发整个请求(含相同 body 和 header),导致后端收到重复键——若服务端幂等校验延迟或缓存未生效,将引发双写。

故障链路示意

graph TD
  A[用户点击“确认下单”] --> B[调用 syncOrder]
  B --> C{Chrome 检测 TCP 连接异常}
  C -->|自动重试| D[重发相同 X-Idempotency-Key 请求]
  D --> E[后端缓存未命中 → 二次落库]
  E --> F[订单重复创建]

2.5 Go HTTP Server超时配置与SSE长连接生命周期冲突的代码级验证

冲突根源:三重超时叠加

Go HTTP Server 默认启用 ReadTimeoutWriteTimeoutIdleTimeout,而 SSE 要求连接长期保持活跃,但无频繁写入时易被 IdleTimeout 中断。

复现代码片段

srv := &http.Server{
    Addr:         ":8080",
    ReadTimeout:  30 * time.Second,   // 客户端请求头读取上限
    WriteTimeout: 30 * time.Second,   // 单次响应写入上限(SSE事件间歇写入会触发!)
    IdleTimeout:  60 * time.Second,   // 连接空闲超时(SSE无数据时致命)
}

WriteTimeout 是关键陷阱:SSE 每次 event: message\n\ndata: ...\n\n 是一次独立 Write(),若间隔 >30s,http.ErrHandlerTimeout 直接触发连接关闭。

超时参数影响对比

参数 SSE 场景表现 推荐值
WriteTimeout 每次 Flush() 计时重启 (禁用)
IdleTimeout 连接无读/写即终止 或 ≥5min
ReadTimeout 首次握手后通常不触发 可保留默认

修复方案流程

graph TD
    A[客户端发起 SSE 请求] --> B{Server 启动 WriteTimeout 计时}
    B --> C[发送首个 event]
    C --> D[等待 35s 后发送次事件]
    D --> E[WriteTimeout 触发 panic]
    E --> F[连接强制关闭]
    F --> G[客户端重连,循环]

第三章:Go服务端SSE流式推送的健壮性设计

3.1 基于context取消与心跳保活的连接状态协同管理

在长连接场景中,单靠 TCP 心跳无法感知业务层超时或用户主动退出。Go 的 context 提供了可取消、可超时、可传递截止时间的能力,与心跳机制协同可实现端到端的状态一致性。

心跳与 context 的生命周期对齐

  • 心跳发送/响应需绑定 ctx.Done(),避免 goroutine 泄漏
  • 连接关闭时调用 cancel(),自动终止所有关联子 context
  • 心跳超时(如 30s 无响应)触发 context.WithTimeout 的 cancel

数据同步机制

// 启动带取消的心跳协程
func startHeartbeat(conn net.Conn, parentCtx context.Context) {
    ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
    defer cancel() // 确保资源释放

    ticker := time.NewTicker(15 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            if err := sendPing(ctx, conn); err != nil {
                log.Printf("ping failed: %v", err)
                return // 自动退出,不阻塞
            }
        case <-ctx.Done():
            return // context 取消或超时,优雅退出
        }
    }
}

parentCtx 由上层业务控制(如 HTTP handler 的 request.Context),WithTimeout 为心跳设置独立超时;selectctx.Done() 优先级高于 ticker,确保 cancel 信号即时响应。

协同维度 context 侧 心跳侧
触发源 业务逻辑取消/超时 网络不可达或服务宕机
响应延迟 纳秒级(内存信号) 秒级(网络往返)
状态最终一致性 ✅(cancel 广播至所有子 ctx) ✅(心跳失败后触发 cancel)
graph TD
    A[业务请求启动] --> B[创建 parentCtx]
    B --> C[启动心跳 goroutine]
    C --> D{心跳成功?}
    D -->|是| C
    D -->|否| E[调用 cancel()]
    E --> F[所有子 ctx.Done() 关闭]
    F --> G[清理连接/资源]

3.2 并发安全的事件缓冲队列与客户端连接注册中心实现

核心设计目标

  • 支持高并发写入(事件入队)与低延迟读取(客户端拉取)
  • 客户端连接生命周期自动注册/注销,避免内存泄漏
  • 事件不丢失、顺序可保障(按连接维度局部有序)

线程安全事件缓冲队列

type EventBuffer struct {
    mu      sync.RWMutex
    events  []Event
    clients map[string]*ClientMeta // clientID → 元信息
}

func (eb *EventBuffer) Push(e Event) {
    eb.mu.Lock()
    eb.events = append(eb.events, e)
    eb.mu.Unlock()
}

sync.RWMutex 保障多写一读场景下的数据一致性;events 切片虽非无锁,但通过细粒度锁+批量消费策略平衡性能与安全性。clients 映射需配合原子操作维护活跃连接视图。

客户端注册中心状态表

clientID conn lastActive seqOffset
cli_001 *net.Conn 1717024512 1024
cli_002 *net.Conn 1717024515 1031

数据同步机制

客户端首次连接时从 seqOffset 拉取增量事件,服务端按连接粒度维护游标,避免全局序列锁竞争。

3.3 断线重连ID(Last-Event-ID)的幂等校验与增量同步方案

数据同步机制

服务端通过 Last-Event-ID 头识别客户端最后接收事件序号,结合事件流中的 id: 字段实现精准断点续传。

幂等性保障策略

  • 服务端为每个事件分配全局单调递增的逻辑 ID(如 1698723450123-001
  • 客户端重连时携带 Last-Event-ID: 1698723450123-001,服务端跳过已发送事件
  • 事件存储需支持按 ID 范围快速定位(如 B+ 树索引)

增量同步流程

GET /events HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 1698723450123-001

该请求触发服务端从 > 1698723450123-001 的最小 ID 开始流式推送。Last-Event-ID 作为游标而非时间戳,规避时钟漂移问题;服务端需确保同一 ID 在全集群唯一且不可复用。

组件 职责 约束条件
客户端 缓存并透传 Last-Event-ID 必须持久化至本地存储
网关 透传头字段,不修改 禁止添加/覆盖该 header
事件服务 ID 生成、范围查询、流控 ID 必须满足字典序有序
graph TD
    A[客户端断线] --> B[重连请求携带 Last-Event-ID]
    B --> C{服务端查ID索引}
    C -->|找到后续事件| D[流式推送增量事件]
    C -->|无新事件| E[长轮询等待或返回空流]

第四章:生产级SSE系统可观测性与故障定位体系

4.1 自定义HTTP中间件注入SSE连接元数据与链路追踪ID

在SSE(Server-Sent Events)长连接场景中,需将请求上下文信息透传至业务层,支撑可观测性与调试能力。

核心注入策略

中间件在http.Handler链中拦截请求,提取并注入两类关键元数据:

  • X-Request-ID(链路追踪ID,若缺失则生成)
  • X-SSE-Session-ID(基于客户端IP+UserAgent哈希生成唯一会话标识)
func SSEMetadataMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 1. 确保链路ID存在
        traceID := r.Header.Get("X-Request-ID")
        if traceID == "" {
            traceID = uuid.New().String()
        }
        r = r.WithContext(context.WithValue(r.Context(), "trace_id", traceID))

        // 2. 注入SSE会话ID(仅对/event-stream路径)
        if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
            sessionID := fmt.Sprintf("%x", md5.Sum([]byte(
                r.RemoteAddr + r.UserAgent(),
            )))
            w.Header().Set("X-SSE-Session-ID", sessionID)
        }

        // 3. 透传trace ID到响应头,便于前端日志关联
        w.Header().Set("X-Request-ID", traceID)
        next.ServeHTTP(w, r)
    })
}

逻辑分析

  • r.WithContext()trace_id安全注入请求上下文,避免全局变量污染;
  • X-SSE-Session-ID 仅对SSE请求生效,通过响应头返回,供前端日志采集;
  • 双向透传X-Request-ID确保前后端链路ID一致,支持全链路追踪。

元数据注入效果对比

字段 来源 是否透传至前端 用途
X-Request-ID 请求头/生成 分布式链路追踪
X-SSE-Session-ID 中间件计算 SSE连接生命周期管理与诊断
graph TD
    A[Client发起SSE请求] --> B{中间件拦截}
    B --> C[生成/复用trace_id]
    B --> D[计算session_id]
    C --> E[注入Context & 响应头]
    D --> E
    E --> F[业务Handler处理事件流]

4.2 Prometheus指标埋点:连接数、消息延迟、重试率、EOF异常统计

核心指标设计原则

面向可观测性,需满足:低侵入性、高正交性、语义明确性。连接数反映服务容量水位,消息延迟刻画端到端SLA,重试率揭示下游稳定性,EOF异常统计定位网络/协议层断裂。

关键指标埋点示例

// 初始化指标(需在init()或服务启动时注册)
var (
  connGauge = prometheus.NewGaugeVec(
    prometheus.GaugeOpts{
      Name: "kafka_client_connection_total",
      Help: "Current number of active connections to Kafka brokers",
    },
    []string{"cluster", "broker"},
  )
  delayHist = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
      Name:    "kafka_produce_latency_ms",
      Help:    "Producer message latency in milliseconds",
      Buckets: prometheus.ExponentialBuckets(1, 2, 10), // 1ms~512ms
    },
    []string{"topic", "partition"},
  )
)

connGauge 使用 GaugeVec 支持多维标签(集群+Broker),实时反映连接生命周期;delayHist 采用指数桶,精准覆盖毫秒级延迟分布,避免线性桶在长尾场景下的分辨率损失。

指标语义对照表

指标名 类型 标签维度 业务含义
kafka_client_connection_total Gauge cluster, broker 当前活跃TCP连接数
kafka_consumer_retry_rate Counter topic, group_id 消费重试总次数(归一化为每秒)
kafka_eof_exceptions_total Counter broker, reason EOF类异常(如NETWORK_EXCEPTION

EOF异常归因流程

graph TD
  A[捕获EOFException] --> B{是否连续发生?}
  B -->|是| C[触发broker连接重建]
  B -->|否| D[记录metric并打标reason]
  C --> E[上报kafka_eof_exceptions_total{broker=\"x\", reason=\"disconnected\"}]
  D --> E

4.3 基于OpenTelemetry的端到端事件流追踪与故障根因定位

在复杂事件驱动架构中,单条消息可能穿越Kafka、Flink、Redis及多个微服务,传统日志串联难以还原完整调用链。OpenTelemetry通过trace_idspan_id跨系统透传,实现事件生命周期的原子级观测。

数据同步机制

OTLP exporter自动将Span上下文注入Kafka消息头(traceparent标准字段):

from opentelemetry.propagate import inject
from kafka import KafkaProducer

def send_traced_event(producer, topic, value):
    headers = {}
    inject(dict.__setitem__, headers)  # 注入W3C traceparent/tracestate
    producer.send(topic, value=value, headers=headers)

逻辑说明:inject()基于当前活跃Span生成符合W3C Trace Context规范的traceparent(含trace_id、span_id、flags),确保下游服务可无损提取并续接Span树。

根因定位关键维度

维度 作用
span.kind 区分producer/consumer/server等角色
messaging.system 标识Kafka/Flink等中间件类型
error.type 自动捕获反序列化或处理异常

故障传播路径

graph TD
    A[Event Producer] -->|trace_id=abc123| B[Kafka]
    B --> C[Flink Job]
    C --> D[Redis Cache]
    D --> E[API Gateway]
    E -.->|span.status=ERROR| F[Root Cause: Redis timeout]

4.4 日志结构化设计:按连接ID聚合的SSE会话全生命周期审计日志

为实现端到端可追溯性,SSE(Server-Sent Events)会话日志需以唯一 connection_id 为根键进行结构化建模,覆盖建立、心跳、数据推送、异常中断与主动关闭五阶段。

日志字段规范

  • connection_id: UUIDv4,客户端首次请求时服务端生成并透传至前端
  • event_type: open / heartbeat / data / error / close
  • timestamp_ms: 毫秒级Unix时间戳(UTC)
  • payload_size_bytes: 仅 data 类型记录有效载荷长度

核心日志结构示例

{
  "connection_id": "a1b2c3d4-5678-90ef-ghij-klmnopqrstuv",
  "event_type": "data",
  "timestamp_ms": 1717023456789,
  "payload_size_bytes": 1247,
  "http_status": 200,
  "client_ip": "203.0.113.42"
}

该结构支持ES按 connection_id 聚合查询,还原单次SSE会话完整轨迹;payload_size_bytes 用于识别大消息异常或DoS试探。

审计日志生命周期流转

graph TD
  A[Client connect] --> B[Open log + connection_id]
  B --> C{Heartbeat every 15s}
  C --> D[Data push event]
  D --> E[Error/close detected]
  E --> F[Close log with duration_ms]
字段 类型 必填 说明
connection_id string 全局唯一会话标识符
duration_ms number close 事件携带,计算 close_ts - open_ts

第五章:从事故到演进——Go SSE生态的工程化反思

在2023年Q3,某千万级实时通知平台遭遇一次典型的SSE链路雪崩:用户端连接数在15分钟内从80万飙升至142万,后端goroutine堆积超230万,API平均延迟从87ms跃升至2.4s,最终触发Kubernetes Horizontal Pod Autoscaler(HPA)紧急扩容失败——因新Pod启动时仍复用旧版net/http连接池配置,导致新建连接持续耗尽文件描述符。

连接生命周期失控的真实日志片段

我们从/debug/pprof/goroutine?debug=2快照中截取关键线索:

goroutine 1248923 [select, 12 minutes]:
net/http.(*http2serverConn).serve(0xc000a12000)
    /usr/local/go/src/net/http/h2_bundle.go:5321 +0x1a5
net/http.(*http2serverConn).processHeaderBlockFragment(0xc000a12000, {0xc000b2e000, 0x1000, 0x1000})
    /usr/local/go/src/net/http/h2_bundle.go:4982 +0x1c7

该goroutine卡在HTTP/2 header解析阶段,根源是客户端发送了未按规范终止的event:字段(如event: message\n\n后缺失data行),而标准net/http未实现RFC 8015第6.2节要求的“不完整事件帧自动丢弃”机制。

生产环境连接健康度对比表

指标 事故前(稳定态) 事故峰值期 改进后(v2.3.0)
平均连接存活时长 4m12s 18m33s 3m58s
异常断连率(/min) 0.2% 17.6% 0.03%
内存泄漏速率(MB/min) 0.8 42.1 0.0

自研连接治理中间件核心逻辑

我们基于golang.org/x/net/http2构建了轻量级SSE适配层,在http.ResponseWriter写入前注入状态机校验:

func (s *SSEMiddleware) WriteHeader(code int) {
    if s.eventState == eventIncomplete && code == http.StatusOK {
        // 强制关闭异常连接,避免goroutine滞留
        s.wrapped.CloseNotify()
        return
    }
    s.wrapped.WriteHeader(code)
}

故障根因的mermaid归因图

flowchart LR
A[客户端发送无data事件] --> B[Go stdlib未校验event完整性]
B --> C[goroutine阻塞在h2_bundle.go:4982]
C --> D[连接池无法回收fd]
D --> E[新Pod启动后重复消耗fd]
E --> F[系统级OOM Killer介入]

灰度发布验证策略

采用基于OpenTelemetry的渐进式流量切分:

  • 第一阶段:将1%生产流量路由至新SSE服务(启用--enable-event-validation
  • 第二阶段:当http_server_sse_event_validation_errors_total指标连续5分钟为0,提升至10%
  • 第三阶段:全量切换前执行混沌工程测试——向1000个连接注入event: test\nid: 123\n(无data行)

监控告警体系重构要点

新增3个关键Prometheus指标:

  • go_sse_connection_lifecycle_seconds_bucket{le="300"}(连接存活时长直方图)
  • http_sse_event_validation_failures_total{reason="missing_data"}(事件结构校验失败计数)
  • go_sse_goroutines_per_connection_ratio(每连接goroutine均值,阈值>1.2即告警)

生态工具链协同演进

我们向github.com/bradfitz/golang-net提交PR#127,修复http2serverConn.serve中对空事件帧的处理缺陷;同时将自研中间件开源为github.com/ourorg/go-sse-guardian,已集成至公司内部PaaS平台的标准SSE模板中。

客户端兼容性兜底方案

针对存量Android 8.0以下设备(WebView内核不支持EventSource.close()),我们在Nginx层配置:

location /sse/ {
    proxy_http_version 1.1;
    proxy_set_header Connection 'keep-alive';
    proxy_cache_bypass $http_upgrade;
    # 强制30秒心跳保活,规避老旧内核连接假死
    add_header X-Accel-Buffering no;
    add_header Cache-Control "no-cache";
}

记录 Go 学习与使用中的点滴,温故而知新。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注