sentinel-dashboard redis持久化实现

〇、概述

  • alibaba sentinel是一个开源的流控框架,项目地址:https://github.com/alibaba/Sentinel

    其中,sentinel-dashboard提供了一个简单的web页面,可以实现流控规则的页面配置与展示;

  • sentinel-dashboard默认使用内存存储数据,可以通过简单的配置,把数据源设置为nacos,实现持久化。根据官方文档介绍和源码中sentinel-extension模块的目录结构,可以看出sentinel也提供consul-kvetcdzookeeperapollo等其他持久化方案。但是,只有nacos是阿里自家的,集成做得最完善,网上搜到的sentinel-dashboard持久化基本都是使用nacos

  • 我们项目里没有nacos,也不可能为了一个小小的sentinel-dashboard而引入。所以,我对sentinel-dashboard做了个二次开发,通过redis实现持久化。

一、redis持久化

1.0 依赖版本

组件 版本
sentinel-dashboard/sentinel-core 1.8.0
springboot 2.0.5.RELEASE
spring-data-redis 2.0.10.RELEASE
lettuce 5.0.5.RELEASE

1.1 整体思路

1.1.1 程序现状
  • 流控规则、降级规则存储在程序内存中;
  • 新增、修改、删除规则时,dashboard通过http client向目标应用推送全量规则;
  • 规则id基于AtomicLong,单点自增
1.1.2 改造方案
  • 存储方式修改: 规则存储在程序内存的同时,也存储到redis;
  • 推送方式修改: 基于redis发布订阅功能。新增、修改、删除规则后,发布通知至应用,应用重新加载规则;
  • id生成方式修改: 基于redis incr命令,生成分布式自增id

1.2 整体设计

  1. 实现DynamicRuleProvider<List<FlowRuleEntity>>接口,根据应用名称查询redis流控规则;
  2. 实现DynamicRulePublisher<List<FlowRuleEntity>>接口,保存流控规则、发布更新消息;
  3. 修改FlowControllerV1中查询流控规则的方法,改为调用DynamicRuleProvider<List<FlowRuleEntity>>从redis获取;
  4. 修改FlowControllerV1中新增、修改、删除时的发布方法,改为调用DynamicRulePublisher<List<FlowRuleEntity>>保存到redis并发送更新消息。

1.3 源码修改

  1. 集成redis
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <version>${spring.boot.version}</version>
        </dependency>
spring:
  redis:
    sentinel:
      master: master
      nodes:
        - host1:port1
        - host2:port2
        - host3:port3
    password: password
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory)
    {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(factory);
        redisTemplate.setEnableTransactionSupport(true);
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setValueSerializer(jsonRedisSerializer);
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(jsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}
  1. redis key常量定义

    public final class Constants {
    
        public static final String RULE_FLOW_PREFIX = "sentinel:rule:flow:";
    
        public static final String RULE_FLOW_CHANNEL_PREFIX = "sentinel:channel:flow:";
    
        public static final String RULE_FLOW_ID_KEY = "sentinel:id:flow";
    
    }
    
  2. InMemFlowRuleStore中的id生成修改

    @Component
    public class RedisIdGenerator {
    
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
    
        public long nextId(String key) {
            return redisTemplate.opsForValue().increment(key, 1);
        }
    }
    
    @Component
    public class InMemFlowRuleStore extends InMemoryRuleRepositoryAdapter<FlowRuleEntity> {
    
        @Autowired
        private RedisIdGenerator redisIdGenerator;
    
        @Override
        protected long nextId() {
            return redisIdGenerator.nextId(RULE_FLOW_ID_KEY);
        }
    }
    
  3. DynamicRuleProvider<List<FlowRuleEntity>>

    @Component("flowRuleRedisProvider")
    public class FlowRuleRedisProvider implements DynamicRuleProvider<List<FlowRuleEntity>> {
    
        private final Logger logger = LoggerFactory.getLogger(FlowRuleRedisProvider.class);
    
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
    
        @Override
        public List<FlowRuleEntity> getRules(String appName) throws Exception {
            Assert.notNull(appName, "应用名称不能为空");
            logger.info("拉取redis流控规则开始: {}", appName);
            String key = RULE_FLOW_PREFIX + appName;
            String ruleStr = (String)redisTemplate.opsForValue().get(key);
            if(StringUtils.isEmpty(ruleStr)) {
                return Collections.emptyList();
            }
            List<FlowRuleEntity> rules = JSON.parseArray(ruleStr, FlowRuleEntity.class);
            logger.info("拉取redis流控规则成功, 规则数量: {}", CollectionUtils.size(rules));
            return rules;
        }
    }
    
  4. DynamicRulePublisher<List<FlowRuleEntity>>

    @Component("flowRuleRedisPublisher")
    public class FlowRuleRedisPublisher implements DynamicRulePublisher<List<FlowRuleEntity>> {
    
        private final Logger logger = LoggerFactory.getLogger(FlowRuleRedisPublisher.class);
    
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
    
        @Value("${sentinel.channel.enabled:true}")
        private boolean channelEnabled;
    
        @Override
        public void publish(String app, List<FlowRuleEntity> rules) throws Exception {
            Assert.notNull(app, "应用名称不能为空");
            Assert.notEmpty(rules, "策略规则不为空");
            logger.info("推送流控规则开始, 应用名: {}, 规则数量: {}", app, rules.size());
            redisTemplate.multi();
            String ruleKey = RULE_FLOW_PREFIX + app;
            String ruleStr = JSON.toJSONString(rules);
            redisTemplate.opsForValue().set(ruleKey, ruleStr);
            if (channelEnabled) {
                String channelKey = RULE_FLOW_CHANNEL_PREFIX + app;
                redisTemplate.convertAndSend(channelKey, rules.size());
            }
            redisTemplate.exec();
        }
    }
    
  5. FlowControllerV1中的查询方法apiQueryMachineRules

        @Autowired
        private FlowRuleRedisProvider flowRuleRedisProvider;
    
        @GetMapping("/rules")
        @AuthAction(PrivilegeType.READ_RULE)
        public Result<List<FlowRuleEntity>> apiQueryMachineRules(@RequestParam String app,
                                                                 @RequestParam String ip,
                                                                 @RequestParam Integer port) {
            if (StringUtil.isEmpty(app)) {
                return Result.ofFail(-1, "app can't be null or empty");
            }
            try {
                List<FlowRuleEntity> rules = flowRuleRedisProvider.getRules(app);
                rules = repository.saveAll(rules);
                return Result.ofSuccess(rules);
            } catch (Throwable throwable) {
                logger.error("Error when querying flow rules", throwable);
                return Result.ofThrowable(-1, throwable);
            }
        }
    
  6. FlowControllerV1中新增、修改、删除时的发布方法publishRules

        @Autowired
        private FlowRuleRedisPublisher flowRuleRedisPublisher;
        @Value("${sentinel.channel.enabled}")
        private boolean channelEnabled;
    
     private CompletableFuture<Void> publishRules(String app, String ip, Integer port) {
            List<FlowRuleEntity> allRules = repository.findAllByApp(app);
            try {
                flowRuleRedisPublisher.publish(app, allRules);
                return CompletableFuture.completedFuture(null);
            } catch (Exception e) {
                logger.error("推送流控规则至redis失败, 应用名称: {}", app, e);
            }
            if (!channelEnabled) {
                List<FlowRuleEntity> rules = repository.findAllByMachine(MachineInfo.of(app, ip, port));
                return sentinelApiClient.setFlowRuleOfMachineAsync(app, ip, port, rules);
            }
            return AsyncUtils.newFailedFuture(
                new CommandFailedException("sentinel推送流控规则失败, channelEnabled: " + channelEnabled));
        }
    

1.4 效果展示

  1. sentinel:id:flow中的规则id
redis flow id.png
  1. 应用服务的流控规则
redis flow rule.png
  1. sentinel:channel:flow:服务名称中的消息
redis subscribe.png

二、应用端开发

2.1 官方实现

其实官方提供了一个简单的实现:

sentinel-datasource-redis: com.alibaba.csp.sentinel.datasource.redis.RedisDataSource,但经过测试,我发现它有bug(version 1.8.0),程序初始化时没法全量加载规则,但更新时可以监听。作者并不是不知道初始化时要全量加载,只是单纯地写错了:

  1. 构造方法RedisDataSource()中,调用loadInitialConfig()初始化redis数据;

  2. loadInitialConfig()中调用getProperty().updateValue(newValue)刷新本地规则缓存;

  3. updateValue(newValue)方法由DynamicSentinelProperty实现:

        @Override
        public boolean updateValue(T newValue) {
            if (isEqual(value, newValue)) {
                return false;
            }
            RecordLog.info("[DynamicSentinelProperty] Config will be updated to: " + newValue);
    
            value = newValue;
            for (PropertyListener<T> listener : listeners) {
                listener.configUpdate(newValue);
            }
            return true;
        }
    
  4. 然而,此时的listeners还没有初始化,是个空集合,根本加载不了数据;

  5. 参考官方的配置方式,数据源初始化完成后调用FlowRuleManager.register2Property()时才会初始化listeners,监听器的默认实现为

    com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager$FlowPropertyListener

            ReadableDataSource<String, List<FlowRule>> redisFlowDataSource = new RedisDataSource<>();
            FlowRuleManager.register2Property(redisFlowDataSource.getProperty());
    
  6. 所以,需要重新实现RedisDataSource,修正初始化数据逻辑。官方已经提供了实现思路,我只要基于官方的实现,稍微改一改就行了。

2.2 整体设计

参照官方的实现,做以下修改:

  1. 构造方法中不再尝试加载redis初始化数据,仅订阅消息队列;
  2. 监听器初始化完成后,手动触发一次updateValue(newValue)方法,加载初始化数据;
  3. 当然,前提条件是应用端已经集成了spring-data-redis并正确配置了RedisTemplate

2.3 代码编写

  1. redis key常量定义

    public final class Constants {
    
        public static final String RULE_FLOW_PREFIX = "sentinel:rule:flow:";
    
        public static final String RULE_FLOW_CHANNEL_PREFIX = "sentinel:channel:flow:";
    
    }
    
  2. RedisDataSource

    public class RedisDataSource<T> extends AbstractDataSource<String, T> {
    
        private static final Logger logger = LoggerFactory.getLogger(RedisDataSource.class);
    
        private static final String REDIS_SUCCESS_MSG = "OK";
    
        private RedisTemplate redisTemplate;
    
        private String ruleKey;
    
        private String channel;
    
    
        public RedisDataSource(Converter<String, T> parser, RedisTemplate redisTemplate, String ruleKey, String channel) {
            super(parser);
            AssertUtil.notNull(redisTemplate, "redisTemplate can not be null");
            AssertUtil.notEmpty(ruleKey, "redis ruleKey can not be empty");
            AssertUtil.notEmpty(channel, "redis subscribe channel can not be empty");
            this.redisTemplate = redisTemplate;
            this.ruleKey = ruleKey;
            this.channel = channel;
            subscribeFromChannel();
        }
    
        @Override
        public String readSource() throws Exception {
            return (String) redisTemplate.opsForValue().get(ruleKey);
        }
    
        @Override
        public void close() throws Exception {
            redisTemplate.execute((RedisCallback<String>) connection -> {
         connection.getSubscription().unsubscribe(channel.getBytes(StandardCharsets.UTF_8));
                return REDIS_SUCCESS_MSG;
            });
        }
    
        /**
         * 订阅消息队列
         */
        private void subscribeFromChannel() {
            redisTemplate.execute((RedisCallback<String>) connection -> {
                connection.subscribe((message, pattern) -> {
                    byte[] bytes = message.getBody();
                    String msg = new String(bytes, StandardCharsets.UTF_8);
                    logger.info("接收到流控规则更新消息: {} ", msg);
                    getProperty().updateValue(parser.convert(msg));
                }, channel.getBytes(StandardCharsets.UTF_8));
                return REDIS_SUCCESS_MSG;
            });
        }
    }
    
  3. redis数据源配置

    @Configuration
    public class RedisDataSourceConfig implements ApplicationRunner {
    
        private static final Logger logger = LoggerFactory.getLogger(RedisDataSourceConfig.class);
    
        @Autowired
        private RedisTemplate redisTemplate;
    
        @Value("${spring.application.name}")
        private String appName;
    
        @Override
        public void run(ApplicationArguments args) throws Exception {
            logger.info("初始化sentinel数据源开始, appName: {}", appName);
            // 初始化流控规则
            String ruleFlowKey = RULE_FLOW_PREFIX + appName;
            Converter<String, List<FlowRule>> flowRuleParser = msg -> {
                String rulesStr = (String) redisTemplate.opsForValue().get(ruleFlowKey);
                return JSON.parseArray(rulesStr, FlowRule.class);
            };
            String ruleFlowChannel = RULE_FLOW_CHANNEL_PREFIX + appName;
            ReadableDataSource<String, List<FlowRule>> redisFlowDataSource = new RedisDataSource<>(flowRuleParser, redisTemplate,                ruleFlowKey, ruleFlowChannel);
            FlowRuleManager.register2Property(redisFlowDataSource.getProperty());
            List<FlowRule> flowRuleList = flowRuleParser.convert(EMPTY);
            redisFlowDataSource.getProperty().updateValue(flowRuleList);
            logger.info("初始化sentinel数据源结束, appName: {}", appName);
        }
    }
    

2.4 效果展示

  1. 初始化规则加载
app startup.png
app flow rule 0.png
  1. 新增规则
config flow rule.png
  1. 消息监听
app subscribe.png
app flow rule 1.png

三、dashboard前端升级补丁

3.1 问题

使用过程中,我发现dashboard 1.8.0新增/编辑降级规则时,没有统计时长字段,通过查阅源码得知统计时长statIntervalMs被设置为默认值1000,无法修改。而1秒的统计时长,对于QPS较低的应用,难以保证采样均匀;

最新版本1.8.2,统计时长字段可以修改。

version compare.png
1.8.0 VS 1.8.2

3.2 目标

修改dashboard前端angular代码,添加统计时长字段。

3.3 过程

  • 参照1.8.2版本,修改文件degrade-rule-dialog.html,添加如下代码:

                      <div class="form-group">
                          <label class="col-sm-2 control-label">统计时长</label>
                          <div class="col-sm-4">
                              <div class="input-group">
                                  <input type='number' min="1" class="form-control highlight-border"
                                           ng-model='currentRule.statIntervalMs'
                                         placeholder="统计时长(ms)" />
                                  <span class="input-group-addon">ms</span>
                              </div>
                          </div>
                      </div>
    
  • 清空缓存-重启、maven clean-重启、关闭idea-重启,发现字段不仅没加上,弹框还404了...

  • angular我根本不会啊,全靠搜索引擎。核对了项目中angular的版本(1.4.8),做了一些没啥用的尝试

  • 当我给scripts/app.js中的路由定义添加了cache属性后,总算好使了

          .state('dashboard.degrade', {
            templateUrl: 'app/views/degrade.html',
            url: '/degrade/:app',
            controller: 'DegradeCtl',
            cache: false,
            resolve: {
              loadMyFiles: ['$ocLazyLoad', function ($ocLazyLoad) {
                return $ocLazyLoad.load({
                  name: 'sentinelDashboardApp',
                  files: [
                    'app/scripts/controllers/degrade.js',
                  ]
                });
              }]
            }
          })
    

3.4 效果

  • dashboard前端展示
dialog edit.png
  • dashboard后端接收
controller 0.png
  • 最终推送到业务应用中的规则
app flow rule.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,634评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,951评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,427评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,770评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,835评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,799评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,768评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,544评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,979评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,271评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,427评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,121评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,756评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,375评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,579评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,410评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,315评论 2 352

推荐阅读更多精彩内容