📝 摘要:对接三方接口最怕它悄悄挂掉还在傻调,本文用 Redis + Lua 脚本实现轻量级健康检查与自动熔断:滑动窗口统计最近 N 次调用成功率,低于阈值自动屏蔽渠道并告警,屏蔽 Key 靠 TTL 自动恢复;多实例共享状态、脚本原子执行、正常态零写入,业务侧只需 try-finally 记录加 isBlocked 过滤两处改动即可零侵入接入。

对接三方接口最怕什么?不是接口报错——而是接口悄悄挂了,你还在一个劲儿地调,用户体验持续恶化,直到告警炸了手机你才知道。本文分享一个基于 Redis + Lua 的三方接口健康检查方案,实现自动检测 + 自动屏蔽 + 自动恢复 + 实时告警,让三方接口"挂了也不慌"。

一、问题场景

假设我们对接了多个三方支付渠道(微信支付、支付宝、Stripe 等),正常情况下用户可以选择所有支付方式。

但如果某个渠道突然抽风了呢?

用户点支付 → 调支付宝 → 超时 → 重试 → 又超时 → 用户骂街 → 你背锅

理想情况下,我们希望系统能做到:

  1. 自动检测:实时监控每个渠道的调用成功率
  2. 自动屏蔽:成功率低于阈值时,自动从支付方式列表中移除
  3. 自动恢复:屏蔽一段时间后自动恢复,给渠道一个"改过自新"的机会
  4. 实时告警:触发屏蔽时通知相关人员

一句话总结:最近 10 次调用成功率低于 70%,自动屏蔽 30 分钟。


二、方案选型

1. 开源方案对比

在动手之前,先调研了主流的熔断/健康检查方案:

方案 核心能力 适用场景 不选的原因
Sentinel 流控、熔断、降级 微服务全链路治理 太重;且熔断是"拦截请求",我们需要的是"隐藏渠道",控制点不同,接入需额外改造
Resilience4j 熔断、限流、重试 Java 微服务 熔断状态基于本地内存,多实例不共享
Hystrix 熔断、降级 遗留 Spring Cloud 项目 Netflix 2018 年起转入维护模式、不再迭代,不建议新项目引入
自研(Redis Lua) 滑动窗口 + 自动屏蔽 轻量级健康检查 ✅ 选它

2. 为什么选自研?

需求 Sentinel / Resilience4j Redis Lua 自研
多实例状态共享 需额外配置持久化 Redis 天然共享
原子性 本地原子 Lua 脚本原子
配置动态变更 需要 Dashboard 或代码 配置中心 JSON 热更新
引入成本 依赖较多 一个 Lua + 一个 Java 类
屏蔽自动恢复 需自定义 Redis Key TTL 天然支持

核心原因:多实例部署时,健康状态必须共享。A 实例检测到渠道挂了,B 实例也得知道。Redis 天然满足这个需求,不需要额外引入分布式组件。

顺带统一下用词:标准熔断器做的是「拦截请求」,本方案做的是「从可选渠道列表里把它摘掉」——效果相近但控制点不同,下文一律叫「屏蔽」。


三、方案设计

1. 整体流程

整个方案就四个环节,业务侧真正要动的只有两处——调用后记一次结果,取列表时过滤一次

三方接口健康检查方案全景:调用前过滤、调用后记录、Lua 原子判定、屏蔽与 TTL 自动恢复四个环节,及各环节的 Redis 开销

2. 数据结构设计

Redis Key 类型 用途 示例
channel_health:{渠道名} List 滑动窗口,存储最近 N 次调用结果 ["1","1","0","1","0"]
channel_blocked:{渠道名} String 屏蔽标记,存在即被屏蔽 "1",TTL=30分钟

为什么 Key 里有 {} 这是 Redis Cluster 的 Hash Tag 语法。两个 Key 的 {} 内容相同,就能保证它们落在同一个 slot,Lua 脚本才能同时操作这两个 Key。不加会直接撞上 CROSSSLOT Keys in request don't hash to the same slot——这个坑的来龙去脉见《Redis 集群不支持 Lua 脚本?别被报错骗了,真相没那么简单》

3. 核心参数

参数 说明 默认值 可配置
windowSize 滑动窗口大小(最近 N 次调用) 10
successRate 成功率阈值(百分比) 70
blockMinutes 屏蔽时长(分钟) 30
switchMode 开关模式(on/off/指定渠道) off

配置通过配置中心下发,支持热更新,格式:

{"switchMode":"on","windowSize":10,"successRate":70,"blockMinutes":30}

四、核心实现

1. Lua 脚本:一次往返,原子完成

脚本一共六步,但绝大多数调用在前两步就返回了——先看判定路径,再看代码会清楚很多:

图:Redis Lua 熔断脚本的六步决策流程——屏蔽中 1×EXISTS 直接返回,正常态 2×EXISTS 零写入快速返回,只有失败才走 LRANGE 遍历统计与触发屏蔽的重路径

是 · 屏蔽中

是 · 正常态

成功

失败

进入 Lua 脚本(KEYS:healthKey · blockKey)

Step 1 · blockKey 存在?

返回 0,0,0,1 —— 开销 1×EXISTS

Step 2 · 本次成功 且 healthKey 不存在?

返回 0,0,0,0 —— 开销 2×EXISTS,零写入

Step 3 写入滑动窗口:RPUSH → LLEN → LTRIM → EXPIRE

本次是成功?

Step 4 · 窗口满且全成功?

DEL healthKey,回到轻量路径

返回 size,0,0,0

Step 5 LRANGE 遍历统计:rate = (window − size + succ) ÷ window

rate ≥ 阈值?

返回 size,succ,0,0 —— 还没到屏蔽线

Step 6 触发屏蔽:DEL healthKey + SET blockKey EX blockSeconds

返回 size,succ,1,0 —— Java 侧据此发告警

绿色是两条快速返回的路径,橙色才是失败时的重路径,红色是触发屏蔽。对应到代码:

-- 三方接口健康检查:记录调用结果 + 判定是否屏蔽
-- KEYS[1] = healthKey, KEYS[2] = blockKey
-- ARGV[1] = value("1"/"0"), ARGV[2] = windowSize, ARGV[3] = successRateThreshold
-- ARGV[4] = blockSeconds, ARGV[5] = expireSeconds
-- 返回:{总数, 成功数, 是否触发屏蔽(0/1), 是否屏蔽中(0/1)}

-- 1. 屏蔽中,跳过
if redis.call('EXISTS', KEYS[2]) == 1 then
    return {0, 0, 0, 1}
end

-- 2. 成功 且 healthKey 不存在(无近期失败),跳过写入
if ARGV[1] == '1' and redis.call('EXISTS', KEYS[1]) == 0 then
    return {0, 0, 0, 0}
end

-- 3. 记录 + 滑动窗口
local window = tonumber(ARGV[2])
redis.call('RPUSH', KEYS[1], ARGV[1])
local size = redis.call('LLEN', KEYS[1])
if size > window then
    redis.call('LTRIM', KEYS[1], size - window, -1)
    size = window
end
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5]))

-- 4. 成功:窗口满时检查是否全部成功,是则清空回到轻量路径
if ARGV[1] == '1' then
    if size >= window then
        local items = redis.call('LRANGE', KEYS[1], 0, -1)
        local allSucc = true
        for i = 1, #items do
            if items[i] ~= '1' then allSucc = false break end
        end
        if allSucc then
            redis.call('DEL', KEYS[1])
        end
    end
    return {size, 0, 0, 0}
end

-- 5. 失败:计算成功率(未记录的槽位视为成功)
local items = redis.call('LRANGE', KEYS[1], 0, -1)
local succ = 0
for i = 1, #items do
    if items[i] == '1' then succ = succ + 1 end
end
local rate = (window - size + succ) * 100 / window

if rate >= tonumber(ARGV[3]) then
    return {size, succ, 0, 0}
end

-- 6. 触发屏蔽,清空 healthKey(恢复后从零计数)
redis.call('DEL', KEYS[1])
redis.call('SET', KEYS[2], '1', 'EX', tonumber(ARGV[4]))
return {size, succ, 1, 0}

2. 三个关键优化点

这个 Lua 脚本不是一步到位写出来的,而是经历了几轮迭代优化。这里拆开讲讲每个优化的动机和效果。

2.1 从第一次失败才开始记录

问题:如果每次调用都往 Redis 写数据,正常运行时也在不停 RPUSH + LTRIM,白白消耗性能。

优化:Step 2 判断——如果本次是成功,且 healthKey 不存在(说明最近没有失败过),直接返回,不写 Redis

-- 成功 且 healthKey 不存在(无近期失败),跳过写入
if ARGV[1] == '1' and redis.call('EXISTS', KEYS[1]) == 0 then
    return {0, 0, 0, 0}
end

效果:正常运行时(全部成功),每次调用只需 2 次 EXISTS 检查,零写入。

2.2 窗口未满时的"补位"计算

问题:假设窗口大小是 10,系统正常运行了很久(全成功被跳过),突然来了 3 次失败。此时 healthKey 里只有 [0, 0, 0],长度才 3,远没到 10。如果要求"窗口满了才判定",那永远凑不满 10 条记录,永远不会触发屏蔽。

优化:未记录的槽位视为成功(因为它们确实是被 Step 2 跳过的成功调用),用补位公式计算成功率:

-- (窗口未记录的位置 + 实际成功数) / 窗口大小
local rate = (window - size + succ) * 100 / window

滑动窗口补位计算示意:窗口 10 个槽位中只有 4 次失败被记录进 healthKey,其余 6 个未记录槽位补成成功,算得成功率 60% 低于 70% 阈值触发屏蔽

举例(窗口=10,阈值=70%):

场景 healthKey 内容 补位计算 结果
连续 3 次失败 [0,0,0] (10-3+0)/10 = 70% 通过
连续 4 次失败 [0,0,0,0] (10-4+0)/10 = 60% 触发屏蔽
2失败+3成功(混合) [0,0,1,1,1] (10-5+3)/10 = 80% 通过
2.3 只在失败时做 LRANGE 遍历

问题:LRANGE + 遍历是 Lua 脚本中最重的操作。如果每次调用都做,性能白白浪费。

优化:成功时只做 Step 3 的 RPUSH + LLEN + LTRIM + EXPIRE,不做 LRANGE;只有失败时(Step 5)才 LRANGE + 遍历统计。唯一的例外是窗口已经填满时,Step 4 会做一次 LRANGE 来检测"是否全部成功"(用于清空 healthKey 回到轻量路径)。

原因很简单:成功只会让成功率持平或上升,永远不会触发屏蔽,所以不需要计算。

但有个小问题:第一次失败后,后续的成功也会写入 healthKey(因为 healthKey 已经存在了)。如果一直成功下去,healthKey 永远不会消失,每次都走 RPUSH 路径。

解决:Step 4 加了一个"全部成功检测"——窗口满了且全是成功时,DEL healthKey,回到轻量路径。以窗口 10 为例:失败之后再来 10 次成功调用,就能把失败记录全部挤出窗口、触发 DEL,其中只有窗口填满之后的那几次才多做一次 LRANGE,之后就恢复到 2x EXISTS 的最低开销。

各场景 Redis 操作开销

场景 Redis 操作
全部成功(正常态) 2x EXISTS
屏蔽中 1x EXISTS
成功 + 有近期失败、窗口未满 RPUSH + LLEN + LTRIM + EXPIRE
成功 + 有近期失败、窗口已满 RPUSH + LLEN + LTRIM + EXPIRE + LRANGE + 遍历(检测全成功)
失败 RPUSH + LLEN + LTRIM + EXPIRE + LRANGE + 遍历(算成功率)

3. 屏蔽恢复机制

这里有个容易踩的坑:屏蔽恢复后,旧的失败记录还在 healthKey 里。新的成功调用会和旧失败记录叠加,可能导致刚恢复又被屏蔽。

假设触发屏蔽时不清空 healthKey(错误做法):
屏蔽前: [0,0,0,0,0,0,0,0,1,1]  → 触发屏蔽
屏蔽 30 分钟后恢复...
新调用成功 → healthKey 还是 [0,0,0,0,0,0,0,0,1,1,1] → 成功率仍然低 → 又屏蔽了!

解决方案:触发屏蔽时,不仅写入 blockKey,还同时 DEL healthKey(Step 6)。这样恢复后 healthKey 是空的,走 Step 2 跳过,直到下一次失败才重新开始记录。干净利落,从零开始。

4. Java 健康检查器

@Slf4j
@Component
public class ChannelHealthChecker {

    private static final String LUA_SCRIPT;

    static {
        // Lua 脚本从资源文件加载
        LUA_SCRIPT = FileUtils.getContent("lua/channel_health_record.lua");
    }

    private final RedissonClient redissonClient;
    private final StringRedisTemplate stringRedisTemplate;
    private final AlertNotifier alertNotifier;

    // 支持配置中心热更新
    private volatile HealthConfig healthConfig = new HealthConfig();

    /**
     * 记录三方接口调用结果
     *
     * @param channel    渠道类型
     * @param success    本次调用是否成功
     */
    public void record(String channel, boolean success) {
        HealthConfig config = this.healthConfig;
        if (!config.isEnabled(channel)) {
            return;
        }
        try {
            int windowSize = Math.min(config.getWindowSize(), 100);
            int blockMinutes = config.getBlockMinutes();

            // Hash Tag 保证 Redis Cluster 下两个 Key 落在同一个 slot
            String healthKey = "channel_health:{" + channel + "}";
            String blockKey  = "channel_blocked:{" + channel + "}";

            // Lua 返回:{总数, 成功数, 是否触发屏蔽(0/1), 是否屏蔽中(0/1)}
            List<Object> result = redissonClient.getScript(new StringCodec()).eval(
                    RScript.Mode.READ_WRITE,
                    LUA_SCRIPT,
                    RScript.ReturnType.MULTI,
                    Arrays.asList(healthKey, blockKey),
                    success ? "1" : "0",
                    String.valueOf(windowSize),
                    String.valueOf(config.getSuccessRate()),
                    String.valueOf(blockMinutes * 60),           // blockSeconds
                    String.valueOf((blockMinutes + 30) * 60));  // expireSeconds:healthKey 自身的存活时间,见下方「关键设计点」

            // 屏蔽中,跳过
            boolean stillBlocked = result.size() >= 4 && "1".equals(result.get(3).toString());
            if (stillBlocked) {
                return;
            }

            boolean newlyBlocked = result.size() >= 3 && "1".equals(result.get(2).toString());
            if (newlyBlocked) {
                int recordSize = Integer.parseInt(result.get(0).toString());
                int recordSucc = Integer.parseInt(result.get(1).toString());
                int assumedSucc = windowSize - recordSize;
                String rateDetail = String.format("(%d+%d)/(%d+%d)",
                        recordSucc, assumedSucc, recordSize, assumedSucc);
                log.error("健康检查不通过,屏蔽 {} {}分钟,成功率: {}",
                        channel, blockMinutes, rateDetail);
                this.alertNotifier.send("三方接口自动屏蔽",
                        String.format("渠道: %s\n成功率: %s,低于阈值 %d%%,屏蔽 %d 分钟",
                                channel, rateDetail, config.getSuccessRate(), blockMinutes));
            }
        } catch (Exception e) {
            log.error("记录健康状态异常, channel={}", channel, e);
        }
    }

    /**
     * 判断该渠道是否被屏蔽
     */
    public boolean isBlocked(String channel) {
        if (!this.healthConfig.isEnabled(channel)) {
            return false;
        }
        String blockKey = "channel_blocked:{" + channel + "}";
        return Boolean.TRUE.equals(stringRedisTemplate.hasKey(blockKey));
    }

    /**
     * 健康检查配置(支持配置中心热更新)
     * 格式:{"switchMode":"on","windowSize":10,"successRate":70,"blockMinutes":30}
     *
     * switchMode   - on 全开 / off 全关 / 指定渠道如 ,ALIPAY,WECHAT,
     * windowSize   - 滑动窗口大小,默认 10
     * successRate  - 成功率阈值(%),默认 70
     * blockMinutes - 屏蔽时长(分钟),默认 30
     */
    @Data
    public static class HealthConfig {
        private String switchMode = "off";
        private int windowSize = 10;
        private int successRate = 70;
        private int blockMinutes = 30;

        public boolean isEnabled(String channel) {
            if ("on".equalsIgnoreCase(switchMode)) return true;
            if ("off".equalsIgnoreCase(switchMode)) return false;
            return switchMode.contains("," + channel + ",");
        }
    }
}

关键设计点

设计 说明
Redisson 执行 Lua 相比 Spring RedisTemplate,Redisson 在集群模式下对 Lua 脚本的支持更稳定
Hash Tag {channel} 保证 healthKey 和 blockKey 落在同一个 slot
stillBlocked 判断 通过 Lua 返回值判断是否屏蔽中,避免额外的 Redis 查询
配置热更新 通过配置中心监听变更,volatile 保证多线程可见性
windowSize 上限 Math.min(config.getWindowSize(), 100) 防止配置错误导致 Lua 处理过多数据
healthKey 的 TTL 滑动窗口是「最近 N 」不是「最近 N 分钟」,所以要给 healthKey 一个存活时间:超过这段时间没有新调用,几小时前的失败记录自动作废,不会被算进下一轮判定。取 blockMinutes + 30 分钟只是图一个「足够长」的值,和屏蔽时长本身没有强耦合(触发屏蔽时 healthKey 已被 DEL,屏蔽期间它根本不存在)

五、业务接入

接入只需要改两个地方,对业务代码几乎零侵入。

1. 调用三方接口后:记录结果

在调用三方接口的地方,用 try-finally 包一层:

boolean success = false;
try {
    Response resp = callThirdPartyApi();
    success = resp.isBizSuccess();   // 注意:不是"没抛异常"就算成功
} finally {
    channelHealthChecker.record(channel, success);
}

「成功」的判据要单独想一遍,这是接入时最容易让整套机制静默失效的地方:很多三方接口失败时照样返回 HTTP 200,只在响应体里塞一个错误码。如果拿"没抛异常"当成功,渠道其实已经全量报错,健康检查却全程认为它健康——脚本再严谨也白搭。

重试也要想清楚记哪一层:超时会抛异常、能被正确记成失败,但如果外面套了重试,一次业务调用会记进去好几次失败,屏蔽会比预期来得快。按需决定 record 是挂在"每次网络请求"上还是"每次业务调用"上。

2. 获取渠道列表时:过滤被屏蔽的渠道

在返回渠道列表的地方,加一行判断:

for (String channel : allChannels) {
    if (channelHealthChecker.isBlocked(channel)) {
        log.info("渠道 {} 健康检查不通过,已屏蔽", channel);
        continue;
    }
    // ... 正常添加到列表
}

六、单元测试

光写代码不够,还得验证 Lua 脚本在各种场景下的行为是否符合预期。以下测试直接连 Redis 执行 Lua 脚本,不 mock。

1. 测试基础设施

@SpringBootTest(classes = {RedisConfig.class, RedissonConfig.class})
@RunWith(SpringRunner.class)
@Slf4j
public class ChannelHealthCheckerTest {

    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private static final String LUA_SCRIPT = FileUtils.getContent("lua/channel_health_record.lua");
    private static final String HEALTH_KEY = "channel_health:{TEST_CHANNEL}";
    private static final String BLOCK_KEY  = "channel_blocked:{TEST_CHANNEL}";

    // 默认参数:窗口=5, 成功率阈值=60%, 屏蔽60秒, 过期90秒
    private static final String WINDOW = "5";
    private static final String RATE = "60";
    private static final String BLOCK_SEC = "60";
    private static final String EXPIRE_SEC = "90";

    @Before
    public void setup() { cleanup(); }

    @After
    public void tearDown() { cleanup(); }

    private void cleanup() {
        stringRedisTemplate.delete(HEALTH_KEY);
        stringRedisTemplate.delete(BLOCK_KEY);
    }

    /** 执行 Lua 脚本的快捷方法 */
    private List<Object> eval(String value) {
        return redissonClient.getScript(new StringCodec()).eval(
                RScript.Mode.READ_WRITE, LUA_SCRIPT, RScript.ReturnType.MULTI,
                Arrays.asList(HEALTH_KEY, BLOCK_KEY),
                value, WINDOW, RATE, BLOCK_SEC, EXPIRE_SEC);
    }

    private long toLong(Object obj) {
        return Long.parseLong(obj.toString());
    }
}

2. 核心测试用例

2.1 全部失败 → 触发屏蔽

最基本的场景:连续失败达到窗口大小,应触发屏蔽。实际上按补位计算,3 次连续失败就已触发(见 2.6),这里用 5 次是为了演示最直观的全失败场景。

@Test
public void testAllFailTriggersBlock() {
    List<Object> result = null;
    for (int i = 0; i < 5; i++) {
        result = eval("0");
        // 补位计算下第 3 次失败就触发屏蔽((5-3)/5=40% < 60%);触发后 healthKey 被清空、
        // 后续 eval 只会返回「屏蔽中」{0,0,0,1},所以要在触发这一拍就停下、抓住这次的返回值
        if ("1".equals(result.get(2).toString())) break;
    }
    // 连续失败触发屏蔽,第 3 项(是否触发)应为 1
    Assert.assertEquals("应触发屏蔽", 1L, toLong(result.get(2)));
    Assert.assertTrue("blockKey 应存在",
            Boolean.TRUE.equals(stringRedisTemplate.hasKey(BLOCK_KEY)));

    // 屏蔽 Key 应有 TTL
    Long ttl = stringRedisTemplate.getExpire(BLOCK_KEY);
    Assert.assertTrue("blockKey 应有TTL", ttl != null && ttl > 0 && ttl <= 60);
}
2.2 成功率达标 → 不屏蔽

先 4 次成功(healthKey 不存在,被 Step 2 跳过),再 1 次失败,healthKey 实际只有 [0],补位计算 (5-1+0)/5 = 80%,高于 60% 阈值,不应屏蔽。

@Test
public void testSuccessRateAboveThreshold() {
    for (int i = 0; i < 4; i++) { eval("1"); }
    List<Object> result = eval("0");

    Assert.assertEquals("不应触发屏蔽", 0L, toLong(result.get(2)));
    Assert.assertFalse("blockKey 不应存在",
            Boolean.TRUE.equals(stringRedisTemplate.hasKey(BLOCK_KEY)));
}
2.3 成功率刚好等于阈值 → 不屏蔽

边界值测试:先 3 次成功(被 Step 2 跳过),再 2 次失败,healthKey 实际为 [0,0],补位计算 (5-2+0)/5 = 60%,等于阈值 60%,不应屏蔽(rate < threshold 才屏蔽)。

@Test
public void testSuccessRateEqualsThreshold() {
    eval("1"); eval("1"); eval("1");
    eval("0");
    List<Object> result = eval("0");

    Assert.assertEquals("等于阈值不应触发屏蔽", 0L, toLong(result.get(2)));
}
2.4 屏蔽中 → 跳过记录

屏蔽状态下,不应记录新数据,直接返回"屏蔽中"标记。

@Test
public void testSkipWhenBlocked() {
    // 触发屏蔽(第 3 次就会触发,后两次落在「屏蔽中」分支;这里不校验触发那一拍的返回值,所以不用 break)
    for (int i = 0; i < 5; i++) { eval("0"); }
    Assert.assertTrue(Boolean.TRUE.equals(stringRedisTemplate.hasKey(BLOCK_KEY)));

    // 屏蔽期间的调用应被跳过
    List<Object> result = eval("0");
    Assert.assertEquals("应返回屏蔽中标记", 1L, toLong(result.get(3)));
}
2.5 屏蔽恢复后 → 从零计数

验证屏蔽恢复后,旧的失败记录被清空,不会影响新的判定。

@Test
public void testRecoveryResetsWindow() {
    // 触发屏蔽(会同时 DEL healthKey)
    for (int i = 0; i < 5; i++) { eval("0"); }
    Assert.assertTrue(Boolean.TRUE.equals(stringRedisTemplate.hasKey(BLOCK_KEY)));

    // 模拟屏蔽到期
    stringRedisTemplate.delete(BLOCK_KEY);

    // 恢复后连续成功,不应被旧数据影响
    for (int i = 0; i < 5; i++) {
        List<Object> result = eval("1");
        Assert.assertEquals("恢复后成功不应触发屏蔽", 0L, toLong(result.get(2)));
    }
    // healthKey 不应存在(恢复后全成功都走 Step 2 轻量路径,未写入)
    Assert.assertFalse("healthKey 不应存在",
            Boolean.TRUE.equals(stringRedisTemplate.hasKey(HEALTH_KEY)));
}
2.6 补位计算 → 窗口未满也能判定

验证窗口未满时,未记录的槽位视为成功,仍能正确判定。

@Test
public void testAssumedSuccessForUnfilledWindow() {
    // 窗口=5,连续 3 次失败,healthKey: [0,0,0]
    // 补位计算: (5-3+0)/5 = 40% < 60% → 应触发屏蔽
    List<Object> result = null;
    for (int i = 0; i < 3; i++) {
        result = eval("0");
    }
    Assert.assertEquals("补位后应触发屏蔽", 1L, toLong(result.get(2)));
}

七、方案的边界

这套东西够轻,代价是有几处刻意没做。照抄之前先确认这些取舍你能接受。

1. 恢复是"直接全量放开",没有半开探活

标准熔断器有 HALF_OPEN 状态:冷却结束后先放一小部分流量试探,确认好了才全开。本方案没有——blockKey 的 TTL 一到,渠道立刻回到候选列表,拿真实用户流量去试

后果很直接:如果渠道其实还没好,这批用户会实打实失败一轮,直到补位计算再次跌破阈值(窗口 10 / 阈值 70% 下是 4 次失败)才重新屏蔽。也就是每个屏蔽周期都会漏一小撮用户出去踩雷

之所以接受它,是因为半开要额外维护"探活中"状态和放行配额,复杂度上去了就不再是"一个 Lua + 一个 Java 类"。但如果你的接口单次失败代价很高(比如扣款类),就得自己补一个半开态,或者把 blockMinutes 调大、改成人工确认后再解除。

2. isBlocked 每次都真查 Redis

取渠道列表时每个渠道查一次 EXISTS,N 个渠道就是 N 次往返,没有本地缓存

渠道数是个位数时无所谓;但如果这个列表挂在高 QPS 接口上,建议加一个 100~500ms 的本地缓存(Caffeine 就够)。屏蔽本身是分钟级的状态,晚几百毫秒感知完全可以接受——代价是"刚屏蔽"和"刚恢复"都会晚半拍生效。

3. 只在触发屏蔽时告警,自动恢复时没有通知

record 里只有 newlyBlocked 分支会发告警;TTL 到期放开是 Redis 自己完成的,没有任何人被通知

于是群里只看得到"渠道 X 已屏蔽 30 分钟",看不到它什么时候回来的,更不知道回来之后是撑住了还是又挂了。要补的话,最省事的是加个定时任务对比 blockKey 的存在性变化(从有到无 = 已恢复)。别指望在 Lua 里顺手做——Step 2 那条快速返回路径,在"一直正常"和"刚从屏蔽恢复"两种情况下现场完全一样,脚本自己分辨不出来,真要区分就得再引一个标记 Key,把最热的路径搞重。


八、总结

1. 一个渠道的三种状态

前面把实现和边界都拆得比较碎,最后用一张状态图收口。每个渠道在任意时刻只处于三个状态之一,状态之间怎么流转、各自多少开销,一图看完:

图:三方接口渠道健康状态机——正常态 2×EXISTS 零写入,第一次失败进入记录态,补位成功率跌破阈值进入屏蔽态,屏蔽 Key TTL 到期后自动回到正常态从零计数

出现第一次失败

窗口满且全成功
DEL healthKey

补位成功率 < 阈值
SET blockKey + DEL healthKey + 告警

TTL 到期自动删除
从零开始计数

正常态 · 轻量路径

healthKey 不存在
开销 = 2×EXISTS,零写入

记录态 · 滑动窗口计数中

healthKey 存在,每次 RPUSH + LTRIM
只有失败才 LRANGE 遍历算成功率

屏蔽态 · 渠道已摘除

blockKey 存在,TTL = blockMinutes
开销 = 1×EXISTS,不再记录

这张图也解释了为什么这个方案平时几乎不花钱:绝大多数时间渠道都停在正常态,只有真出问题时才会短暂进入右边两个状态。

2. 方案特点

特性 说明
原子性 Lua 脚本单次执行,记录+统计+屏蔽不会被并发打断
多实例共享 基于 Redis,所有实例共享健康状态
自动屏蔽 + 恢复 屏蔽用 Key TTL 自动过期,恢复后清零重新计数
实时告警 通过 Lua 返回值判断,只在首次触发时通知,不重复告警
性能友好 正常运行时只需 2x EXISTS,失败时才走重路径
配置热更新 窗口大小、阈值、屏蔽时长均可动态调整,无需重启
零侵入 业务代码只需 try-finally + isBlocked 两处改动

这张表只列了它擅长的部分,别忘了配着第七节的三条边界一起看——尤其是"恢复不做探活"这条,它决定了这个方案适不适合你的场景。

3. 适用场景

这个方案不局限于支付,任何依赖三方接口的场景都可以用:

  • 三方支付渠道(微信、支付宝、Stripe)
  • 短信服务商(阿里云短信、腾讯云短信)
  • 物流查询接口
  • 第三方登录(微信登录、Google 登录)

方案不大,但五脏俱全。如果你也在对接三方接口,不妨试试这个轻量级方案——毕竟,凌晨 3 点被告警叫醒和早上 9 点看到"已自动处理",体验差的不是一点半点 😄

觉得有帮助的话,点个赞收藏一下,下次三方接口挂了就不慌了 🍻


延伸阅读


📅 最后更新:2026-08-22,新增第七节「方案的边界」(恢复不做半开探活 / isBlocked 无本地缓存 / 自动恢复无通知);修正 expireSeconds 的作用说明(它是 healthKey 自身的窗口有效期,与屏蔽时长无关);补充「成功的判据不能只看有没有抛异常」与重试记录层级两个接入坑;精确化各场景 Redis 开销。

🏷️ 标签Redis Lua 脚本 熔断降级 健康检查 滑动窗口 三方接口

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐