分类: 架构设计

  • 深入 Seata AT 陷阱排查:@GlobalTransactional 滥用引发的全局锁竞争与 DB 连接池耗尽雪崩实战

    近期,核心交易链路在一次大促压测起步阶段便遭遇惨烈雪崩。订单服务接口 P99 延迟从 50ms 瞬间飙升至 8000ms,随后触发熔断,上游网关出现大量 503 HTTP 状态码。排查结论非常明确:业务研发在 C 端高并发下单接口上,极其粗暴地滥用了 Seata AT 模式(一种 2PC 的变体)。他们在包裹了三次跨网络 RPC 调用的上帝方法上打了一个 @GlobalTransactional 注解,导致本地 DB 连接被长时间挂起,加上 Seata TC(Transaction Coordinator)端的全局锁竞争,瞬间打穿了 HikariCP 连接池,最终引发微服务级联崩溃。

    分布式事务领域没有银弹,把 2PC 思想的框架当成本地 @Transactional 盲目使用,是对系统稳定性的严重破坏。

    案发现场:连接池耗尽与死锁报警

    排查过程中,订单服务最先弹出的核心告警是 DB 连接池被打满。查看应用日志,满屏都是 HikariCP 的获取连接超时异常:

    java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30005ms.
        at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:695)
        at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197)
    

    同时,MySQL 侧监控显示 Threads_running 飙升,通过 show engine innodb status\G 查看,发现大量事务处于 LOCK WAIT 状态。

    再去扒 Seata TC Server(我们部署的是集群模式,DB 存储)的日志,发现了大量全局锁获取失败的报错:

    [timeoutChecker_1] ERROR io.seata.server.coordinator.DefaultCore - Global lock acquire failed, xid: 192.168.1.10:8091:123456789, branchId: 987654321
    

    这一切的元凶,指向了订单服务中的一段“经典”代码:

    @GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
    @Transactional
    public Order createOrder(OrderDTO dto) {
        // 1. 本地 DB 扣减库存 (热点商品)
        inventoryMapper.decrease(dto.getSkuId(), dto.getQty());
        // 2. RPC 调用:扣减积分
        pointClient.decrease(dto.getUserId(), dto.getPoints());
        // 3. RPC 调用:核销优惠券
        couponClient.use(dto.getCouponId());
        // 4. 本地 DB 创建订单
        orderMapper.insert(order);
        return order;
    }
    

    致命的架构逻辑缺陷

    在微服务架构下,写出上面这种代码是不可原谅的。这不仅暴露了对分布式事务底层机制的无知,更是直接违背了“防御性编程”中关于资源控制的基本原则。

    1. 连接池被强行劫持(The I/O Trap) 在 Seata AT 模式的一阶段(Phase 1),业务数据和回滚日志(undo_log)必须在同一个本地事务中提交。当方法被 @Transactional 和 @GlobalTransactional 同时修饰时,Spring 会在方法一开始就从 HikariCP 借出一个 DB 连接。 随后发生的 pointClient 和 couponClient 是跨网络 RPC 调用。假设每个 RPC 耗时 200ms,这个 DB 连接就会被白白挂起至少 400ms,期间不执行任何实质性的 DB 读写。如果在 2000 QPS 的并发下,一个最大连接数为 200 的 HikariCP 连接池,不到 1 秒钟就会被彻底耗尽。

    2. 全局锁与本地行锁的双重绞杀(Lock Contention) Seata AT 为了保证隔离性(写隔离),在本地事务提交前,必须向 TC 注册分支事务并获取全局锁(Global Lock)。 在上述代码中,如果 dto.getSkuId() 是一个热点商品(例如秒杀),本地 MySQL 已经对该 SKU 所在的行加上了独占锁(X锁)。接着,应用带着这个状态去向 TC 申请该记录的全局锁。如果此时 RPC 调用出现哪怕一点网络抖动,或者 TC 端处理能力到达瓶颈,本地事务就会一直处于 WAIT 状态,死死抱着 MySQL 的行锁不放。这不仅会导致当前分布式事务超时,更会阻塞所有针对该热点 SKU 的普通更新操作,最终演变成 DB 层的锁雪崩。

    拆解与重构:抛弃 2PC,走向补偿与最终一致性

    对于 C 端高并发主链路,2PC(包括基于 2PC 演进的 AT 模式)永远是反模式。2PC 的同步阻塞特性决定了它只适合后台管理系统的低并发、强一致场景。

    面对这个烂摊子,我们在现场果断勒令研发团队进行重构,砍掉 Seata AT,改用 本地消息表(Outbox Pattern)+ 消息队列 的最终一致性方案,部分强依赖链路降级为 TCC(Try-Confirm-Cancel) 模式。

    方案一:RocketMQ 事务消息(最终一致性) 核心思路是把分布式事务降维打击成单机事务。 将扣减库存和创建订单放在一个本地事务中,利用 RocketMQ 的事务消息机制,确保本地 DB 提交和 MQ 消息发送的原子性。下游的积分和优惠券服务通过订阅 MQ 进行异步扣减。这种方式将跨服务的强一致解耦为最终一致,彻底释放了 DB 连接池压力。

    方案二:TCC 模式的工程取舍 如果某些业务场景强依赖严格的资源预留(例如不超卖、不超发),我们采用 TCC 模式。但在落地 TCC 时,必须在框架层死磕以下三个防御性设计:

    1. 防空回滚:Cancel 阶段被触发时,如果 Try 阶段由于网络延迟根本没执行,Cancel 逻辑必须识别并直接返回成功。我们在 TCC 事务控制表里增加一个状态字段,未找到 Try 记录的 Cancel 视为“空回滚”。

    2. 防悬挂:Cancel 接口比 Try 接口先到达(网络乱序)。应对方案是 Cancel 执行时不仅要判断是否空回滚,还要在控制表中插入一条“已回滚”记录。当迟到的 Try 请求到达时,发现该记录已经存在,则直接拒绝执行。

    3. 幂等性:Confirm 和 Cancel 极大概率会被重试,业务表必须引入 tx_id 唯一约束,确保重复请求被拦截。

    排查清单与避坑指南

    针对分布式事务(2PC / Saga / TCC)的落地实践,建议梳理以下排查清单,供排错和架构 Review 时对照:

    1. DB 连接泄漏审查:排查所有标注了分布式事务注解(如 @GlobalTransactional、@Compensable)的方法,严禁在事务上下文中包裹长耗时的外部 I/O 操作(RPC/HTTP 调用、耗时计算)。

    2. 锁超时配置对齐:必须确保 MySQL innodb_lock_wait_timeout > Seata client rm.lock-retry-timeout。否则会导致 MySQL 本地事务先于 Seata 释放锁,从而产生脏写(Dirty Write),破坏 undo_log,引发二阶段 Rollback 崩溃。

    3. TCC 防御性控制表校验:确认 TCC 参与者服务中是否实现了标准的事务状态表。必须同时包含 xid, branch_id, status 字段,并在 Try/Confirm/Cancel 阶段对其使用 select ... for update 或基于唯一索引进行状态机流转。

    4. TC 高可用与存储瓶颈:如果仍在使用 Seata AT,检查 TC Server 的存储模式。对于生产环境,坚决弃用 File 模式,改用 DB 或 Redis 模式,并监控 TC 端的 global_table 和 branch_table 的行数,防范僵尸事务堆积。

  • 深入 Redis 陷阱排查:RDB bgsave 触发内存淘汰风暴与 Gossip 协议假死引发的集群雪崩实战

    结论先行:Redis 集群在执行 RDB bgsave 时若伴随高并发写入,极易引发严重 Copy-on-Write(CoW)导致内存飙升。一旦触及 maxmemory 阈值并触发同步淘汰(Eviction)风暴,将长时间阻塞主线程。这不仅会导致业务请求响应耗时(99线)飙升至秒级,更会引发 Cluster Gossip 协议的 Ping/Pong 响应超时,最终触发集群误判节点下线与无意义的主从切换(Failover),造成全局雪崩。

    案发现场:诡异的 P99 尖刺与主从频繁切换

    某次排查过程中,监控大盘发出严重告警。核心 Redis 集群(版本 6.2.6,3主3从架构)的 API 99线从平时的 2ms 瞬间飙升至 4500ms。与此同时,DBA 团队收到多个节点的主从切换告警通知。

    登录其中一台发生切换的原主节点,查看 Redis 日志,发现大量如下报错:

    7892:M 15:32:11.102 * Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.
    7892:M 15:32:16.455 # Connection with replica 10.x.x.5:6379 lost.
    7892:M 15:32:20.123 # Cluster state changed: fail
    7892:M 15:32:25.881 # Marking node 9b3d... as failing (quorum reached).
    

    直觉判断是磁盘 IO 瓶颈或是网络抖动,但排查底层系统指标发现:

    1. iostat -x 1 显示磁盘 util 虽然达到了 70%,但并未完全打死,await 也在合理范围内。

    2. 节点间的网络 ping 延迟极低,无丢包。

    进一步通过 redis-cli 提取案发时间段的内核和内存指标:

    redis-cli -p 6379 info stats | grep evicted
    # 结果显示 evicted_keys 在几秒钟内增加了近 40万。
    
    redis-cli -p 6379 info persistence | grep -E "rdb_last_bgsave|latest_fork_usec"
    # rdb_last_bgsave_status:ok
    # rdb_last_bgsave_time_sec: 18
    # latest_fork_usec: 24500
    

    至此,线索闭环:这是一起典型的由 RDB 快照引发的内存暴涨,继而触发淘汰机制阻塞主线程,最终击穿 Gossip 协议导致集群脑裂的惨案。

    为什么 RDB Fork 会触发内存淘汰风暴并导致 Gossip 假死?

    很多研发认为 Redis 是单线程的,且 RDB 是通过 bgsave 在后台子进程完成的,不会影响主进程。这是一个极其危险的误区。

    1. Copy-on-Write (CoW) 带来的内存刺客 当 Redis 执行 bgsave 时,主进程会调用 Linux 的 fork() 系统调用创建子进程。利用操作系统的 CoW 机制,父子进程初始共享同一块物理内存。但如果此时业务端有大量的写请求(SET/HSET 等),主进程在修改数据前,必须先将原有内存页(通常是 4KB,如果开启了 THP 则是 2MB)复制一份。 此时,Redis 实例的实际物理内存占用量 = 现有数据大小 + CoW 复制的页大小。如果写入极为频繁,内存占用会在短时间内急速飙升,直接撞上 maxmemory 限制。

    2. 同步淘汰(Eviction)风暴阻塞主线程 当内存触及 maxmemory,Redis 会根据配置的 maxmemory-policy(如 allkeys-lru)开始清理内存。 在 Redis 6.2 默认配置下,如果没有开启懒释放(lazyfree-lazy-eviction yes),内存淘汰操作是在主线程中同步执行的。如果 CoW 导致的内存超发极大,Redis 需要在一个事件循环周期内强制淘汰数十万个 Key。寻找 LRU 目标、解除哈希表映射、释放内存,这一整套动作将主线程完全卡死。

    3. Gossip 协议假死与雪崩 Redis Cluster 维持高可用依赖于 Gossip 协议。每个节点通过主线程的 clusterCron() 函数(默认每 100ms 运行一次)向其他节点发送 Ping,并处理 Pong。 当主线程被淘汰风暴卡死长达数秒时,clusterCron() 根本无法获得执行机会:

    • 节点无法响应其他节点的 Ping 报文。

    • 其他节点在超过 cluster-node-timeout(默认 15000ms,部分激进配置可能设为 5000ms)未收到响应后,会将该节点标记为 PFAIL(疑似下线)。

    • 随后通过 Gossip 传播,集群半数以上主节点确认该节点失联,状态升级为 FAIL,强制触发 Replica 提主流程(Failover)。

    由于主从切换,客户端连接断开重连,引发缓存短暂不可用,流量直接打穿到 DB,最终演变为全局雪崩。

    防御性加固与最佳实践

    不要指望业务侧降低并发来适应底层,运维架构的底线是通过系统性配置兜底。针对此陷阱,需实施以下加固:

    1. 预留足够的内存 Buffer (绝对铁律) 严禁将 maxmemory 设置为机器物理内存的极限。标准做法是:maxmemory 绝不能超过系统可用内存的 70%。如果实例承载重度写入,甚至需要降至 50%-60%,专门为 RDB 的 CoW 留出 Buffer,避免触发淘汰。

    2. 强制开启 Lazyfree 异步淘汰 从 Redis 4.0 开始引入了异步释放,但在 6.x 版本中淘汰策略默认仍是阻塞的。必须在 redis.conf 中明确开启:

    # 开启异步内存淘汰,避免阻塞主线程
    lazyfree-lazy-eviction yes
    # 对于大 Key 的 DEL 操作也建议走异步 (UNLINK 代替 DEL)
    lazyfree-lazy-user-del yes
    

    3. 审视系统内核参数 THP Transparent Huge Pages (THP) 是内存杀手。开启 THP 后,内存页大小从 4KB 变为 2MB。这意味着即使只修改了 10 个字节的数据,CoW 也要复制整个 2MB 的内存页,导致内存碎片和消耗速度剧增 500 倍。 强制关闭:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    

    4. 调整 Cluster 容忍度 不要把 cluster-node-timeout 设置得过小。如果网络环境非极度苛刻,保持默认的 15000ms 即可。过小(如 3000ms)会导致极易因为一次大 Key 的删除或短暂的 IO 抖动引发误切换。

    cluster-node-timeout 15000
    

    常见问题 (FAQ)

    Q1:为什么我们在监控上看到系统的总剩余内存(Free)还有很多,但 Redis 依然触发了 Eviction? 因为 Redis 触发淘汰只看内部配置的 maxmemory 阈值,与宿主机的剩余物理内存无关。即使机器有 128G 内存,如果 maxmemory 设置为 10G,一旦 Redis 自己计算的内存使用量(包含数据、客户端缓冲区等,但不包括 CoW 子进程消耗)超过 10G,就会开始无情淘汰。

    Q2:如何准确监控 RDB 执行期间 Copy-on-Write 消耗的内存大小? 可以通过解析 Redis 日志获取,每次 bgsave 结束后,Redis 会打印一行日志: Background saving terminated with success 同时在 INFO STATS 中的 latest_fork_usec 可以看到 fork 耗时。但最精准的监控方式是在 bgsave 期间查看 /proc//smaps 中的 Private_Dirty 字段增长量,或者在 bgsave 结束后直接查看 Redis 日志中输出的 RDB: XX MB of memory used by copy-on-write 核心提示。

    Q3:为了避免这种问题,是否可以在集群模式下彻底关闭 RDB,只用 AOF? 不建议彻底关闭 RDB。虽然全量同步可以通过无盘复制(diskless replication)缓解,但新节点加入或严重断网后的重同步依然依赖 RDB 快照机制生成。更好的做法是控制快照生成的频率(取消过于频繁的 save m n 自动触发条件),将备份操作通过定时任务强制调度到业务低峰期执行,并确保 lazyfree 和足够的内存水位。

  • 深入异地多活陷阱排查:GSLB 探针脑裂引发的流量震荡与 MySQL 双写冲突实战

    异地多活的核心不是简单部署两套集群,而是严格的故障域隔离与流量染色。某次处理跨区双活故障时,边缘 GSLB 探针因专线网络抖动发生“脑裂”,导致单租户流量被同时打入双机房。配合底层 MySQL 异步双向同步的秒级延迟,直接引发大面积主键冲突与脏写。结论:多活架构必须在网关层实现强单元化路由,且 DB 层需引入基于 DC 标识的防冲突发号器,绝不能仅依赖全局 DNS 调度。

    故障现场:P99 飙升与主键雪崩

    排查期间,监控大盘突然出现剧烈波动:

    1. API 网关指标:核心业务写接口 P99 延迟从 45ms 飙升至 5s 以上,5xx 错误率瞬间击穿 15% 的 SLA 阈值。

    2. DB 负载:双机房的 MySQL (8.0.28) 实例 Load Average 均出现异常冲高,CPU Sys 态占比激增。

    3. 同步链路:基于 Canal/Otter 搭建的异地双向同步链路大面积告警,同步延迟从 500ms 扩大至 NaN(同步中断)。

    登录双边机房的数据库机器,直接查看 MySQL 错误日志,满屏的 1062 报错触目惊心:

    [ERROR] [MY-010584] [Repl] Slave SQL for channel 'dc2_to_dc1': Worker 1 failed executing transaction 'b9f8a3c2-11e9-11ed-a8b3-00163e0020a1:1593452'; Error 'Duplicate entry '89341093841' for key 'orders.PRIMARY'' on query. Default database: 'trade_db'. Query: 'INSERT INTO orders (order_id, user_id, status) VALUES (89341093841, 10045, 1)'
    

    从日志看,DC1 和 DC2 几乎在同一时间对同一张表插入了相同 order_id 的记录。当 DC2 的 Binlog 异步同步到 DC1 时,由于主键已存在,导致 SQL 线程直接挂起,复制中断。

    为什么 GSLB 会在物理机房之间产生流量“抽搐”?

    业务原本的设计是:基于 GSLB(全局负载均衡)做就近路由,华南用户解析到 DC1,华北用户解析到 DC2。当某机房故障时,GSLB 将域名切到可用机房。

    但仔细分析 Nginx Access Log 发现,同一个客户端 IP,在短短 3 秒内,请求分别落在了 DC1 和 DC2 的网关上。这是典型的流量“抽搐”,而非正常的机房切换。

    深入排查 GSLB 探针的运行日志与配置:

    # GTM Health Check Probe Config
    health_check:
      protocol: tcp
      port: 443
      timeout: 2s
      interval: 3s
      healthy_threshold: 2
      unhealthy_threshold: 3
    

    底层原理与诱因剖析: GSLB 的健康检查依赖分布在全国的多个边缘探针节点。事发时,DC1 所在机房的 BGP 出口发生了路由收敛抖动(持续约十几秒)。

    1. 探针脑裂:部分电信探针节点在 3s * 3 = 9s 内无法建连 DC1,判定 DC1 宕机,向全局调度中心上报故障,DNS 解析开始剔除 DC1,将该区域流量指向 DC2。

    2. 联通/移动探针正常:而联通/移动的探针由于走不同的骨干网路由,依然判定 DC1 存活。

    3. LocalDNS 缓存混战:由于各省 LocalDNS 的 TTL 缓存失效时间不一致(部分运营商甚至无视我们设置的 60s TTL,强行缓存 10 分钟),导致用户的连续请求在重试时,通过不同的 DNS 缓存拿到了不同机房的 VIP。

    结果就是:同一个用户的写请求,前一秒打到了 DC1,由于前端超时发起重试,后一秒重试请求通过另一个 LocalDNS 解析到了 DC2。这就是灾难的开始。

    灾难放大:MySQL 双向同步的“黑暗时刻”

    如果只是流量调度混乱,顶多是请求变慢。但多活架构的致命弱点在于数据一致性的取舍。

    我们在底层使用了双向异步复制通道。当流量同时打向 DC1 和 DC2 时:

    1. 请求 A 落在 DC1,应用层生成了订单,执行 INSERT。

    2. 重试请求 B 落在 DC2,由于应用层的订单号生成逻辑依赖时间戳+用户ID的简单哈希(并未包含机房标识),生成了与请求 A 一模一样的 order_id,并在 DC2 执行 INSERT。

    3. MySQL 8.0 默认的隔离级别(RR)和本地事务只能保证单机房内的唯一性。DC1 和 DC2 各自本地提交成功,向前端返回 200 OK。

    4. 毫秒级之后,双向同步组件(Otter)开始拉取 Binlog。DC1 的记录同步到 DC2 报错 1062;DC2 的记录同步到 DC1 同样报错 1062。

    5. 双边复制链路同时中断,两边的数据开始分叉(Split-Brain),形成事实上的“脏写”。

    此时,如果强行设置 slave_exec_mode = IDEMPOTENT(幂等模式)跳过 1062 错误,虽然同步链路能恢复,但这会导致双边数据静默不一致,后续的业务逻辑将彻底失控。

    架构重塑:防御性多活设计与一致性取舍

    针对这种“基础设施不可控”引发的流量逃逸,必须在应用层和网关层建立深度的防御性编程机制。单纯依赖 GSLB 做多活路由是极度脆弱的。

    1. 网关层:基于 UID 的强单元化染色拦截

    不能信任 DNS 解析的结果。流量到达任意机房的 API Gateway(如 OpenResty/Nginx)后,必须进行二次校验。 我们引入了基于用户 ID 的强路由规则。在 OpenResty 中使用 Lua 脚本拦截:

    -- OpenResty Lua 流量染色与校验 (版本 1.21.4.1)
    local core_route = require "resty.core_route"
    local headers = ngx.req.get_headers()
    local uid = headers["X-User-Id"]
    
    if uid then
        -- 简单的 Sharding 逻辑:奇数去 DC1,偶数去 DC2
        local expected_dc = (tonumber(uid) % 2 == 0) and "DC2" or "DC1"
        local current_dc = ngx.var.current_dc -- 环境变量注入本物理机房标识
    
        if expected_dc ~= current_dc then
            -- 发现流量逃逸!当前机房不该处理该用户的写请求
            -- 方案A:直接阻断,返回特定错误码让客户端重新获取路由
            -- ngx.status = 421 
            -- ngx.say('{"err": "Misdirected Request"}')
    
            -- 方案B(平滑过渡):在网关层通过内网专线反向 Proxy 到正确机房
            ngx.var.upstream_target = "internal-api." .. string.lower(expected_dc) .. ".svc"
        end
    end
    

    通过上述逻辑,即使 DNS 将流量导错了机房,网关层也能将其纠正或拒绝,将“多活乱序写”拦截在 DB 之前。

    2. DB 层:底层发号器的机房隔离(防脏写底线)

    即便网关层做了拦截,为了兜底极端情况下的应用层 Bug,必须彻底消灭跨机房主键冲突的可能性。 全面废除简单的时间戳哈希,引入包含 Data Center ID (机房标识) 的全局发号器(如改进版 Snowflake 算法):

    • 1位符号位 + 41位时间戳 + 4位机房ID + 8位机器/Pod ID + 10位序列号。

    • DC1 写入的数据,高位永远带有 0001 标识;DC2 带有 0010 标识。

    从根本上保证:即便出现流量双写,两边生成的 order_id 也是不同的。同步时退化为两次独立的 INSERT(虽有业务重复的瑕疵,但 DB 链路不会中断,后期可通过离线脚本对冲重复数据)。

    常见问题

    Q: MySQL 双向同步出现 1062 主键冲突,除了手工跳过,有什么自动修复策略? A: 生产环境中禁止盲目 sql_slave_skip_counter=1。标准做法是:引入冲突解决表(Conflict Resolution Tables)。在同步中间件(如 Canal/Otter)配置冲突降级策略:一旦捕获到目标端抛出 1062 异常,提取 Binlog 中的前后像数据写入专门的 sync_conflict_log 表,并丢弃当前 event 以维持链路运转,随后由自动巡检脚本比对时间戳(Last Write Wins)或通过人工后台介入仲裁。

    Q: 异地多活场景下,如何根本解决 DNS TTL 缓存导致的切流延迟问题? A: 无法彻底解决。公网 LocalDNS 的缓存污染是不可控的。最佳实践是:

    1. 客户端接入 HTTPDNS:绕过 LocalDNS,直接通过 HTTP 接口向云厂商获取当前准确的机房 IP 列表。

    2. 长连接探活与下发:App 端与后端维持 WebSocket/TCP 长连,当机房准备切换时,通过长连主动下发新的接入点 IP,强制客户端断线重连至新机房。

    Q: 单元化架构中,由于业务关联导致跨单元的写请求(如 A 机房用户给 B 机房用户转账),如何处理? A: 严禁跨机房直接读写对方的数据库!这会引入极高的网络延迟和分布式事务灾难。 标准解法是服务层 RPC 转发:A 机房的业务代码通过内部 RPC/MQ 将转账请求发送给 B 机房的转账服务微服务,由 B 机房的服务在 B 机房本地完成数据库写入。即:让流量在服务网关层走专线跨机房,而不是让 DB 跨机房。

  • 深入 API 网关限流陷阱排查:Token Bucket 容量失控引发的微突发穿透与熔断震荡实战

    排查某业务线核心 API 网关间歇性 503 告警时,发现了一个极其经典的防御性配置失效案例。结论先行:网关层的 Token Bucket(令牌桶)限流算法中,burst(桶容量)配置过大,导致毫秒级微突发流量直接穿透网关,瞬间击垮下游服务线程池。更要命的是,熔断器配置缺乏防抖逻辑,导致在“熔断-半开-放行-再次击垮”之间形成了高频震荡(Thrashing)。

    解决思路很简单:将 burst 值严格限制在下游最大并发承载能力之内,或者针对这种重计算型下游改用 Leaky Bucket(漏桶)算法进行流量整形(Traffic Shaping),同时为熔断器加入指数退避冷却时间。

    案发现场与指标异常

    监控大盘上的表象非常诡异。网关层统计的平均 QPS 稳定在 2,000 左右,并未达到设定的 3,000 QPS 报警水位。但下游订单服务的 P99 延迟却会毫无规律地从 50ms 飙升至 5,000ms 以上,随后伴随大量的 context deadline exceeded 报错。

    查看下游宿主机的 Node Exporter 指标,Load Average 在几秒内冲高到正常核心数的 4 倍,CPU 软中断(si)陡增。

    抓取网关层的 Envoy(或基于 Go 自研的网关)日志,看到大量 503 状态码以及频繁的熔断状态切换:

    [gw-error] downstream_connection_error: upstream_reset_before_response_started
    [circuit_breaker] state changed: CLOSED -> OPEN, reason: 5xx_rate_exceeded
    [circuit_breaker] state changed: OPEN -> HALF_OPEN
    [circuit_breaker] state changed: HALF_OPEN -> CLOSED
    [circuit_breaker] state changed: CLOSED -> OPEN, reason: 5xx_rate_exceeded
    

    愚蠢的配置与技术逻辑剖析

    直接调出网关的限流配置片段,问题一目了然:

    # 某网关 Token Bucket 限流配置片段
    rate_limit:
      token_bucket:
        max_tokens: 15000       # 桶的总容量 (Burst)
        tokens_per_fill: 3000   # 每次填充的令牌数
        fill_interval: 1s       # 填充间隔
    

    写出这段配置的研发可能对限流算法有什么误解。他认为“平时流量 2,000,限制在 3,000,为了防止大促时的流量尖刺,把 max_tokens 设成 15,000 留足缓冲”。

    这是毫无系统边界意识的典型表现。 Token Bucket 的核心特性是允许一定程度的突发流量。当业务处于低谷时,令牌桶会很快被填满(15,000 个令牌)。此时如果客户端利用并发压测工具或因为重试风暴,在 10 毫秒内打过来 10,000 个请求,网关会认为“桶里有足够的令牌”,瞬间全部放行。

    下游订单服务是个典型的 Java Spring Boot 配合 Tomcat 的同步阻塞架构,Tomcat 最大线程数才配置了 800,数据库连接池 200。这 10,000 个并发砸下去,直接导致:

    1. Tomcat 线程池瞬间打满,后续请求全部进入 Accept 队列。

    2. 数据库连接池耗尽,获取 Connection 发生等待。

    3. 线程疯狂上下文切换,CPU 资源被内耗殆尽。

    网关层的限流不仅没有保护下游,反而成了帮凶。你以为的“缓冲”,其实是积攒了一发核弹交给了客户端。

    熔断震荡(Circuit Breaker Thrashing)的雪上加霜

    下游瘫痪后,网关的熔断器(Circuit Breaker)触发了 OPEN 状态,切断流量,下游开始缓慢恢复。但随后的恢复过程暴露了熔断器设计的另一个缺陷。

    现有的熔断半开(HALF_OPEN)策略是:等待 5 秒后,放行 10 个请求,如果全成功,则关闭熔断器(CLOSED)。 因为这 10 个探路请求下去时,下游服务刚刚喘过气,处理极快。熔断器一看,非常健康,瞬间切回 CLOSED。结果桶里早就又攒满了 15,000 个令牌,下一波微突发(Micro-burst)再次穿透,下游再次暴毙。

    这种简单的状态机没有考虑到下游服务的冷启动缓冲和并发水位线,导致系统在可用与不可用之间剧烈震荡,P99 延迟曲线变成了梳子状。

    避坑指南与底层调优

    解决这类问题,必须摒弃“一厢情愿”的配置,回归到底层容量规划上来:

    1. 重新校准 Token Bucket,或者改用 Leaky Bucket 如果下游服务对瞬间并发极度敏感(如涉及复杂事务、慢 SQL 的 DB 操作),首选 Leaky Bucket(漏桶)。漏桶就像一个队列,强行将请求以恒定速率滴漏给下游(Traffic Shaping),完全抹平突发。 如果必须用 Token Bucket,max_tokens(Burst)的绝对上限,必须小于下游服务能够承受的最大瞬时并发数(通常是 线程池大小 + 队列长度的小部分)。

    修正后的配置:

    rate_limit:
      token_bucket:
        max_tokens: 800         # 严格卡住瞬间并发上限
        tokens_per_fill: 3000
        fill_interval: 1s
    

    (注:如果使用 Go 的 golang.org/x/time/rate,Limit 控制速率,Burst 就是这个 max_tokens,务必小心设置。)

    2. 引入滑动窗口与指数退避熔断 放弃拍脑袋的连续错误计数熔断,改用基于时间滑动窗口(Sliding Window)的错误率熔断。半开状态的探测必须具备平滑放量能力,而不是简单的“成功 10 个就完全放开”。冷却时间必须使用指数退避(Exponential Backoff,如 5s -> 10s -> 20s),并引入 Jitter(随机抖动)防止探路请求形成新的微并发。

    3. 监控维度的降维打击:从秒级到毫秒级 只看秒级(QPS)的监控,永远抓不到微突发。在排查阶段,通过抓包或高频采样,将吞吐量统计粒度降到 10ms 或 100ms,你会清楚地看到流量的尖刺。

    排查清单(同类问题速查)

    1. 核对 Burst/Capacity 配置:检查网关限流器中代表突发容量的字段(如 burst, max_tokens),其值是否远大于下游服务的可用线程数/连接池数。

    2. 区分流量整形需求:评估下游是否能处理突发。如果不能,立刻将 Token Bucket 替换为 Leaky Bucket,或在 Nginx 中严格使用 limit_req 且不加 burst(或者加 nodelay 时严格控制大小)。

    3. 检查熔断震荡日志:在日志系统中聚合分析 CLOSED -> OPEN -> HALF_OPEN -> CLOSED 的时间差。如果发生频率在秒级切换,说明探路策略过于激进,需增大退避时间和放量步长。

    4. 排查毫秒级微突发:如果 1s 内的 QPS 正常但 P99 飙升,使用 Prometheus 的 rate(metric[1m]) 是看不出来的,必须拉取网关 Access Log 按照 100ms 粒度做 count 聚合分析。

  • Etcd 集群频繁 Leader 切换雪崩:WAL fsync 阻塞引发的 Raft 心跳饿死与选主风暴排查实战

    近期排查了一个非常经典的分布式共识层故障。K8s 集群的 API Server 频繁报 context deadline exceeded,核心控制器全线 CrashLoopBackOff。底层定位到 Etcd 集群处于极度不稳定的状态,Raft Leader 疯狂切换(Flapping)。最终查明,这是一起由于共主节点磁盘 I/O 被同机其他定时任务打满,导致 Etcd WAL (Write-Ahead Log) fsync 严重超时,进而“饿死” Raft 心跳触发的选主风暴惨案。

    在分布式共识(Raft/Paxos)的工程实践中,存储 I/O 抖动是干掉集群可用性的头号杀手。遇到这种问题,调整网络参数是缘木求鱼,必须深入底层的日志复制和状态机流转机制去开刀。

    故障现场:API Server 雪崩与疯狂的 Term 暴增

    排查期间,首先接到 Prometheus 告警,K8s API Server 的 P99 延迟直接从平时的 30ms 飙升到了 8000ms 以上。查看 Etcd 集群状态,发现 etcd_server_leader_changes_seen_total 指标呈阶梯状暴增。

    直接拉取 Etcd 的运行日志,满屏的红色 Error,核心报错就两行:

    # Leader 节点疯狂抱怨心跳发送超时
    {"level":"warn","ts":"...","caller":"etcdserver/server.go:2038","msg":"failed to send out heartbeat on time (exceeded the 100ms timeout for 2.3s)","server_id":"8211f1d0f64f3269"}
    
    # 紧接着 Leader 发现自己任期落后,被迫下台
    {"level":"info","ts":"...","caller":"raft/raft.go:825","msg":"8211f1d0f64f3269 [term: 1205] received a MsgVote with higher term from 7192f1d0f64f11a2 [term: 1206]"}
    {"level":"info","ts":"...","caller":"raft/raft.go:842","msg":"8211f1d0f64f3269 became follower at term 1206"}
    

    从日志可以看出一个典型的 Raft 状态扭转过程:

    1. 当前 Leader 因为某种原因,长达 2.3 秒没有发包。

    2. Follower 节点的 election-timeout(默认 1000ms)耗尽,认为 Leader 已死。

    3. Follower 状态转为 Candidate,将当前任期(Term)+1,并向集群广播 MsgVote。

    4. 原 Leader 收到高 Term 的投票请求,瞬间认怂,StepDown 退化为 Follower。

    如此反复,集群陷入了永无止境的选主(Election Storm),导致没有任何一个节点能稳定处理外部 Client 提交的写请求(Propose)。

    原理剖析:为什么磁盘卡顿会饿死网络心跳?

    很多新人会有个疑问:磁盘 I/O 慢,大不了客户端的写请求(Put)慢一点,为什么连 Raft 节点之间的网络心跳都会发不出去?

    这就得扒一下 Etcd 底层 Raft 状态机的工程实现逻辑。在 etcd/raft 模块中,为了保证强一致性,Raft Node 处理状态机输出(Ready 结构体)的典型流程是一个同步的串行大循环:

    // Etcd Raft 核心循环的伪代码逻辑映射
    for {
        select {
        case rd := <-node.Ready():
            // 1. 将 HardState 和 Entries 写入底层 WAL 文件并强制落盘
            saveToStorage(rd.HardState, rd.Entries)
            // 注意这里的 fsync 是阻塞调用!
            wal.Fsync() 
    
            // 2. 将消息(包含 AppendEntries/心跳)发送给其他 Peer
            send(rd.Messages)
    
            // 3. 将已提交的日志应用到内存状态机(KV 存储)
            applyToStore(rd.CommittedEntries)
    
            node.Advance()
        }
    }
    

    发现致命问题了吗?WAL 落盘(wal.Fsync())和发送网络消息(send)是在同一个处理流程中的。 Raft 协议要求:日志必须先持久化到本地(保证 Crash-Safe),然后才能广播给其他节点。 如果底层磁盘 I/O 突然飙升,fsync 系统调用被内核挂起 2 秒,那么紧跟在后面的 send(rd.Messages) 就会被硬生生延迟 2 秒!

    Leader 发不出带着空 Entry 的 AppendEntries RPC(即心跳),Follower 就会准时发起叛变。

    现场缉凶:I/O 被谁吃干抹净了?

    顺着这个逻辑,直接去 Leader 宿主机上查 I/O 现场。 使用 iostat -dx 1 监控,发现系统盘(/dev/vda)的 %util 长期顶死在 100%,await 指标高达 2500ms+。

    进一步通过 iotop -o 和 ps 溯源,抓到了真凶: 宿主机上被人偷偷配了一个 Ansible 统一下发的 Cronjob,跑的是一个极度暴力的 tar -czf 日志归档脚本,且没有任何资源限制(cgroups/ionice)。这个任务瞬间榨干了云盘的 IOPS(突发型 EBS 的 Burst Balance 直接被扣光),导致同在一块盘上的 Etcd WAL 写入被内核底层 I/O 调度队列无情阻塞。

    架构避坑与防御性配置

    把这种重型 I/O 任务与对延迟极其敏感的分布式共识组件混跑,在运维界属于经典的低级失误。为了防止这类 I/O 抖动导致系统雪崩,必须做好以下防御性架构调优:

    1. 物理隔离:分离 WAL 目录

    千万不要把 Etcd 的数据和系统的 /var/log 甚至其他业务跑在同一块盘上。 Etcd 启动时强烈建议利用 --wal-dir 参数,将 WAL 单独挂载到一块独立的高性能 SSD / NVMe 盘上。 WAL 是 Append-only 的顺序写,对 IOPS 要求极高且对延迟敏感;而 DB 文件 (--data-dir) 存在随机读写和压缩。分离两者能最大程度保护心跳逻辑。

    2. 调优 Raft 超时参数 (适用于云环境)

    Etcd 默认的 heartbeat-interval=100ms 和 election-timeout=1000ms 是为局域网低延迟裸金属服务器设计的。在存在网络虚拟化和存储网络化(EBS/Ceph)的云环境中,稍微的 I/O 抖动就会打破这个 1 秒的底线。 实战建议: 针对跨可用区(Multi-AZ)或云盘环境,适当放宽超时容忍度。

    # 启动参数调整
    --heartbeat-interval=250
    --election-timeout=2500
    

    注:election-timeout 推荐设置为 heartbeat-interval 的 10 倍,以规避网络偶发丢包。

    3. 确保 Pre-Vote 机制开启

    如果是自行维护的旧版本 Etcd 或其他 Raft 实现,务必确保 Pre-Vote 机制是开启的(Etcd 3.4+ 默认开启)。 当网络发生非对称分区(Asymmetric Partition)或节点局部 I/O 夯死时,节点会被隔离并空转 Term。一旦它恢复并重新接入集群,它的高 Term 会立刻把正常 Leader 打下台。开启 Pre-Vote 后,Candidate 在增加本地 Term 前,必须先发起一轮预投票(PreVote),如果无法获得多数派响应,则不允许增加 Term,从根本上阻断了此类选主风暴。

    排查清单:同类问题速查

    如果你的 K8s/Etcd/Consul 集群出现频繁选主或超时断连,请直接按以下清单排查:

    1. 查磁盘 fsync 延迟:查看 Prometheus 指标 etcd_disk_wal_fsync_duration_seconds,若 P99 超过 election-timeout(默认 1s),必发选主风暴。

    2. 查系统级 I/O 争抢:使用 iostat 检查 IO util 和 await,排查同节点是否有定时快照(Snapshot)、日志备份、Prometheus 压盘等耗 IO 进程。

    3. 查网络 RTT 与丢包率:排查跨 AZ 部署时的网络抖动,指标 etcd_network_peer_round_trip_time_seconds,若网络 RTT 超过心跳间隔(100ms),会导致 Follower 频繁超时。

    4. 查大 Key 写阻塞:排查业务端是否有超大体积的 KV 写入(如巨型 ConfigMap)。Raft 复制大单体 Entry 会占用整个网络与 I/O 周期,变相阻塞后续的心跳包发送。

  • 深入剖析分布式事务的工程取舍:从 2PC 锁争用泥潭到 TCC 防悬挂实战

    核心结论:高并发核心链路严禁直接使用 XA/2PC 协议,其同步阻塞与全局锁定机制必然导致数据库连接池雪崩。Seata AT 模式虽通过一阶段提交缓解了长事务,但在热点行更新时,全依赖 TC 全局锁,极易造成 P99 延迟飙升。落地高并发分布式事务,最稳妥的解法是 TCC 或 Saga,并必须在底层辅以本地事务防悬挂控制表,实现极致的防御性编程。

    排查与重构高并发交易系统时,分布式事务永远是绕不开的雷区。很多人在架构选型时迷信各种中间件包装好的透明事务,却忽视了 CAP 定理下分布式事务的本质:通过牺牲可用性(锁阻塞)或牺牲一致性(最终一致补偿)来换取系统的流转。

    本文以 MySQL 8.0.32 和 Seata 1.6.1 为例,撕开分布式事务底层的工程细节,只谈实际落地时的痛点与防御。

    XA/2PC 的原罪:网络 RTT 与底层锁的致命耦合

    传统 XA 规范(2PC)的逻辑看似无懈可击:Prepare 阶段锁定资源,Commit/Rollback 阶段统一决断。但在实际微服务场景下,这是灾难的设计。

    当业务发起一次 XA 事务,MySQL 底层会执行 XA START -> SQL -> XA PREPARE。此时,InnoDB 引擎已经对涉及的数据行加上了排他锁(X Lock),并且这个锁的释放完全依赖于网络另一端 TM(Transaction Manager)的指令。

    你可以通过以下 SQL 在 MySQL 8.0+ 中观察到 XA 事务持有的锁阻塞情况:

    SELECT 
        p.trx_id, 
        p.trx_state, 
        p.trx_started, 
        l.lock_type, 
        l.lock_mode, 
        l.lock_data
    FROM performance_schema.data_locks l
    JOIN information_schema.innodb_trx p ON l.engine_transaction_id = p.trx_id
    WHERE p.trx_state = 'PREPARED';
    

    雪崩路径:

    1. 阶段一完成后,RM(数据库)持有行锁。

    2. TM 在阶段二由于网络抖动、GC 停顿或节点宕机,迟迟不发送 XA COMMIT。

    3. 其他并发请求试图访问该行数据,全部堆积在 innodb_lock_waits 中。

    4. 数据库连接池(如 HikariCP)迅速被占满,拖垮整个服务。

    这就是为什么在 C 端高并发核心链路(如库存扣减、资金转账)中,XA 协议属于绝对的禁区。

    为什么 Seata AT 模式在热点数据下会演变成性能灾难?

    为了解决 2PC 的长时间锁资源问题,Seata AT 模式应运而生。它的核心思想是:一阶段直接提交本地事务释放数据库锁,二阶段通过 undo_log 回滚。这听起来很完美,但它真的能抗住高并发吗?

    在某次大促压测中,我们发现扣减热点 SKU 库存时,TPS 始终卡在 300 左右,且 API 的 P99 延迟高达 3000ms+。抓取 Seata TC Server 的日志发现大量获取全局锁超时:

    [timeoutChecker_1] ERROR io.seata.core.lock.LockManager - Global lock wait timeout, xid: 192.168.1.10:8091:859392134, table: inventory, pk: 1001
    

    底层原理解析: Seata AT 为了防止脏写(Dirty Write),在本地事务提交前,必须向 TC(Transaction Coordinator)申请全局锁(Global Lock)。 如果两个并发请求同时修改同一行数据(例如热点 SKU id=1001):

    1. 事务 A 获取本地锁,修改数据。

    2. 事务 A 申请全局锁 inventory:1001,成功。A 提交本地事务,释放本地锁。

    3. 事务 B 获取本地锁,修改数据。

    4. 事务 B 申请全局锁 inventory:1001,失败,事务 A 尚未完成二阶段。

    5. 事务 B 必须等待,若超时则抛出 LockWaitTimeoutException,随后回滚本地事务。

    结论: Seata AT 只是把数据库的行锁争用,转移到了 Seata TC Server 的全局锁争用上。在热点行更新场景下,网络 RTT 被放大,性能瓶颈依然存在。AT 模式适合低并发的后台管理系统,绝不适合高并发交易链路。

    TCC 架构的防御性编程:空回滚、幂等与防悬挂实战

    既然底层锁不可靠,我们就必须走向应用层补偿事务,即 TCC(Try-Confirm-Cancel)或 Saga。 TCC 的 Try 阶段预留资源,Confirm 提交,Cancel 释放预留。但 TCC 落地的核心难点根本不是业务逻辑,而是分布式网络三大暗礁:网络重试导致的非幂等、空回滚、悬挂(Suspension)。

    • 空回滚:Try 请求因网络丢包未到达,TM 直接发起 Cancel。此时 Cancel 必须能够识别并成功返回。

    • 悬挂:Try 请求超时,TM 发起 Cancel 并执行成功。随后那个被网络延迟的 Try 请求终于到达了参与者。如果 Try 成功执行,预留的资源将永远无法被 Confirm 或 Cancel,造成数据死锁。

    最佳实践:基于本地控制表的 TCC 防御机制

    我们必须在业务数据库中建立一张 TCC 事务控制表,利用本地事务的 ACID 特性来抵抗分布式网络的混乱。

    CREATE TABLE `tcc_branch_control` (
        `xid` VARCHAR(128) NOT NULL COMMENT '全局事务ID',
        `branch_id` VARCHAR(128) NOT NULL COMMENT '分支事务ID',
        `status` TINYINT NOT NULL COMMENT '状态: 0-Try, 1-Confirm, 2-Cancel',
        `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
        `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        PRIMARY KEY (`xid`, `branch_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    

    Try 阶段的防御代码逻辑:

    在 Try 方法中,我们将业务 SQL 与插入控制表包裹在同一个本地事务中。

    @Transactional(rollbackFor = Exception.class)
    public boolean tryDeduct(String xid, String branchId, String sku, int count) {
        // 1. 防悬挂与幂等检查:尝试插入 Try 记录
        // 如果插入失败(主键冲突),说明 Try 已执行(需处理幂等),或者 Cancel 已经执行(发生悬挂)
        int insertCount = tccControlMapper.insertIgnore(xid, branchId, 0);
        if (insertCount == 0) {
            TccControl record = tccControlMapper.select(xid, branchId);
            if (record.getStatus() == 2) {
                log.warn("防悬挂拦截: Cancel已执行, 丢弃迟到的Try请求, xid: {}", xid);
                return false; 
            }
            log.info("Try 幂等放行, xid: {}", xid);
            return true;
        }
    
        // 2. 正常执行 Try 业务逻辑 (如: 冻结库存)
        inventoryMapper.freeze(sku, count);
        return true;
    }
    

    Cancel 阶段的防御代码逻辑:

    @Transactional(rollbackFor = Exception.class)
    public boolean cancelDeduct(String xid, String branchId, String sku, int count) {
        // 1. 尝试插入 Cancel 记录 (防御空回滚)
        // 如果之前没有 Try 过,这里会插入成功,状态为 2 (Cancel)。
        // 这同时阻断了后续迟到的 Try (防悬挂)。
        int insertCount = tccControlMapper.insertIgnore(xid, branchId, 2);
        if (insertCount > 0) {
            log.info("空回滚执行: 记录Cancel状态, 拦截后续Try, xid: {}", xid);
            return true;
        }
    
        // 2. 检查当前状态
        TccControl record = tccControlMapper.select(xid, branchId);
        if (record.getStatus() == 2) {
            log.info("Cancel 幂等放行, xid: {}", xid);
            return true;
        }
    
        // 3. 执行资源释放,并更新状态为 Cancel
        inventoryMapper.unfreeze(sku, count);
        tccControlMapper.updateStatus(xid, branchId, 2);
        return true;
    }
    

    通过这一张表和一个 INSERT IGNORE 指令,我们在数据库引擎层面完美防范了所有由于网络乱序引发的事务状态异常。

    Saga 模式的取舍:隔离性的彻底放弃

    当你的分布式事务跨越了第三方系统(如调用外部银行接口),你无法要求第三方提供 Try 接口预留资源,此时 TCC 不适用,只能退化为 Saga 模式。

    Saga 也是两阶段:一阶段直接执行正向业务(如直接入账),二阶段执行补偿业务(如扣减入账)。 它的最大缺陷是缺乏隔离性。在正向业务执行完,补偿业务尚未执行的这段时间窗口内,其他事务可能会读取甚至修改这部分数据(脏读、脏写)。

    Saga 防治脏写的底线: 如果采用 Saga,必须引入乐观锁(版本号机制)或状态机。一旦补偿阶段发现数据的版本号被其他事务推进过,绝对不能强行执行回滚逻辑,必须立即阻断补偿链路,抛出异常,转入人工对账异常队列表。自动化的尽头是人工,这是容灾兜底的最后防线。

    常见问题 (FAQ)

    Q1:在 TCC 模式下,如果 Confirm 或 Cancel 阶段执行失败(比如数据库临时宕机),应该怎么处理? A: TCC 的设计前提是 Confirm 和 Cancel 必须最终成功。如果阶段二失败,TM(Transaction Manager)会不断重试。工程实现上,必须保证阶段二的绝对幂等性。如果重试超过一定阈值(如重试 5 次依然报错),通常意味着出现了底层硬故障(如坏块或长期的依赖宕机)。此时 TM 会记录异常日志,触发告警,转由人工介入。绝对不要在阶段二返回业务层面的错误。

    Q2:Saga 模式执行补偿逻辑时,发现数据已经被用户修改过了(脏写),如何进行补偿? A: 这是 Saga 的经典痛点。在设计 Saga 时,必须对被操作的数据加上状态锁或语义锁。例如订单状态变更为“发货中”,此时如果触发补偿,发现状态已经是“已收货”,就不应该直接执行逆向逻辑。一旦检测到脏写(通过乐观锁版本号或状态机流转规则拦截),系统应该停止自动补偿,触发风控或异常对账流程,由运营人员判断是否需要人工冲正。

    Q3:Seata Server (TC) 如果发生 OOM 或者宕机,对正在运行的业务有什么影响? A: 以 Seata 1.6.1 为例,TC 本身无状态,其事务数据存储在 MySQL 或 Redis 中。如果 TC 宕机,客户端的发起的全局事务将无法注册或提交,业务接口会大量抛出 TransactionException,导致新事务完全中断(可用性受损)。对于已经进入二阶段的事务,待 TC 恢复后,会从数据库读取处于 COMMITTING 或 ROLLBACKING 状态的会话,继续下发二阶段指令。监控上会观察到活跃事务数(Active Transactions)剧增。