Sentinel + 幂等:限流熔断与接口幂等
Sentinel + 幂等:限流熔断与接口幂等
一、Sentinel 流量防卫兵
1.1 Sentinel 能做什么
Sentinel = 限流 + 熔断 + 降级 + 系统保护 + 热点探测
| 功能 | 解决的问题 | 类比 |
|------|-----------|------|
| 流量控制 | 防止瞬时流量打垮服务 | 高速公路收费站限速 |
| 熔断降级 | 下游服务不可用时快速失败,避免级联故障 | 电路保险丝 |
| 系统保护 | 根据系统负载(CPU/Load)自适应限流 | 人体自我保护机制 |
| 热点参数限流 | 对特定参数值限流(如某个商品 ID) | 某件爆款商品限购 |
1.2 核心概念:资源、规则、Slot Chain
Resource(资源)= 被保护的东西(接口、方法、代码块)
Rule(规则)= 怎么保护(限流阈值、熔断策略)
Slot Chain(插槽链)= Sentinel 内部的处理流水线
Slot Chain 执行流程:
Entry 进入 ->
-> NodeSelectorSlot(收集资源路径,构建树形结构)
-> ClusterBuilderSlot(聚类统计,记录全局指标)
-> StatisticSlot(核心统计:QPS、线程数、异常数)
-> FlowSlot(限流检查)
-> DegradeSlot(熔断降级检查)
-> SystemSlot(系统保护检查)
-> AuthoritySlot(黑白名单检查)
-> 通过 -> 执行目标代码
1.3 流量控制
1.3.1 限流策略
| 策略 | 原理 | 适用场景 |
|------|------|----------|
| 直接拒绝(Reject) | 超过阈值直接抛 BlockException | 常规接口 |
| Warm Up(预热) | 冷启动,阈值从 1/3 逐步增长到设定值 | 秒杀系统启动时 |
| 匀速排队(Rate Limiter) | 漏桶算法,请求匀速通过,超时拒绝 | 处理突发流量,排队消费 |
// 直接拒绝
FlowRule rule = new FlowRule("queryGoods");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 按 QPS
rule.setCount(100); // 阈值 100 QPS
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_REJECT);
// Warm Up 预热
FlowRule warmUpRule = new FlowRule("seckill");
warmUpRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
warmUpRule.setCount(1000);
warmUpRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP);
warmUpRule.setWarmUpPeriodSec(10); // 10秒内从 333 增长到 1000
// 匀速排队
FlowRule rateLimiterRule = new FlowRule("order");
rateLimiterRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rateLimiterRule.setCount(50);
rateLimiterRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER);
rateLimiterRule.setMaxQueueingTimeMs(500); // 最大排队等待 500ms
1.3.2 流控模式
| 模式 | 含义 | 示例 |
|------|------|------|
| 直接 | 资源本身达到阈值就限流 | 接口 QPS > 100 就限 |
| 关联 | 关联资源达到阈值就限流本资源 | 写接口繁忙时限制读接口 |
| 链路 | 只针对从某个链路过来的请求限流 | 只限制从 Gateway 来的请求 |
关联模式(Write 影响 Read):
// 当 /api/order/write(写订单)QPS 超过 50 时,限制 /api/order/read
FlowRule rule = new FlowRule("/api/order/read");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(200);
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_REJECT);
rule.setRefResource("/api/order/write"); // 关联资源
rule.setStrategy(RuleConstant.STRATEGY_RELATE); // 关联模式
链路模式(只限制特定入口):
// 只对从 Gateway 进入的请求限流,内部调用不受影响
FlowRule rule = new FlowRule("orderService.create");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(100);
rule.setStrategy(RuleConstant.STRATEGY_CHAIN);
rule.setRefResource("gateway-entry"); // 入口资源名
1.4 熔断降级
1.4.1 熔断 vs 降级 vs 限流
| 概念 | 方向 | 触发条件 | 类比 |
|------|------|----------|------|
| 限流 | 保护自己 | 自己流量过大 | 收费站限流 |
| 熔断 | 保护自己 | 下游服务不可用 | 电路保险丝跳闸 |
| 降级 | 保护上游 | 自身负载过高或下游不可用 | 飞机抛货物减轻重量 |
一句话理解:限流防自己被打死,熔断防别人拖死自己,降级是为了保命主动放弃功能。
1.4.2 熔断策略
| 策略 | 判断指标 | 触发条件 |
|------|----------|----------|
| 慢调用比例 | 响应时间(RT) | 慢调用比例 > 阈值 |
| 异常比例 | 异常数 / 总请求数 | 异常比例 > 阈值 |
| 异常数 | 异常数量 | 异常数 > 阈值 |
// 慢调用比例熔断
DegradeRule slowRule = new DegradeRule("callUserService");
slowRule.setGrade(RuleConstant.DEGRADE_GRADE_SLOW_REQUEST_RATIO);
slowRule.setCount(0.5); // 慢调用比例超过 50% 触发熔断
slowRule.setSlowRatioThreshold(0.5);
slowRule.setTimeWindow(10); // 熔断持续 10 秒
slowRule.setMinRequestAmount(5); // 最小请求数(少于 5 个不统计)
slowRule.setStatIntervalMs(1000); // 统计时长 1 秒
// 异常比例熔断
DegradeRule errorRule = new DegradeRule("callPaymentService");
errorRule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO);
errorRule.setCount(0.3); // 异常比例超过 30%
errorRule.setTimeWindow(10); // 熔断 10 秒
errorRule.setMinRequestAmount(5);
// 异常数熔断
DegradeRule errorCountRule = new DegradeRule("callLogisticsService");
errorCountRule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_COUNT);
errorCountRule.setCount(10); // 异常数超过 10 个就熔断
errorCountRule.setTimeWindow(10); // 熔断 10 秒
errorCountRule.setMinRequestAmount(5);
1.4.3 熔断状态机
CLOSED(关闭) -> 正常放行,统计异常/慢调用比例
↓ 达到熔断阈值
OPEN(打开) -> 所有请求直接拒绝,开始倒计时(TimeWindow)
↓ TimeWindow 结束
HALF_OPEN(半开) -> 放行一个请求探测
↓ 成功 -> CLOSED(恢复)
↓ 失败 -> OPEN(继续熔断)
1.5 热点参数限流
热点参数限流:对同一个接口的不同参数值分别限流。
// 接口:查询商品详情
@GetMapping("/goods/{id}")
@SentinelResource(value = "queryGoods", blockHandler = "handleBlock")
public Result queryGoods(@PathVariable Long id) { ... }
// 热点规则:商品 ID=1 的 QPS 限制为 10,其他商品 ID 默认 100
ParamFlowRule rule = new ParamFlowRule("queryGoods");
rule.setParamIdx(0); // 第 0 个参数(id)
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(100); // 默认阈值
// 特殊参数值单独限流
ParamFlowItem item = new ParamFlowItem();
item.setObject("1"); // 商品 ID = 1(爆款商品)
item.setCount(10); // 只允许 10 QPS
item.setClassType(long.class.getName());
rule.setParamFlowItemList(List.of(item));
应用场景:
- 秒杀场景中,某个热门商品被大量请求访问,单独限制该商品 ID 的 QPS
- 防刷:同一个用户 ID 短时间内大量请求,限制该用户的访问频率
1.6 系统保护(Adaptive Protection)
// 当系统负载过高时,自动拒绝新请求
SystemRule rule = new SystemRule();
rule.setHighestSystemLoad(5.0); // 系统 Load > 5 触发保护
rule.setHighestCpuUsage(0.8); // CPU 使用率 > 80% 触发
rule.setAvgRt(50); // 平均 RT > 50ms 触发
rule.setQps(1000); // QPS > 1000 触发
rule.setThreadCount(200); // 线程数 > 200 触发
系统保护是最后一道防线,当上述所有规则都没拦住,但系统已经快撑不住了,系统保护自适应地拒绝请求。
1.7 与 OpenFeign 整合
// 方式一:@FeignClient + Sentinel 自动生效(需开启配置)
@FeignClient(name = "user-service", fallback = UserClientFallback.class)
public interface UserClient {
@GetMapping("/api/users/{id}")
Result<User> getUserById(@PathVariable Long id);
}
// 配置开启
feign:
sentinel:
enabled: true
// 方式二:手动 @SentinelResource 包裹 Feign 调用
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private UserClient userClient;
@SentinelResource(value = "getUserFromService",
fallback = "getUserFallback",
blockHandler = "getUserBlockHandler")
public User getUser(Long id) {
return userClient.getUserById(id).getData();
}
// fallback:业务降级(服务异常时调用)
public User getUserFallback(Long id, Throwable e) {
return new User(id, "默认用户");
}
// blockHandler:被 Sentinel 限流/熔断时调用
public User getUserBlockHandler(Long id, BlockException e) {
throw new BizException("服务被限流/熔断");
}
}
fallback vs blockHandler 的区别:
| | fallback | blockHandler |
|---|----------|-------------|
| 触发原因 | 业务异常(下游服务抛异常) | Sentinel 限流/熔断(BlockException) |
| 方法签名 | 与原方法相同 + Throwable 参数 | 与原方法相同 + BlockException 参数 |
| 返回类型 | 必须与原方法相同 | 必须与原方法相同 |
| 用途 | 服务降级,返回兜底数据 | 限流/熔断提示 |
1.8 Sentinel Dashboard 与 Nacos 持久化
问题:Sentinel Dashboard 配置的规则默认存储在内存中,重启后丢失。
解决方案:将规则持久化到 Nacos。
# 应用侧配置
spring:
cloud:
sentinel:
datasource:
flow: # 流控规则
nacos:
server-addr: ${NACOS_ADDR}
data-id: ${spring.application.name}-flow-rules
group-id: SENTINEL_GROUP
rule-type: flow
degrade: # 降级规则
nacos:
server-addr: ${NACOS_ADDR}
data-id: ${spring.application.name}-degrade-rules
group-id: SENTINEL_GROUP
rule-type: degrade
规则同步流程:
Dashboard 修改规则 -> 写入 Nacos ->
应用侧监听 Nacos 配置变化 -> 加载新规则到内存
应用侧规则变化 -> 推送至 Nacos -> Dashboard 读取展示
二、接口幂等性
2.1 什么是幂等
幂等性(Idempotency):同一个操作,执行一次和执行多次,结果相同。
GET /users/1 幂等(查多少次都一样)
DELETE /users/1 幂等(删多少次都是删除)
PUT /users/1 幂等(覆盖更新,结果一致)
POST /users 非幂等(每次创建新用户)
为什么需要保证幂等:
1. 网络重试:RPC 调用超时,调用方重试导致重复请求
2. 前端重复提交:用户连续点击"提交"按钮
3. 消息队列消费:消息重复投递
4. 支付回调:第三方支付重复通知
2.2 Token 机制(防重放)
思路:请求前先获取一个一次性 Token,提交时携带 Token,服务端校验并删除。
前端 -> [获取 Token] -> 服务端生成 Token 存入 Redis -> 返回 Token
前端 -> [提交请求 + Token] -> 服务端校验 Token(DEL 原子操作)->
-> Token 存在:执行业务
-> Token 不存在:拒绝(重复提交)
// 1. 生成 Token
@GetMapping("/token")
public Result<String> getToken() {
String token = UUID.randomUUID().toString().replace("-", "");
// 存入 Redis,设置过期时间 5 分钟
redisTemplate.opsForValue().set("idempotent:token:" + token, "1", 5, TimeUnit.MINUTES);
return Result.success(token);
}
// 2. 校验 Token(Lua 脚本保证原子性)
@PostMapping("/order")
public Result createOrder(@RequestHeader("X-Idempotent-Token") String token,
@RequestBody OrderDTO dto) {
// Lua 脚本:检查 + 删除,原子操作
String lua = "if redis.call('get', KEYS[1]) == '1' then " +
" redis.call('del', KEYS[1]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(lua, Long.class),
List.of("idempotent:token:" + token)
);
if (result == 0) {
return Result.fail("请勿重复提交");
}
// 执行业务逻辑
orderService.create(dto);
return Result.success();
}
为什么用 Lua 而不是先 get 再 del:
// 非原子操作的问题:
if (redis.get(key) != null) { // 线程 A 和 B 同时 get 到 token
redis.del(key); // A 和 B 都删除成功,都执行业务 -> 重复
}
// Lua 脚本原子执行:
// GET + DEL 在 Redis 中是单线程执行的,不存在并发问题
2.3 数据库唯一索引
思路:利用数据库唯一约束,重复插入会报 DuplicateKeyException。
// 订单表增加唯一索引
// CREATE UNIQUE INDEX uk_order_no ON orders(order_no);
@PostMapping("/order")
public Result createOrder(@RequestBody OrderDTO dto) {
Order order = new Order();
order.setOrderNo(dto.getOrderNo()); // 前端生成的唯一订单号
order.setUserId(dto.getUserId());
order.setAmount(dto.getAmount());
try {
orderMapper.insert(order);
return Result.success();
} catch (DuplicateKeyException e) {
return Result.fail("订单已存在,请勿重复提交");
}
}
适用场景:
- 订单创建、支付记录插入等需要持久化的场景
- 前端生成唯一业务编号(如
ORDER_用户ID_时间戳_随机数)
注意事项:
- 唯一索引冲突会抛异常,需要捕获并友好提示
- 在事务中使用,异常会回滚,不影响数据一致性
- 配合
INSERT ... ON DUPLICATE KEY UPDATE可实现幂等更新
2.4 去重表
// 去重表结构
// CREATE TABLE idempotent_record (
// id BIGINT PRIMARY KEY AUTO_INCREMENT,
// biz_type VARCHAR(32) NOT NULL, -- 业务类型
// biz_no VARCHAR(64) NOT NULL, -- 业务唯一编号
// status TINYINT DEFAULT 0, -- 处理状态
// create_time DATETIME DEFAULT NOW(),
// UNIQUE KEY uk_biz (biz_type, biz_no)
// ) ENGINE=InnoDB;
@Transactional
public void processPayment(String orderNo, BigDecimal amount) {
// 1. 插入去重表(唯一约束保证幂等)
IdempotentRecord record = new IdempotentRecord();
record.setBizType("PAYMENT");
record.setBizNo(orderNo);
int rows = idempotentMapper.insert(record);
if (rows == 0) {
// 唯一冲突,说明已处理过
log.warn("重复请求,orderNo={}", orderNo);
return;
}
// 2. 执行实际业务
paymentService.doPay(orderNo, amount);
// 3. 更新状态
idempotentMapper.updateStatus(orderNo, 1);
}
去重表 vs 唯一索引:
- 去重表独立于业务表,不污染业务数据模型
- 可以记录处理状态、重试次数、处理结果等元数据
- 适合 MQ 消费场景(消息 ID 作为 bizNo)
2.5 分布式锁实现幂等
@PostMapping("/order")
public Result createOrder(@RequestBody OrderDTO dto) {
String lockKey = "order:create:" + dto.getUserId();
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,最多等待 3 秒,锁自动释放时间 10 秒
if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
return Result.fail("操作过于频繁,请稍后重试");
}
// 检查是否已存在订单
Order exist = orderMapper.selectByOrderNo(dto.getOrderNo());
if (exist != null) {
return Result.success("订单已存在");
}
// 创建订单
orderService.create(dto);
return Result.success();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
适用场景:需要"先查询再插入"的幂等场景(防止并发插入)。
2.6 各种方案对比
| 方案 | 性能 | 可靠性 | 适用场景 | 局限性 |
|------|------|--------|----------|--------|
| Token 机制 | 高(Redis 操作) | 高(Lua 原子) | 表单提交、前端防重 | 需要额外获取 Token 的接口 |
| 唯一索引 | 中(DB 操作) | 高(DB 保证) | 订单创建、支付记录 | 依赖数据库,高并发时 DB 压力大 |
| 去重表 | 中(DB 操作) | 高(DB 保证) | MQ 消费、异步任务 | 需要维护额外表 |
| 分布式锁 | 中(Redis 操作) | 高(Redisson 保证) | 先查后写场景 | 加锁有性能开销 |
| 状态机 | 高 | 中 | 订单状态流转 | 只能保证状态不逆向流转 |
2.7 状态机幂等
订单状态流转天然适合幂等:只允许正向流转,相同状态的重复操作不生效。
public boolean updateOrderStatus(Long orderId, OrderStatus from, OrderStatus to) {
int rows = orderMapper.updateStatus(orderId, from, to);
// UPDATE orders SET status = #{to}
// WHERE id = #{orderId} AND status = #{from}
// rows = 0 说明当前状态不是 from(已被处理或状态已变更)
return rows > 0;
}
// 使用
boolean success = updateOrderStatus(orderId, PAID, SHIPPED);
if (!success) {
log.info("订单状态已变更,跳过处理, orderId={}", orderId);
}
2.8 MQ 消费幂等
@RabbitListener(queues = "order.queue")
public void handleOrderMessage(Message message, Channel channel) {
String msgId = message.getMessageProperties().getMessageId();
String bizNo = extractBizNo(message);
// 方案一:用消息 ID 做去重
String key = "mq:dedup:" + msgId;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(success)) {
// 消息已处理,直接 ACK
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
return;
}
try {
// 处理消息
processOrder(bizNo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
// 处理失败,NACK 重试
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
throw e;
}
}
三、Sentinel + 幂等 生产实践
3.1 接口防重复提交的完整方案
前端:提交后禁用按钮 + 路由拦截(防用户操作)
↓
网关层:Token 校验(防重放攻击)
↓
应用层:分布式锁(防并发提交)
↓
数据层:唯一索引(最后一道防线)
3.2 Sentinel 限流阈值怎么定
1. 压测:用 JMeter/Wrk 逐步增加并发,找到系统的最大 QPS
2. 除以安全系数:阈值 = 压测最大 QPS * 0.8(留 20% 余量)
3. 观察生产指标:根据实际 CPU/内存/RT 调整
4. 分级设置:
- 核心接口(下单/支付):保守(阈值偏低)
- 普通接口(查询):宽松
- 非核心接口(日志/统计):可以限掉
3.3 被限流后怎么给前端反馈
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BlockException.class)
public Result handleBlockException(BlockException e) {
if (e instanceof FlowException) {
return Result.fail("系统繁忙,请稍后重试");
}
if (e instanceof DegradeException) {
return Result.fail("服务降级中,请稍后重试");
}
return Result.fail("请求被拦截");
}
}
3.4 缓存 + 降级 组合拳
@SentinelResource(value = "getGoodsDetail",
fallback = "getGoodsDetailFallback")
public GoodsDTO getGoodsDetail(Long id) {
// 1. 先查缓存
GoodsDTO cached = cacheManager.get("goods:" + id);
if (cached != null) {
return cached;
}
// 2. 查数据库
GoodsDTO goods = goodsMapper.selectById(id);
cacheManager.put("goods:" + id, goods, 30, TimeUnit.MINUTES);
return goods;
}
// 降级方法:返回缓存中的旧数据
public GoodsDTO getGoodsDetailFallback(Long id, Throwable e) {
// 尝试从缓存获取旧数据
return cacheManager.get("goods:" + id);
}

浙公网安备 33010602011771号