Redis持久化Sentinel规则

48 阅读11分钟

前段时间,公司项目出现一个问题,程序内部有一些接口提供给第三方调用,虽然这些接口做了鉴权,但是没有做任何限流措施。结果第三方疯狂的调用这些接口,导致系统处于崩溃边缘。后来经过讨论后,决定采用sentinel来做流量治理。毫无疑问,这个雷又要我来埋了。虽然之前有了解过sentinel,但也仅仅是皮毛,并没有真正在项目中使用过。所以借助这次机会,我也对sentinel进行了一次详细的学习。

官网地址 sentinelguard.io/zh-cn/index…

源码地址 github.com/alibaba/Sen…

通过阅读官方文档和源码,发现默认情况下sentinel结合dashboard的规则都是存储在内存中的,那也就意味着,如果项目重启,所有的规则都会被抹掉。那这种肯定是不行的,项目每启动一次,都需要配置一遍规则,所以规则需要进行持久化。sentinel是alibaba开源的项目,官方更推荐使用nacos做规则配置持久化,但是为了限流多搭一套nacos环境,我是比较排斥的,所以基于公司现有情况,准备出两种持久化方案,一种是数据库,一种是Redis。

image.png 如果选择数据库,那就需要建表,写sql语句,应用服务还需要监听规则的变化等。但是redis就会便捷很多,只需要使用缓存和发布订阅功能就可以解决问题。详细流程可以分为4个步骤:

  1. dashboard将规则持久化到redis,并通过发布订阅功能通知应用服务更新规则
  2. 应用服务接收到更新规则的消息后,从缓存中读取最新规则并更新
  3. dashboard重启后可以从redis中读取数据
  4. 应用服务重启后可以从redis中读取规则数据

image.png

流程梳理完成后,就开始着手编码吧。

自定义数据源

首先需要定义好监听的规则,即每种规则需要有不同的处理方式,需要存储在不同的key下。所以在定义数据源前,先定义一个枚举类,用来区分消息类型,缓存的key值。比如redis监听到的消息是flow,那就代表限流规则更新了,那么就从redis中拉取key为sentinel_flow_simp_{应用名}的缓存。

public enum SentinelFunctionEnum {

   FLOW("flow","sentinel_flow_simp_","限流规则"),
   AUTH("auth","sentinel_auth_simp_","授权规则"),
   SYSTEM("system","sentinel_system_simp_","系统规则"),
   DEGRADE("degrade","sentinel_degrade_simp_","熔断规则"),
   ;

   public String function;

   public String key;

   public String desc;

   SentinelFunctionEnum(String function,String key,String desc){
      this.function = function;
      this.key = key;
      this.desc = desc;
   }

   public String getFunction() {
      return function;
   }

   public String getKey() {
      return key;
   }

   public String getDesc() {
      return desc;
   }
}

枚举类定义完成后,开始自定义数据源,sentinel提供了一个抽象类AbstractDataSource,通过继承这个父类,并重写readSource方法,即可自定义数据源。

/**
 * 扩展的数据源需要继承AbstractDataSource类
 * @param <T>
 */
public class SentinelRedisDataSource<T> extends AbstractDataSource<String,T> {


   public RedisTemplate redisTemplate;

   // redis订阅的channel,建议设置为应用名
   // 一个应用服务对应一个channel,在通过消息内容区分不同的规则
   // 可以理解为rocketmq中的Topic和tag的关系
   public String channel;

   // 定义的枚举类
   public SentinelFunctionEnum sentinelFunctionEnum;

   public SentinelRedisDataSource(RedisTemplate redisTemplate, String channel, SentinelFunctionEnum sentinelFunctionEnum,Converter<String, T> parser) {
      super(parser);
      this.redisTemplate = redisTemplate;
      this.channel=channel;
      this.sentinelFunctionEnum = sentinelFunctionEnum;
   }

   /**
    * 从redis中获取规则
    * @return
    * @throws Exception
    */
   @Override
   public String readSource() throws Exception {
      // 读取规则的key
      Object sentinelJob = redisTemplate.opsForValue().get(sentinelFunctionEnum.getKey()+channel);
      return String.valueOf(sentinelJob);
   }

   /**
    * 关闭数据源连接(这里使用的是redisTemplate,不用关闭)
    * @throws Exception
    */
   @Override
   public void close() throws Exception {

   }
}

加载数据到规则管理器

数据源定义完成后,需要在项目启动的时候注册数据源并且加载上次的规则到管理器中。需要注意的是,每种规则的管理器是不一样的。流控规则管理器对应FlowRuleManager,授权规则管理器对应AuthorityRuleManager,系统规则对应SystemRuleManager等。

/**
 * 项目启动后需要注册数据源并恢复原有流控规则
 */
@PostConstruct
public void ruleManager() {

   try {
      // 将扩展的redis数据源注册到流控规则管理中
      // 1. 流控规则
      ReadableDataSource<String, List<FlowRule>> flowRuleDataSource
            = new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.FLOW, source -> JSON.parseObject((String) source, new TypeReference<List<FlowRule>>() {
      }));
      FlowRuleManager.register2Property(flowRuleDataSource.getProperty());
      // 2. 授权规则
      ReadableDataSource<String, List<AuthorityRule>> authRuleDataSource
            = new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.AUTH, source -> JSON.parseObject((String) source, new TypeReference<List<AuthorityRule>>() {
      }));
      AuthorityRuleManager.register2Property(authRuleDataSource.getProperty());

      // 3. 系统规则
      ReadableDataSource<String, List<SystemRule>> systemRuleDataSource
            = new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.SYSTEM, source -> JSON.parseObject((String) source, new TypeReference<List<SystemRule>>() {
      }));
      SystemRuleManager.register2Property(systemRuleDataSource.getProperty());

      // 4. 熔断规则
      ReadableDataSource<String, List<DegradeRule>> degRuleDataSource
            = new SentinelRedisDataSource(redisTemplate, channel, SentinelFunctionEnum.DEGRADE, source -> JSON.parseObject((String) source, new TypeReference<List<DegradeRule>>() {
      }));
      DegradeRuleManager.register2Property(degRuleDataSource.getProperty());

      // 1.1 恢复原有流控规则
      Object flowJobObj = redisTemplate.opsForValue().get(SentinelFunctionEnum.FLOW.getKey() + channel);
      if (flowJobObj != null) {
         List<FlowRule> flowRules = JSONObject.parseArray(String.valueOf(flowJobObj), FlowRule.class);
         WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(flowRules));
         FlowRuleManager.loadRules(flowRules);
      }

      // 2.1 恢复原有的授权规则
      Object authObj = redisTemplate.opsForValue().get(SentinelFunctionEnum.AUTH.getKey() + channel);
      if (authObj != null) {
         List<AuthorityRule> authorityRules = JSONObject.parseArray(String.valueOf(authObj), AuthorityRule.class);
         WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(authorityRules));
         AuthorityRuleManager.loadRules(authorityRules);
      }

      // 3.1 恢复原有的系统规则
      Object systemObj = redisTemplate.opsForValue().get(SentinelFunctionEnum.SYSTEM.getKey() + channel);
      if (systemObj != null) {
         List<SystemRule> systemRules = JSONObject.parseArray(String.valueOf(systemObj), SystemRule.class);
         SystemRuleManager.loadRules(systemRules);
      }

      // 4.1 恢复原有的熔断规则
      Object degJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.DEGRADE.getKey() + channel);
      if (degJob != null) {
         List<DegradeRule> degradeRules = JSONObject.parseArray(String.valueOf(degJob), DegradeRule.class);
         WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(degradeRules));
         DegradeRuleManager.loadRules(degradeRules);
      }
   } catch (Exception e) {
      log.error("【sentinel初始化异常】");
      e.printStackTrace();
   }
}

动态刷新规则

数据源注册完成后,需要监听dashboard服务发布的消息,从缓存中读取最新的规则数据,并刷新规则内容数据。首先需要订阅消息,监听规则内容的变化。


/**
 * 订阅Topic
 *
 * @param connectionFactory
 * @param listenerAdapter
 * @return
 */
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) {
   RedisMessageListenerContainer container = new RedisMessageListenerContainer();
   container.setConnectionFactory(connectionFactory);
   //订阅通道(项目名)
   container.addMessageListener(listenerAdapter, new PatternTopic(channel));
   return container;
}

/**
 * 监听消息
 *
 * @param receiver 自定义的监听类
 * @return
 */
@Bean
public MessageListenerAdapter listenerAdapter(RedisReceiver receiver) {
   // handel为消息处理的方法
   return new MessageListenerAdapter(receiver, "handel");
}

监听到消息后,需要根据不同的消息类型,刷新对应的规则数据。RestfulUrlCleaner.create在下文会提及到,这里暂不做描述。

@Component
public class RedisReceiver {

   @Autowired
   private RedisTemplate redisTemplate;

   @Value("${spring.application.name:open}")
   private String channel;

   /**
    * 监听消息的处理方法   sentinel-dashboard更新规则时,会通过message来告知哪一项规则发生了变动
    *
    * @param message 消息类型 -> SentinelFunctionEnum
    */
   public void handel(String message) {
      // 限流的消息
      if (StrUtil.contains(message, SentinelFunctionEnum.FLOW.getFunction())) {
         Object flowJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.FLOW.getKey() + channel);
         if (flowJob != null) {
            List<FlowRule> flowRules = JSONObject.parseArray(String.valueOf(flowJob), FlowRule.class);
            WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(flowRules));
            // 刷新规则
            FlowRuleManager.loadRules(JSONObject.parseArray(String.valueOf(flowJob), FlowRule.class));
         }
      }
      // 授权的消息
      if (StrUtil.contains(message, SentinelFunctionEnum.AUTH.getFunction())) {
         Object authJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.AUTH.getKey() + channel);
         if (authJob != null) {
            List<AuthorityRule> authorityRules = JSONObject.parseArray(String.valueOf(authJob), AuthorityRule.class);
            WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(authorityRules));
            AuthorityRuleManager.loadRules(authorityRules);
         }
      }
      // 系统规则消息
      if (StrUtil.contains(message, SentinelFunctionEnum.SYSTEM.getFunction())) {
         Object sysJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.SYSTEM.getKey() + channel);
         if (sysJob != null) {
            List<SystemRule> systemRules = JSONObject.parseArray(String.valueOf(sysJob), SystemRule.class);
            SystemRuleManager.loadRules(systemRules);
         }
      }
        // 熔断规则消息
      if(StrUtil.contains(message, SentinelFunctionEnum.DEGRADE.getFunction())){
         Object degJob = redisTemplate.opsForValue().get(SentinelFunctionEnum.DEGRADE.getKey() + channel);
         if(degJob!=null){
            List<DegradeRule> degRules = JSONObject.parseArray(String.valueOf(degJob), DegradeRule.class);
            WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(degRules));
            DegradeRuleManager.loadRules(degRules);
         }
      }
   }
}

sentinel-dashboard源码修改

上面应用服务的代码已经撸完了,现在开始改造dashboard的代码,原代码中规则变动的通知都是通过http方式进行的,那么这就需要改造成redis来通知的方式。默认情况下dashboard提供了两个接口类来支持我们自定义改造,其分别是DynamicRulePublisher(推送规则)和DynamicRuleProvider(拉取规则)。 image.png

在改造前,需要定义好redis的key,需要注意的是一种规则需要定义两个key,其中SIMP_KEY是需要和应用服务中的对应,另一个key则是留给当前dashoard回显数据用。同一种规则为什么要定义两个key呢?这是因为dashboard服务和应用服务规则对象是不一致的,以限流规则为例: dashboard服务中的规则对象是FlowRuleEntity,应用服务的规则对象是FlowRule。

定义缓存key

public class RuleConstants {

    // ======= 限流相关的key ========
    public static final String SENTINEL_FLOW_KEY = "sentinel_flow_";

    public static final String SENTINEL_FLOW_RULE_SIMP_KEY = "sentinel_flow_simp_";

    // ====== 授权相关的key ========
    public static final String SENTINEL_AUTH_KEY = "sentinel_auth_";

    public static final String SENTINEL_AUTH_RULE_SIMP_KEY = "sentinel_auth_simp_";

    // ====== 系统规则相关的key ========
    public static final String SENTINEL_SYSTEM_KEY = "sentinel_system_";

    public static final String SENTINEL_SYSTEM_RULE_SIMP_KEY = "sentinel_system_simp_";

    // ====== 熔断规则相关的key ========
    public static final String SENTINEL_DEGRADE_KEY = "sentinel_degrade_";

    public static final String SENTINEL_DEGRADE_RULE_SIMP_KEY ="sentinel_degrade_simp_";
}

因为各种规则中间除对象不同外,Publisher和Provider逻辑基本相同,所以这里就以限流规则为例来定义相关实现类。

定义Publisher

流程逻辑是将最新的规则数据转化为FlowRule对象,存储到redis中并发布通知,告知订阅者拉取最新的规则数据。

/**
 * 流控Publisher
 */
@Component("redisFlowRuleApiPublisher")
public class RedisFlowRuleApiPublisher implements DynamicRulePublisher<List<FlowRuleEntity>> {

    @Resource
    public RedisTemplate redisTemplate;

    /**
     * 更新规则,并通知客户端刷新规则
     * @param app app name
     * @param rules list of rules to push
     * @throws Exception
     */
    @Override
    public void publish(String app, List<FlowRuleEntity> rules) throws Exception {
        if (StringUtil.isBlank(app)) {
            return;
        }
        if (rules == null) {
            rules = new ArrayList<>();
        }
        String s = JSON.toJSONString(rules);
        List<FlowRule> flowRuleList = rules.stream().map(FlowRuleEntity::toRule).collect(Collectors.toList());
        String simple = JSON.toJSONString(flowRuleList);
        redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations operations) throws DataAccessException {
                operations.multi();
                operations.opsForValue().set(RuleConstants.SENTINEL_FLOW_KEY + app,s);
                operations.opsForValue().set(RuleConstants.SENTINEL_FLOW_RULE_SIMP_KEY+ app,simple);
                // 通知订阅者,限流规则更新了
                operations.convertAndSend(app,"flow");
                return operations.exec();
            }
        });
    }

}

定义Provider

除了应用服务需要规则数据外,dashboard本身也是需要在页面上会先配置的规则数据的。那这个需要从redis中读取数据。Provider做的就是这个工作。

/**
 * 流控Provider
 */
@Component("redisFlowRuleApiProvider")
public class RedisFlowRuleApiProvider implements DynamicRuleProvider<List<FlowRuleEntity>> {

    @Resource
    public RedisTemplate redisTemplate;

    /**
     * 从redis中读取规则
     * @param appName
     * @return
     * @throws Exception
     */
    @Override
    public List<FlowRuleEntity> getRules(String appName) throws Exception {
        if (StringUtil.isBlank(appName)) {
            return new ArrayList<>();
        }
        Object json = redisTemplate.opsForValue().get(RuleConstants.SENTINEL_FLOW_KEY + appName);
        if(json!=null){
            List<FlowRuleEntity> flowRuleEntities = JSON.parseArray(String.valueOf(json), FlowRuleEntity.class);
            return flowRuleEntities;
        }
        return new ArrayList<>();
    }
}

修改controller

Provider和Publisher已经改造完成,接下来就需要在controller中调用这两个方法。并在 v2 的 controller 中通过 @Qualifier 注解替换相应的 bean 即可实现应用维度推送。 image.png 列表接口

@GetMapping("/rules")
@AuthAction(PrivilegeType.READ_RULE)
public Result<List<FlowRuleEntity>> apiQueryMachineRules(@RequestParam String app) {

    if (StringUtil.isEmpty(app)) {
        return Result.ofFail(-1, "app can't be null or empty");
    }
    try {
        // 从redis中读取规则数据,用于列表展示
        List<FlowRuleEntity> rules = ruleProvider.getRules(app);
        if (rules != null && !rules.isEmpty()) {
            for (FlowRuleEntity entity : rules) {
                entity.setApp(app);
                if (entity.getClusterConfig() != null && entity.getClusterConfig().getFlowId() != null) {
                    entity.setId(entity.getClusterConfig().getFlowId());
                }
            }
        }
        //将规则数据暂存正内存中,便于后续进行crud操作
        rules = repository.saveAll(rules);
        return Result.ofSuccess(rules);
    } catch (Throwable throwable) {
        logger.error("Error when querying flow rules", throwable);
        return Result.ofThrowable(-1, throwable);
    }
}

新增规则接口



@PostMapping("/rule")
@AuthAction(value = AuthService.PrivilegeType.WRITE_RULE)
public Result<FlowRuleEntity> apiAddFlowRule(@RequestBody FlowRuleEntity entity) {

    Result<FlowRuleEntity> checkResult = checkEntityInternal(entity);
    if (checkResult != null) {
        return checkResult;
    }
    entity.setId(null);
    Date date = new Date();
    entity.setGmtCreate(date);
    entity.setGmtModified(date);
    entity.setLimitApp(entity.getLimitApp().trim());
    entity.setResource(entity.getResource().trim());
    try {
        entity = repository.save(entity);
        publishRules(entity.getApp());
    } catch (Throwable throwable) {
        logger.error("Failed to add flow rule", throwable);
        return Result.ofThrowable(-1, throwable);
    }
    return Result.ofSuccess(entity);
}

// 实现推送消息的方法
private void publishRules(/*@NonNull*/ String app) throws Exception {
    List<FlowRuleEntity> rules = repository.findAllByApp(app);
    rulePublisher.publish(app, rules);
}

到这里持久化操作就算完成了,只需要启动项目就可以正常操作啦,即使应用服务部署多个节点也没关系,因为用的是发布订阅,所以所有节点都会进行同步更新操作。

在开发过程中遇到了一些问题,在这里也一并记录一下。分别是限制来源导致OOM和数据乱码问题,以及接口识别错误的问题。

请求来源限制

若 Web 应用使用了 Sentinel Web Filter,并希望对 HTTP 请求按照来源限流,则可以自己实现 RequestOriginParser 接口从 HTTP 请求中解析 origin 并注册至 WebCallbackManager 中。

 WebCallbackManager.setUrlCleaner(RestfulUrlCleaner.create(degRules));

但是当这段逻辑部署到测试环境的时候,发现数据库中出现了乱码,经过一系列的排查最终发现,只要请求经过这个方法,就会导致参数乱码,所以需要做一次转码操作。更悲催的事情来了,经过一系列的测试后,决定上线了,结果第二天生产环境OOM了,那这不用想了,肯定是sentinel的问题。有一次经过一系列的排查定位,结果发现还是在这个方法中,最终发现到每个Origin都会创建一个StatisticNode对象并放在内存中,并且不会释放,最终导致OOM。

@Component
public class SentinelRequestOriginParser implements RequestOriginParser {


   @Autowired
   private SessionRepository<? extends Session> repository;

   /**
    * 区分来源    当前来源数据为请求用户的用户名
    * @return
    */
   @Override
   public String parseOrigin(HttpServletRequest httpServletRequest) {
      // 先设置字符集,否则经过该方法后请求字段会乱码
      try {
         httpServletRequest.setCharacterEncoding("UTF-8");
      } catch (UnsupportedEncodingException e) {
         return "";
      }
      String username = "";
      Map<String, String[]> parameterMap = httpServletRequest.getParameterMap();
      String token = httpServletRequest.getParameter(ApplicationConstants.ACCESS_TOKEN_ATTRIBUTE_NAME);

      if (token != null) {
         Session session = repository.getSession(token);
         if (session != null) {
            /**
             * Origin不能太多 ,否则会打满内存 ClusterNode.getOrCreateOriginNode 方法中会判断Origin ,
             * 每个Origin都会创建一个StatisticNode对象并放在内存中
             * {@link com.alibaba.csp.sentinel.node.ClusterNode.getOrCreateOriginNode}
             */
            List<Integer> needLimitUserType = CollUtil.newArrayList(UserTypeEnum.JKZH.getCode());
            User user = session.getAttribute(ApplicationConstants.JKZH_USER_IN_SESSION);
            if(user!=null){
               UserTypeEnum type = user.getType();
               if(type!=null && needLimitUserType.contains(type.getCode())){
                  username = user.getUsername();
               }
            }
         }
      }
      return username;
   }
}

接口识别错误

sentinel对接口的识别并不是完美的,对于接口中带有参数则无法准确识别,比如/api/user/{userId}。这个时候sentinel会把每个userId都识别成一个独立的接口,导致限流失败,所以需要实现UrlCleaner类,自己匹配这种接口

public class RestfulUrlCleaner implements UrlCleaner {

   private List<RestfulPattern> patterns = new ArrayList<>();

   private RestfulUrlCleaner() {
   }

   /**
    * 根据流量控制规则创建与之匹配的RestfulUrlCleaner
    * @param rules 流量控制规则
    * @return RestfulUrlCleaner
    */
   public static RestfulUrlCleaner create(List<?> rules) {
      RestfulUrlCleaner cleaner = new RestfulUrlCleaner();
      cleaner.patterns.clear();
      if (rules == null || rules.size() == 0) {
         return cleaner;
      }
      Pattern p = Pattern.compile("\{[^\}]+\}");
      for (Object rule : rules) {
         String resource = null;
         if(rule instanceof FlowRule){
            resource =((FlowRule) rule).getResource();
         }else if(rule instanceof AuthorityRule){
            resource =((AuthorityRule) rule).getResource();
         }else if(rule instanceof DegradeRule){
            resource =((DegradeRule) rule).getResource();
         }
         Matcher m = p.matcher(resource);
         //如果发现类似{xxx}的结构,断定其为RESTful接口
         if (m.find()) {
            cleaner.patterns.add(new RestfulPattern(Pattern.compile(m.replaceAll("\\S+?")), resource));
         }
      }
      //根据正则表达式重新排序
      Collections.sort(cleaner.patterns);
      return cleaner;
   }

   @Override
   public String clean(String s) {
      for (RestfulPattern pattern : patterns) {
         if (pattern.getPattern().matcher(s).matches()) {
            return pattern.getRealResource();
         }
      }
      return s;
   }
}

最后,如果项目中引用新技术,建议一定要仔细阅读官方文档,避免踩坑,能上云就上云,能让别人做就别自己做😢😢😢😢。