三方接口挂了还在傻傻调?教你用 Redis Lua 实现自动熔断
📝 摘要:对接三方接口最怕它悄悄挂掉还在傻调,本文用 Redis + Lua 脚本实现轻量级健康检查与自动熔断:滑动窗口统计最近 N 次调用成功率,低于阈值自动屏蔽渠道并告警,屏蔽 Key 靠 TTL 自动恢复;多实例共享状态、脚本原子执行、正常态零写入,业务侧只需 try-finally 记录加 isBlocked 过滤两处改动即可零侵入接入。
对接三方接口最怕什么?不是接口报错——而是接口悄悄挂了,你还在一个劲儿地调,用户体验持续恶化,直到告警炸了手机你才知道。本文分享一个基于 Redis + Lua 的三方接口健康检查方案,实现自动检测 + 自动屏蔽 + 自动恢复 + 实时告警,让三方接口"挂了也不慌"。
一、问题场景
假设我们对接了多个三方支付渠道(微信支付、支付宝、Stripe 等),正常情况下用户可以选择所有支付方式。
但如果某个渠道突然抽风了呢?
用户点支付 → 调支付宝 → 超时 → 重试 → 又超时 → 用户骂街 → 你背锅
理想情况下,我们希望系统能做到:
- 自动检测:实时监控每个渠道的调用成功率
- 自动屏蔽:成功率低于阈值时,自动从支付方式列表中移除
- 自动恢复:屏蔽一段时间后自动恢复,给渠道一个"改过自新"的机会
- 实时告警:触发屏蔽时通知相关人员
一句话总结:最近 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. 整体流程
整个方案就四个环节,业务侧真正要动的只有两处——调用后记一次结果,取列表时过滤一次:

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 遍历统计与触发屏蔽的重路径
绿色是两条快速返回的路径,橙色才是失败时的重路径,红色是触发屏蔽。对应到代码:
-- 三方接口健康检查:记录调用结果 + 判定是否屏蔽
-- 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,阈值=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 到期后自动回到正常态从零计数
这张图也解释了为什么这个方案平时几乎不花钱:绝大多数时间渠道都停在正常态,只有真出问题时才会短暂进入右边两个状态。
2. 方案特点
| 特性 | 说明 |
|---|---|
| 原子性 | Lua 脚本单次执行,记录+统计+屏蔽不会被并发打断 |
| 多实例共享 | 基于 Redis,所有实例共享健康状态 |
| 自动屏蔽 + 恢复 | 屏蔽用 Key TTL 自动过期,恢复后清零重新计数 |
| 实时告警 | 通过 Lua 返回值判断,只在首次触发时通知,不重复告警 |
| 性能友好 | 正常运行时只需 2x EXISTS,失败时才走重路径 |
| 配置热更新 | 窗口大小、阈值、屏蔽时长均可动态调整,无需重启 |
| 零侵入 | 业务代码只需 try-finally + isBlocked 两处改动 |
这张表只列了它擅长的部分,别忘了配着第七节的三条边界一起看——尤其是"恢复不做探活"这条,它决定了这个方案适不适合你的场景。
3. 适用场景
这个方案不局限于支付,任何依赖三方接口的场景都可以用:
- 三方支付渠道(微信、支付宝、Stripe)
- 短信服务商(阿里云短信、腾讯云短信)
- 物流查询接口
- 第三方登录(微信登录、Google 登录)
方案不大,但五脏俱全。如果你也在对接三方接口,不妨试试这个轻量级方案——毕竟,凌晨 3 点被告警叫醒和早上 9 点看到"已自动处理",体验差的不是一点半点 😄
觉得有帮助的话,点个赞收藏一下,下次三方接口挂了就不慌了 🍻
延伸阅读
- 《三方支付接入方案》 —— 健康检查熔断最典型的落地场景,就是三方支付渠道对接
- 《Redis 集群不支持 Lua 脚本?别被报错骗了,真相没那么简单》 —— 本方案核心是 Redis Lua 滑动窗口,集群环境下 Lua 有坑要避
- 《AI 服务 502 雪崩排查:从 Nginx 超时到连接池耗尽,查了两次才找到真凶》 —— 下游不可用拖垮连接池引发雪崩,正是熔断要解决的现实动机
- Redis 官方文档:EVAL 命令(Lua 脚本) —— 本方案的原子性根基。官方明确要求「脚本访问的所有 Key 必须作为入参显式传入」,这正是文中用 Hash Tag 把 healthKey / blockKey 收进同一 slot 的原因
📅 最后更新:2026-08-22,新增第七节「方案的边界」(恢复不做半开探活 / isBlocked 无本地缓存 / 自动恢复无通知);修正 expireSeconds 的作用说明(它是 healthKey 自身的窗口有效期,与屏蔽时长无关);补充「成功的判据不能只看有没有抛异常」与重试记录层级两个接入坑;精确化各场景 Redis 开销。
🏷️ 标签:Redis Lua 脚本 熔断降级 健康检查 滑动窗口 三方接口
更多推荐




所有评论(0)