监控告警:指标计算服务重构(Redis Pipeline+lua)处理速率提升17倍,释放资源50w+/年

一、指标计算链路

1. 指标kafka:经过清洗增维后的时序指标数据

关键信息:指标采集时间、资源ID、metric指标名称、tag标签(集群架构的实例ID、kafka的topic-groupId等信息)

2. 指标计算服务(重构部分):对【时序指标+告警策略】计算出告警或恢复事件

策略Redis:以资源维度存储该资源相关的告警策略(告警策略:指标+告警阈值)

指标Redis:存储计算的历史指标数据、策略抑制标识

3. 告警事件kafka:指标计算后的告警或者恢复事件

4. 告警生成服务:根据告警/恢复事件,生成告警并落库,发生通知到责任人

二、指标计算重构前流程

  • 1. 根据资源ID从策略Redis查询告警策略列表
    • 注:由于访问频繁,这里加多了一层本地缓存
  • 2. 根据metric(指标)和tag(附加信息),对告警策略进行过滤
    • 如果当前消费的kafka指标时序数据没有对应的策略,则结束流程
  • 3. 循环遍历策略
    • 3.1 根据【资源ID+tag+指标+策略ID】从指标Redis读出历史指标、抑制标识
    • 3.2 根据策略规则,结合历史指标,计算本次结果是告警事件还是恢复事件
    • 3.3 根据抑制标识,判断是否发送事件给下游(告警事件kafka)
    • 3.4 更新指标Redis的历史指标,告警抑制标识

三、当前告警计算存在的问题

  • 功能问题
    • 1. 没有对指标采集时间去重,会造成误告警
      • 比如上游在某一分钟重复推送异常数据,满足策略设置的连续x周期异常则产生告警
    • 2. 没有对指标采集时间排序,会造成误告警
      • 比如T1为正常指标、T2为异常指标,先消费T2产生告警后才消费T1导致误恢复
    • 3. 没有对指标采集时间过滤,会造成误告警
      • 如机器采集的时区不对,或者链路问题导致指标时间穿越,将无效指标也加入计算
    • 4. 线程安全问题
      • 多线程更新Redis的历史指标、告警抑制标识,会有覆盖写的风险
    • 5. 代码难维护
      • 双指标计算、严格周期匹配等问题修复代码引入较多特殊处理
    • 6. 新功能迭代难展开
      • 如双指标计算拓展为多指标计算,单层级指标计算拓展为多层级
  • 性能问题
    • 指标数据全网大概3000w/min,单节点峰值处理效率仅有50w/min,线上部署4套集群共120台节点
  • 重构问题
    • 资源指标告警核心业务功能,重构上线风险大

四、重构关键举措

1. 以指标维度存储历史指标数据,而非【策略+指标】维度,在双指标或多指标计算场景均可使用

2. 对指标采集时间进行过滤、去重、排序

  • 过滤:对指标时间大于当前时间过多的数据进行无效的过滤,避免场景占用缓存
  • 去重:对指标时间以分钟维度存储,同一分钟只能有一条指标数据,同时双指标的严格周期匹配更为精准
  • 排序:对指标推送时间进行排序,避免阻塞或上游原因导致的乱序问题

3. 使用lua脚步追加历史指标缓存,并返回历史指标

  • lua脚步的原子性可以避免多线程消费导致的覆盖写问题
  • 减少Reids交互次数,由一读一写的两次操作变成lua脚步的一次操作

4. 访问Redis均使用pipeline管道模式进行批量操作,大幅度减少Redis交互,有效提高并发

  • Redis处理简单命令的速度是微秒级别,但是网络IO是毫秒级别,通过批处理可以大幅提高程序性能

5. 访问策略Redis使用了本地缓存,还需要指标时序数据推送到指标kafka时,根据资源ID路由到kafka分区,这样可以大幅提高本地缓存的命中率,减少策略Redis的访问,减少内存消耗

6. 上线风险控制

  • 重构上线时选择一个影响较小的集群,创建新的部署集群试运行,单独链路运行
    • 消费指标kafka时用临时groupId
    • 推送告警事件kafka时用临时topic
  • 在并行跑一段时间,告警抑制标识都生效后,停掉旧链路并启用新链路进行验证
  • 如果验证过程中有问题,需要切换回旧链路

五、指标计算重构后流程

  • 1. 批量消费:根据资源ID列表去重后查询策略Redis
    • 本地缓存没有的才去策略Redis读取
  • 2. 根据metric(指标)和tag(附加信息),对告警策略进行过滤
    • 如果当前消费的kafka指标时序数据没有对应的策略,则结束流程
  • 3. 通过pipeline批量调用lua脚步,追加历史指标数据并返回历史指标
  • 4. 根据策略规则,结合历史指标,计算本次结果是告警事件还是恢复事件
  • 5. 通过pipeline批量调用lua脚步,追加告警抑制标识,并返回是否抑制
  • 6. 根据抑制标识,判断是否发送事件给下游(告警事件kafka)

六、重构预期结果

1. 解决已知的误告警问题

2. Redis交互次数骤减,如默认一批消费是500指标数据

  • 策略Redis:
    • 重构前:最多访问500次,本地缓存没数据的情况下
    • 重构后:只访问1次
  • 指标Redis:
    • 重构前:每次对历史指标和抑制标识进行读和写,总共500 * 4 = 2000次
    • 重构后:只访问2次

3. 提高指标计算性能,通过demo预计单节点(4核8G)由50w/min可以提升至850w/min,3节点可达2200w/min,并且Redis各项指标没有明恶化趋势

  • 线上120个节点可以缩容至6 - 10,容器资源节省110节点
  • 之前由于性能瓶颈问题拆分的4套容器部署集群和4套Redis集群,可以只保留1套Redis
  • 按华为云主机4核8G年费4k/年*110,Redis集群3主3从32G 3w/年* 3,释放资源可达到50w+/年

4. 更好的展开新迭代:多指标、多层级策略的计算

七、关键实现

lua脚本实现原子性追加指标(排序、去重,截取),并返回追加后的历史指标数据

  • KEYS[1]:资源ID,以资源ID维度的hash结构进行数据存储
  • ARGV[1]:hash结构的field字段,以tag+metric作为field
  • ARGV[2]:通过“|”拼接采集时间以及指标值,存储时进行排序
  • ARGV[3]:历史数据存储的最大元素数量,由业务的最大监测周期决定
-- LUA标准库没有string.split函数,需要自定义
local function split(str, delimiter)
    local result = {}
    local pattern = string.format('([^%s]+)', delimiter)
    for word in string.gmatch(str, pattern) do
        table.insert(result, word)
    end
    return result
end
-- 将范围内元素往后挪一位
local function shiftElements(table, startIdx, endIdx)
    for i = endIdx, startIdx, -1 do
        table[i + 1] = table[i]
    end
end
-- 获取输入参数 key(资源ID)、field(tag+metric)、value(采集时间|指标值)、historyMaxCount(历史指标存储最大数量)
local key = KEYS[1]
local field = ARGV[1]
local value = ARGV[2]
local historyMaxCount = tonumber(ARGV[3])
-- 时间与指标值的分隔符
local splitStr = '|'
-- 新指标的时间戳
local newTime = tonumber(split(value, splitStr)[1])
-- 从Redis获取历史指标
local historyValues = redis.call('HGET', key, field)
if not historyValues then
    redis.call('HSET', key, field, value)
    return value
end
-- 历史指标切分
local arr = split(historyValues, ',')
-- 历史指标是已经排序好的,新的指标一般都是最后的,从后到前进行遍历进行插入
local count = #arr
for i = count, 1, -1 do
    local oldTime = tonumber(split(arr[i], splitStr)[1])
    -- 如果存在采集时间相等的情况,直接替换
    if oldTime == newTime then
        arr[i] = value
        redis.call('HSET', key, field, table.concat(arr, ','))
        return table.concat(arr, ',')
    end
    -- 新指标大于旧指标时间
    if oldTime < newTime then
        -- 从i+1的位置进行插入,所有元素往后挪一位
        if i < count then
            shiftElements(arr, i + 1, count)
        end
        arr[i + 1] = value
        break
    end
end
-- 如果从后到前遍历后元素数量没有变化,则标识最新的指标采集时间小于所有指标
if count == #arr then
    shiftElements(arr, 1, count)
    arr[1] = value
end
-- 如果历史指标数量超过最大限制,需要进行截取
local resultValue
if #arr > historyMaxCount then
    resultValue = table.concat(arr, ',', #arr - historyMaxCount + 1, #arr)
else
    resultValue = table.concat(arr, ',')
end
redis.call('HSET', key, field, resultValue)
redis.call('EXPIRE', key, 3600)
return resultValue

Java关键实现:

  • 程序启动时postConstruct钩子上传lua脚本到Redis服务器,返回sha1编号
  • 通过pipeline管道批量调用脚本 evalsha <sha1> 并返回结果
    private String SCRIPT_APPEND_METRIC_SHA1 = null;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private ResourceLoader resourceLoader;

    @PostConstruct
    public void postConstruct() {
        log.info("init script append metric");
        Resource resource = resourceLoader.getResource("classpath:lua\\metric_append.lua");
        try (InputStream inputStream = resource.getInputStream()) {
            byte[] bytes = FileCopyUtils.copyToByteArray(inputStream);
            String script = new String(bytes, StandardCharsets.UTF_8);
            redisTemplate.execute((RedisCallback<String>) redisConnection ->
                    SCRIPT_APPEND_METRIC_SHA1 = redisConnection.scriptLoad(script.getBytes(StandardCharsets.UTF_8))
            );
            log.info("init script append metric end, SHA1: {}", SCRIPT_APPEND_METRIC_SHA1);
        } catch (Exception exception) {
            log.error("init script append metric error", exception);
        }
    }
    
    public void test(){
        // pipeline批量调用lua脚步,返回追加后的指标
        List<Object> resultList = redisTemplate.executePipelined((RedisCallback<String>) connection -> {
            String key = "metric_append:" + message.getHrn();
            String field = message.getMetric() + message.getTag();
            String value = message.getDate().getTime() / 1000 / 60 * 60 + "|" + message.getValue();
            connection.evalSha(SCRIPT_APPEND_METRIC_SHA1, ReturnType.fromJavaType(String.class), 1, key.getBytes(StandardCharsets.UTF_8),
                    field.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8), "10".getBytes(StandardCharsets.UTF_8));
            return null;
        });
    }
    

  • 15
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值