Posted in

Go怎么发WebSocket长连接接口?从handshake到ping/pong心跳保活的13个细节

第一章:WebSocket长连接的本质与Go语言生态定位

WebSocket并非简单的HTTP升级协议,而是一种在单个TCP连接上实现全双工通信的底层网络协议。它通过HTTP/1.1的Upgrade机制完成握手,之后即脱离HTTP语义,以二进制帧或UTF-8文本帧直接收发数据,规避了传统轮询带来的延迟与开销。其核心价值在于维持持久、低开销、双向实时通道——这使其成为在线协作、实时通知、IoT设备管控等场景的基础设施选择。

WebSocket与HTTP的本质差异

维度 HTTP(短连接) WebSocket(长连接)
连接生命周期 请求-响应后立即关闭 握手后持续保持,可数小时
通信模式 单向请求驱动 双向主动推送
头部开销 每次携带完整Header 帧头仅2–14字节
服务端触发 依赖客户端轮询或Server-Sent Events 服务端可随时发送消息

Go语言对WebSocket的原生友好性

Go标准库虽未内置WebSocket支持,但net/http提供了完整的Upgrade能力,配合社区广泛采用的gorilla/websocket库,可快速构建高并发、内存可控的服务端。该库线程安全、零拷贝读写,并深度适配Go的context与goroutine模型:

import "github.com/gorilla/websocket"

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool { return true }, // 生产环境需校验Origin
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil) // 执行协议升级
    if err != nil {
        http.Error(w, "Upgrade failed", http.StatusBadRequest)
        return
    }
    defer conn.Close()

    // 启动读写协程,实现并发处理
    go func() { _, _ = conn.ReadMessage() }() // 处理客户端消息
    go func() { conn.WriteMessage(websocket.TextMessage, []byte("hello")) }() // 主动推送
}

Go的轻量级协程与高效调度器天然契合WebSocket连接密集型场景——单机轻松承载数万并发连接,且GC压力可控。在云原生与微服务架构中,Go已成为实时通信网关与边缘信令服务的首选语言。

第二章:WebSocket握手流程的Go实现细节

2.1 HTTP Upgrade协议解析与Go net/http底层机制

HTTP Upgrade 是一种协商式协议切换机制,常用于从 HTTP/1.1 升级至 WebSocket、HTTP/2(早期协商)或自定义协议。

协议握手关键字段

  • Connection: Upgrade
  • Upgrade: websocket
  • Sec-WebSocket-Key(WebSocket 场景)
  • 响应状态码必须为 101 Switching Protocols

Go 中的 Upgrade 处理路径

func handleUpgrade(w http.ResponseWriter, r *http.Request) {
    if !strings.Contains(r.Header.Get("Connection"), "Upgrade") ||
       !strings.Contains(r.Header.Get("Upgrade"), "websocket") {
        http.Error(w, "Upgrade required", http.StatusUpgradeRequired)
        return
    }

    // Hijack 连接,接管底层 TCP conn
    hijacker, ok := w.(http.Hijacker)
    if !ok {
        http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
        return
    }
    conn, _, err := hijacker.Hijack()
    if err != nil {
        return
    }
    defer conn.Close()

    // 此时可读写原始字节流(绕过 HTTP 解析)
    conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n" +
        "Connection: Upgrade\r\n" +
        "Upgrade: websocket\r\n\r\n"))
}

逻辑分析:Hijack() 释放 ResponseWriter 控制权,返回裸 net.Conn。参数说明:conn 为双向字节流;_ 是缓冲区状态(Go 1.22+ 已弃用);err 检查连接是否仍活跃。

Upgrade 生命周期对比

阶段 标准 HTTP 请求 Upgrade 后连接
协议层 HTTP 解析完成 原始字节流
超时控制 Server.ReadTimeout 有效 须手动管理
中间件介入 全链路生效 绕过所有中间件
graph TD
    A[Client: GET /ws] --> B[Server: Parse Headers]
    B --> C{Has Upgrade?}
    C -->|Yes| D[Hijack Conn]
    C -->|No| E[Normal HTTP Handler]
    D --> F[Raw I/O Loop]

2.2 客户端Handshake请求构造:Sec-WebSocket-Key与签名验证

WebSocket 握手始于客户端发起的 HTTP Upgrade 请求,其中 Sec-WebSocket-Key 是核心安全凭证。

Sec-WebSocket-Key 生成逻辑

客户端需生成一个16字节随机 Base64 编码字符串:

// 生成符合 RFC 6455 的 Sec-WebSocket-Key
const crypto = require('crypto');
const key = crypto.randomBytes(16).toString('base64'); // e.g., "dGhlIHNhbXBsZSBub25jZQ=="
console.log(`Sec-WebSocket-Key: ${key}`);

逻辑分析:randomBytes(16) 确保密码学安全熵;Base64 编码严格遵循 RFC 4648(无填充截断、URL安全字符集未启用)。服务端将此值与固定魔数 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接后计算 SHA-1,并再次 Base64 编码,生成 Sec-WebSocket-Accept 值完成双向验证。

验证流程关键点

  • 服务端不得校验 Key 内容,仅校验其格式(长度24、合法 Base64 字符、无换行)
  • 多次重用同一 Key 将导致握手失败(违反唯一性要求)
阶段 输入 输出 验证方
客户端生成 CSPRNG 16B Base64 字符串 —
服务端签名 Key + GUID SHA-1 → Base64 服务端
graph TD
    A[客户端生成16B随机数] --> B[Base64编码]
    B --> C[构造Sec-WebSocket-Key头]
    C --> D[发送HTTP Upgrade请求]
    D --> E[服务端拼接GUID并SHA-1]
    E --> F[Base64编码得Accept值]

2.3 服务端响应生成:Sec-WebSocket-Accept计算与Header设置

WebSocket 握手成功的关键在于服务端精确生成 Sec-WebSocket-Accept 值,并设置必需的响应头。

核心计算逻辑

该值由客户端 Sec-WebSocket-Key 拼接固定字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 后,进行 SHA-1 哈希并 Base64 编码:

import hashlib, base64

key = "dGhlIHNhbXBsZSBub25jZQ=="  # 客户端提供的 key
accept_key = base64.b64encode(
    hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
).decode()
# → "s3pPLMBiTxaQ9kYGzzhDrcatmNM="

逻辑说明:SHA-1 输出 20 字节二进制,Base64 编码后为 28 字符 ASCII 字符串;任意篡改 key 或常量都会导致校验失败,保障握手完整性。

必需响应头清单

  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhDrcatmNM=
  • Sec-WebSocket-Version: 13

握手验证流程

graph TD
    A[收到Upgrade请求] --> B[提取Sec-WebSocket-Key]
    B --> C[拼接GUID+SHA-1+Base64]
    C --> D[构造响应Header]
    D --> E[返回101 Switching Protocols]

2.4 基于gorilla/websocket的握手异常捕获与重试策略

WebSocket 握手失败是生产环境中高频问题,常见于网络抖动、服务端限流或 TLS 协商超时。gorilla/websocket 默认仅返回 *websocket.Conn 或 error,但原始错误缺乏上下文,需主动增强可观测性。

错误分类与重试决策

握手异常可分为三类:

  • 临时性错误(如 net.OpError, i/o timeout)→ 可重试
  • 永久性错误(如 401 Unauthorized, 403 Forbidden)→ 终止重试
  • 协议错误(如 bad handshake)→ 检查客户端 URL 或服务端配置

带退避的重试实现

func dialWithRetry(u string, maxRetries int) (*websocket.Conn, error) {
    var conn *websocket.Conn
    var err error
    for i := 0; i <= maxRetries; i++ {
        conn, _, err = websocket.DefaultDialer.Dial(u, nil)
        if err == nil {
            return conn, nil
        }
        if !isTransientError(err) { // 判定是否可重试
            return nil, err
        }
        if i < maxRetries {
            time.Sleep(time.Second << uint(i)) // 指数退避:1s, 2s, 4s...
        }
    }
    return nil, fmt.Errorf("dial failed after %d attempts: %w", maxRetries, err)
}

逻辑分析:websocket.DefaultDialer.Dial 返回连接、HTTP响应头和错误;isTransientError 应基于 errors.Is(err, context.DeadlineExceeded) 或 strings.Contains(err.Error(), "timeout") 实现;指数退避避免雪崩,<< uint(i) 精确计算 2^i 秒。

重试策略对比

策略 适用场景 风险
固定间隔重试 网络短暂抖动 可能加剧拥塞
指数退避 高并发+不稳定网络 延迟上升可控
jitter 退避 分布式集群 避免重试同步风暴
graph TD
A[发起 Dial] --> B{握手成功?}
B -- 是 --> C[返回 Conn]
B -- 否 --> D[解析错误类型]
D --> E[临时错误?]
E -- 是 --> F[等待退避时间]
F --> A
E -- 否 --> G[返回原始错误]

2.5 自定义TLS握手配置与SNI支持下的安全连接建立

SNI在多租户场景中的关键作用

服务器名称指示(SNI)使单IP可托管多个HTTPS站点,客户端在ClientHello中明文携带目标域名,避免证书不匹配错误。

自定义TLS握手的典型配置项

  • MinVersion/MaxVersion:限定TLS协议版本(如TLS 1.2–1.3)
  • CurvePreferences:指定ECDHE曲线(X25519, P-256)提升前向安全性
  • NextProtos:协商ALPN协议(h2, http/1.1)

Go语言客户端配置示例

cfg := &tls.Config{
    ServerName:         "api.example.com",
    InsecureSkipVerify: false, // 生产环境必须禁用
    NextProtos:         []string{"h2", "http/1.1"},
}

ServerName 触发SNI扩展;NextProtos 影响HTTP/2协商成败;InsecureSkipVerify=false 强制证书链校验,防止中间人攻击。

TLS握手流程示意

graph TD
    A[ClientHello<br/>含SNI、SupportedGroups] --> B[ServerHello<br/>选密钥交换参数]
    B --> C[Certificate<br/>含匹配SNI的证书]
    C --> D[Finished<br/>密钥确认]
配置项 推荐值 安全影响
MinVersion tls.VersionTLS12 阻断SSLv3/TLS1.0降级攻击
CurvePreferences [X25519, P256] 避免NIST曲线潜在后门

第三章:长连接生命周期管理的核心实践

3.1 连接建立后的状态同步与上下文绑定

连接建立后,客户端与服务端需确保会话上下文一致,避免状态漂移。

数据同步机制

采用增量快照 + 变更日志双轨同步:

def bind_context(conn_id: str, session_token: str) -> dict:
    # conn_id:唯一连接标识;session_token:JWT签发的会话凭证
    # 返回绑定后的上下文元数据,含租户ID、权限策略版本、最后同步TS
    return {
        "context_id": f"ctx_{hashlib.md5((conn_id+session_token).encode()).hexdigest()[:8]}",
        "tenant_id": decode_jwt(session_token)["tid"],
        "policy_ver": get_latest_policy_version(),
        "sync_ts": int(time.time() * 1000)
    }

该函数生成幂等上下文ID,并将租户隔离、策略版本与时间戳绑定,为后续状态一致性校验提供锚点。

同步关键字段对照表

字段名 来源 作用 是否可变
context_id 服务端生成 全局唯一上下文标识 否
tenant_id JWT payload 多租户隔离依据 否
policy_ver 策略中心API 决策规则版本控制 是(热更新)

状态绑定流程

graph TD
    A[连接就绪] --> B[验证session_token签名]
    B --> C[解析租户与权限声明]
    C --> D[加载对应上下文快照]
    D --> E[比对本地变更日志LSN]
    E --> F[拉取缺失delta并重放]
    F --> G[标记context为active]

3.2 并发读写模型:goroutine+channel协作模式设计

核心协作范式

Go 中最自然的并发读写协同方式是「生产者-消费者」解耦:写协程向 channel 发送数据,读协程从中接收,天然规避锁竞争。

数据同步机制

使用带缓冲 channel 控制读写节奏,避免 goroutine 阻塞:

// 定义容量为10的通道,支持异步写入与批量读取
ch := make(chan int, 10)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i * 2 // 非阻塞写入(缓冲未满)
    }
    close(ch) // 写端关闭,通知读端终止
}()

for val := range ch { // 自动阻塞等待,读完自动退出
    fmt.Println("read:", val)
}

逻辑分析:make(chan int, 10) 创建有界缓冲通道,写操作仅在缓冲满时阻塞;close(ch) 向读端广播“无新数据”,range 语义确保安全遍历。参数 10 平衡内存占用与吞吐延迟。

协作模式对比

模式 线程安全 耦合度 流控能力 适用场景
sync.Mutex + 共享变量 是 高 弱 简单状态更新
chan + goroutine 是 低 强 生产/消费流水线
graph TD
    A[Writer Goroutine] -->|发送数据| B[Buffered Channel]
    B -->|接收数据| C[Reader Goroutine]
    C --> D[处理结果]

3.3 连接关闭时的资源清理与优雅退出信号传递

当网络连接终止时,未释放的文件描述符、内存缓存或活跃协程将导致资源泄漏。关键在于建立信号驱动的清理链路。

清理生命周期钩子

  • on_close() 触发资源回收(socket、buffer、timer)
  • on_exit() 向上游服务广播 SIGTERM 事件
  • 所有异步任务需响应 context.Done() 通道

Go 示例:带上下文的优雅关闭

func handleConnection(conn net.Conn, ctx context.Context) {
    defer conn.Close() // 确保底层连接关闭
    go func() {
        <-ctx.Done() // 监听取消信号
        log.Println("Graceful shutdown initiated")
        conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
    }()
}

逻辑分析:conn.SetReadDeadline 强制读操作在 100ms 内超时退出,避免阻塞;defer conn.Close() 保证无论是否异常都执行释放;ctx.Done() 是协调多 goroutine 退出的核心信令。

阶段 动作 超时阈值
通知 广播 SIGTERM 500ms
清理 关闭 socket、释放 buffer 200ms
等待 协程自然退出 1s
graph TD
    A[连接关闭事件] --> B[触发 on_close]
    B --> C[释放 fd & buffer]
    B --> D[发送 SIGTERM]
    D --> E[上游服务降级]
    C --> F[返回 exit code 0]

第四章:Ping/Pong心跳保活机制的工程化落地

4.1 RFC 6455规范下Ping帧结构与Go二进制编码实践

WebSocket Ping帧是控制帧,用于心跳检测,其结构严格遵循RFC 6455:首字节为0x89(FIN=1, opcode=PING),次字节含掩码位与有效载荷长度,后续可选4字节掩码及最多125字节应用数据。

帧格式核心字段

  • FIN: 必置1(控制帧不可分片)
  • Opcode: 0x09(PING)
  • Payload length: 0–125(RFC禁止扩展长度字段用于Ping)

Go编码实现

func buildPingFrame(payload []byte) []byte {
    if len(payload) > 125 {
        panic("ping payload exceeds 125 bytes")
    }
    frame := make([]byte, 2+len(payload))
    frame[0] = 0x89 // FIN + PING opcode
    frame[1] = byte(len(payload)) // no mask, no extended length
    copy(frame[2:], payload)
    return frame
}

逻辑说明:0x89固定标识Ping帧;frame[1]直接写入长度(因≤125,跳过扩展长度字段);无掩码(服务端发Ping时mask bit = 0),符合RFC强制要求。

字段 值 说明
FIN+Opcode 0x89 单帧、PING类型
Payload Len 0x00–0x7D 显式长度,无扩展字段

graph TD A[构造Ping帧] –> B[设置FIN+Opcode=0x89] B –> C[写入payload长度到byte1] C –> D[追加payload字节] D –> E[返回完整二进制帧]

4.2 自适应心跳间隔算法:基于RTT与网络抖动的动态调节

传统固定心跳(如30s)在高抖动或弱网场景下易引发误判。本算法实时采集端到端RTT及Jitter(标准差),动态计算最优心跳间隔:

def calc_heartbeat_interval(rtt_ms: float, jitter_ms: float) -> float:
    # 基线:RTT × 2,确保至少一次往返确认
    base = max(1000.0, rtt_ms * 2)
    # 抖动放大因子:jitter越大,间隔越长(防雪崩)
    factor = 1.0 + min(2.0, jitter_ms / 50.0)
    return min(30000.0, base * factor)  # 上限30s,防连接空闲超时

逻辑说明:rtt_ms为最近5次平滑RTT(单位毫秒),jitter_ms为对应RTT序列的标准差;base保障最小探测窗口,factor引入抖动敏感度,min()兜底避免过长间隔。

关键参数影响关系:

参数 变化趋势 心跳间隔响应
RTT ↑ 线性增长 间隔↑
Jitter ↑ 非线性增长 间隔显著↑
RTT↓且Jitter↓ 双向收敛 间隔趋近1s下限

动态调节流程

graph TD
    A[采集RTT/Jitter] --> B{是否连续3次Jitter > 100ms?}
    B -->|是| C[启用保守模式:interval × 1.5]
    B -->|否| D[执行calc_heartbeat_interval]
    C --> E[上报网络质量降级事件]
    D --> F[更新心跳定时器]

4.3 客户端超时检测与自动重连状态机实现

客户端长连接的健壮性依赖于精准的超时判定与可恢复的状态迁移。核心在于将网络不确定性建模为有限状态机(FSM),而非简单轮询重试。

状态定义与迁移约束

支持五种状态:Idle → Connecting → Connected → Disconnecting → Reconnecting。非法跳转(如 Connected 直接到 Idle)被 FSM 引擎拦截。

超时检测机制

采用双层心跳策略:

  • 应用层心跳(ping/pong,间隔 15s,超时阈值 45s)
  • TCP Keepalive(OS 级,tcp_keepalive_time=600s)
// 状态机核心迁移逻辑(简化版)
const stateMachine = new FSM({
  initial: 'Idle',
  states: {
    Idle: { on: { CONNECT: 'Connecting' } },
    Connecting: { 
      on: { 
        CONNECTED: 'Connected', 
        TIMEOUT: 'Reconnecting' 
      },
      after: { '3000': 'TIMEOUT' } // 连接建立超时3秒
    }
  }
});

逻辑说明:after: { '3000': 'TIMEOUT' } 表示进入 Connecting 状态后 3000ms 未收到 CONNECTED 事件则触发超时迁移;TIMEOUT 是预定义事件名,非字符串字面量。

重连退避策略

尝试次数 基础延迟 最大抖动 实际延迟范围
1 100ms ±20ms 80–120ms
3 800ms ±100ms 700–900ms
5+ 4s ±500ms 3.5–4.5s
graph TD
  A[Connecting] -->|success| B[Connected]
  A -->|TIMEOUT| C[Reconnecting]
  C --> D[Backoff Delay]
  D --> E[Connecting]
  B -->|network loss| C

重连失败后进入指数退避 + 随机抖动,避免雪崩式重连请求。

4.4 服务端心跳响应延迟监控与连接健康度评分机制

心跳延迟采集逻辑

服务端在接收客户端 HEARTBEAT 请求时,记录 request_time,并在响应前注入 server_process_ms(处理耗时)与 response_time(系统纳秒时间戳),客户端据此计算端到端延迟:

# 客户端计算单次心跳延迟(ms)
rtt_ms = (response_time_ns - request_time_ns) // 1_000_000
server_latency_ms = int(headers.get("X-Server-Process-Ms", "0"))

逻辑说明:response_time_ns - request_time_ns 消除了客户端时钟漂移影响;X-Server-Process-Ms 由服务端精确埋点,剥离网络传输抖动,专注评估服务端自身负载压力。

健康度评分维度

健康度 score ∈ [0, 100] 由三因子加权:

  • 延迟分(权重 50%):基于滑动窗口 P95 延迟映射至 0–60 分
  • 连续成功数(权重 30%):最近 10 次心跳全成功得满分
  • 断连恢复耗时(权重 20%):重连后首次心跳耗时越短得分越高
指标 阈值区间 得分映射
P95 延迟(ms) ≤50 60
50–200 线性衰减至 20
>200 0

动态评分更新流程

graph TD
A[收到心跳响应] --> B{是否超时?}
B -- 是 --> C[降分 + 记录断连]
B -- 否 --> D[更新延迟滑窗 & 成功计数]
D --> E[计算三项子分]
E --> F[加权合成健康度 score]

第五章:生产环境调优与典型故障排查指南

高并发场景下的JVM内存泄漏定位实战

某电商大促期间,订单服务频繁Full GC且响应延迟飙升至2.8s。通过jstat -gc <pid>持续观测发现老年代使用率每小时增长15%,结合jmap -histo:live <pid> | head -20定位到com.example.order.OrderContextCache实例数达127万且持续增长。进一步用jstack <pid>确认该缓存被静态Map强引用,且未实现LRU淘汰机制。最终通过引入Caffeine缓存并配置maximumSize(10000)与expireAfterWrite(10, TimeUnit.MINUTES)修复。

Nginx upstream timeout引发的雪崩式超时

线上API网关出现大量504错误,日志显示upstream timed out (110: Connection timed out)。检查Nginx配置发现:

upstream backend {
    server 192.168.1.10:8080 max_fails=3 fail_timeout=30s;
}
location /api/ {
    proxy_connect_timeout 5s;
    proxy_send_timeout 10s;
    proxy_read_timeout 10s;  # 问题根源:后端实际处理需15s
}

将proxy_read_timeout调整为25s并增加proxy_next_upstream error timeout http_500 http_502 http_503 http_504后故障消除。

数据库连接池耗尽的根因分析表

现象 检查命令 关键指标 合理阈值
连接数持续增长 show status like 'Threads_connected' Threads_connected ≤ max_connections × 0.8
等待连接超时 SELECT * FROM information_schema.PROCESSLIST WHERE STATE='Waiting for table metadata lock' 等待线程数
连接泄漏痕迹 SHOW ENGINE INNODB STATUS\G TRANSACTIONS中ACTIVE时间>300s 0

Kubernetes Pod频繁OOMKilled诊断流程

graph TD
    A[Pod状态为OOMKilled] --> B{检查容器内存限制}
    B -->|limit=512Mi| C[执行kubectl top pod --containers]
    C --> D[发现java进程RSS达620Mi]
    D --> E[验证JVM堆外内存占用]
    E --> F[jcmd <pid> VM.native_memory summary]
    F --> G[确认DirectByteBuffer分配超200Mi]
    G --> H[添加-XX:MaxDirectMemorySize=128m参数重启]

Kafka消费者组滞后突增的三步定位法

  1. 执行kafka-consumer-groups.sh --bootstrap-server broker1:9092 --group order-processor --describe获取LAG值;
  2. 发现partition-7 LAG达120万,检查该分区leader所在broker磁盘IO等待时间(iostat -x 1 | grep nvme0n1)达120ms;
  3. 登录对应broker执行lsof -p <kafka_pid> | wc -l发现打开文件数达65421,超出系统ulimit 65536限制,调整fs.file-max后恢复。

Redis缓存穿透防护失效案例

用户中心接口QPS骤降至300(正常值8000),监控显示Redis CPU使用率仅12%但后端DB CPU达98%。抓包发现大量GET user:1000000001请求(无效ID)。原防护方案仅依赖布隆过滤器,但未校验布隆过滤器本身是否加载完成——启动脚本中redis-cli set bloom:ready 1被放在过滤器构建之后,而应用启动时已开始接收流量。修复方案:在Spring Boot ApplicationRunner中阻塞等待GET bloom:ready == "1"后再开放HTTP端口。

生产环境GC日志解析关键字段速查

  • 2023-10-15T08:22:14.123+0800: 123456.789 → JVM启动后秒数,用于计算停顿时间跨度
  • [GC (Allocation Failure) ... ] → 触发原因,Allocation Failure表示年轻代空间不足
  • [PSYoungGen: 819200K->12345K(819200K)] → 年轻代回收前/后/总容量(KB)
  • [ParNew: 819200K->12345K(819200K), 0.1234567 secs] → 停顿时间精确到纳秒
  • [Times: user=0.45 sys=0.02, real=0.12 secs] → real时间即STW时长,必须≤SLA要求

网络丢包导致的分布式事务不一致

支付服务调用账务服务返回UNKNOWN_ERROR,但账务侧日志显示事务已提交。抓取双方服务器tcpdump发现:

  • 支付方发出FIN-ACK后未收到账务方响应
  • 账务方TCP重传超时后关闭连接,但本地事务已commit
  • 最终通过在账务服务增加@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class) + 全局唯一事务ID幂等校验兜底。

浪迹代码世界,寻找最优解,分享旅途中的技术风景。

发表回复

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