车联网规则引擎设计之热更新与版本管理

背景

车联网场景中,规则引擎负责实时评估车辆上报的数据,触发告警并通知业务平台。规则的常见类型包括:

  • 阈值规则:SOC < 20%、速度 > 80km/h 等
  • 故障规则:特定故障码出现
  • 事件规则:特定事件码发生(如充电完成)

这些规则需要频繁调整:阈值变化、新增告警类型、修改适用车辆范围等。如何在不重启服务的情况下实现规则的动态更新,并保证版本可追溯、可回滚,是规则引擎设计的核心挑战。

整体架构

规则引擎在数采平台中的位置:

复制代码
车辆数据 → collector 解码 → rule 评估 → [MySQL 规则配置 + Redis 告警快照]
                                           ↓
                                   告警推送 → 业务平台

数据流:

  1. 配置流:管理后台通过 MySQL 维护规则定义和版本
  2. 评估流:规则引擎从 MySQL 加载已发布规则到内存,实时评估车辆事件
  3. 告警流:命中规则时生成告警,写入 Redis 快照和 MySQL 命中日志,同时推送到下游

核心设计

1. 规则与版本分离

规则设计采用经典的主从表模式:

作用 关键字段
rule_definition 规则元数据 id, rule_code, rule_name, rule_type, severity, current_version
rule_definition_version 规则版本 id, rule_id, version_no, expression_json, dedup_window_seconds, suppress_window_seconds, status

设计要点:

  • 规则定义表中的 current_version 指向当前生效的版本号
  • 版本表中的 status 字段控制生命周期:DRAFT(草稿)、PUBLISHED(已发布)、OFFLINE(已下线)、ROLLBACKED(已回滚)
  • 规则评估仅加载 status = 'PUBLISHED'enable_status = 1 的规则
  • 版本表独立存储表达式、去重窗口、抑制窗口等运行时参数

优势:

  • 版本可追溯:每次规则变更生成新版本,历史版本保留
  • 安全发布:先创建 DRAFT 版本,测试无误后再发布
  • 快速回滚 :将 current_version 切回上一版本号,无需修改版本数据

2. 内存缓存与定时刷新

规则评估是高频操作,每次从数据库查询开销不可接受。方案:

  • 启动时加载所有启用且已发布的规则到内存
  • 后台定时刷新(默认 30 秒),从数据库重新加载规则
  • 评估时只读内存缓存,不访问数据库
缓存结构
go 复制代码
type RuleEngine struct {
    ruleCache    map[int64]*models.RuleDefinition  // ruleID → 规则定义
    ruleVersions map[int64]*models.RuleVersion     // ruleID → 当前版本
    mu           sync.RWMutex                    // 读写锁
}
刷新逻辑:全量替换
go 复制代码
func (e *RuleEngine) refreshRules() {
    // 1. 从数据库加载最新规则
    defs, versionByRule, err := LoadActiveRules(ctx, e.db)

    // 2. 构建新的缓存副本
    newCache := make(map[int64]*models.RuleDefinition, len(defs))
    newVersions := make(map[int64]*models.RuleVersion, len(versionByRule))
    for i := range defs {
        d := &defs[i]
        newCache[d.ID] = d
        if v := versionByRule[d.ID]; v != nil {
            newVersions[d.ID] = v
        }
    }

    // 3. 一次性替换旧缓存
    e.mu.Lock()
    e.ruleCache = newCache
    e.ruleVersions = newVersions
    e.mu.Unlock()
}
原子性保证

关键在于先构建副本,再一次性替换。评估过程中持读锁,不阻塞刷新操作;刷新时持写锁,阻塞评估但时间极短(毫秒级)。

规则类型与表达式存储

不同规则类型的表达式以 JSON 格式存储在 expression_json 字段:

规则类型 表达式类型 表达式结构示例
THRESHOLD threshold {"field":"soc","operator":"<","value":20}
FAULT fault_exists {"faultCode":"P1234"}{}(匹配全部)
EVENT event_code_matches {"eventCode":1}{"eventCode":0}(全部)

3. 去重与抑制窗口

去重窗口:防止重复记录

同一事件(如 SOC < 20%)在短时间内可能被多次上报,导致产生多条重复告警。去重逻辑:

  • 去重键:VIN + RuleID + 事件特征(故障码或事件码)
  • 每条规则配置独立的去重窗口(dedup_window_seconds,默认 60 秒)
  • 窗口内的重复事件跳过,不生成告警
go 复制代码
type deduplicationTracker struct {
    mu      sync.Mutex
    entries map[string]time.Time  // 去重键 → 最后命中时间
}

func (d *deduplicationTracker) withinWindow(key string, window time.Duration) bool {
    d.mu.Lock()
    defer d.mu.Unlock()
    t, ok := d.entries[key]
    return ok && time.Since(t) < window
}
抑制窗口:防止重复通知

同一告警在短时间内不应重复通知下游(如发送短信、推送 App)。抑制逻辑:

  • 抑制键:VIN + RuleID(阈值规则)或 VIN + RuleID + 故障码(故障规则)
  • 每条规则配置独立的抑制窗口(suppress_window_seconds
  • 窗口内的告警状态标记为 SUPPRESSED,仍写入快照但不推送
go 复制代码
type suppressionTracker struct {
    mu      sync.Mutex
    entries map[string]time.Time  // 抑制键 → 最后通知时间
}
区别
特性 去重窗口 抑制窗口
作用 防止重复评估结果写入数据库 防止重复通知下游
键构成 VIN + RuleID + 事件特征 VIN + RuleID(或加故障码)
结果 跳过评估,不记录日志 记录日志,状态为 SUPPRESSED
典型时长 30~120 秒 5~30 分钟

4. 告警快照存储:Redis Sorted Set

设计目标
  • 管理后台实时查询当前活跃告警列表
  • 按发生时间倒序展示
  • 支持按 VIN、严重程度、规则类型筛选
数据结构

Redis 使用 Sorted Set(ZSET)存储告警快照:

  • 主键collect:alarm:v1:*(每条告警一条 Hash)
  • 索引键
    • collect:alarm:idx:global:time:全局时间倒序索引(score = occurred_at)
    • collect:alarm:idx:vin:{vin}:time:单 VIN 时间倒序索引
写入流程
go 复制代码
// 1. 生成告警快照
alarm := &models.AlarmEvent{
    EventID:    newAlarmUUID(),
    VIN:        vin,
    RuleID:     ruleID,
    AlarmCode:   ruleCode,
    Severity:    severity,
    Status:      models.AlarmStatusRaised,
    OccurredAt:  time.Now(),
}

// 2. 写入 Redis Hash
rdb.HSet(ctx, alarmEventKey(alarm.EventID), alarmJSON)

// 3. 写入全局索引(score 为发生时间)
rdb.ZAdd(ctx, alarmIdxGlobalTime(), occurredAt.UnixMilli(), alarm.EventID)

// 4. 写入 VIN 索引
rdb.ZAdd(ctx, alarmIdxVinTime(vin), occurredAt.UnixMilli(), alarm.EventID)
查询流程
go 复制代码
// 1. 从 Sorted Set 读取 ID 列表(按 score 倒序)
ids, _ := rdb.ZRevRange(ctx, alarmIdxGlobalTime(), 0, 49)

// 2. 根据 ID 批量获取告警详情
for _, id := range ids {
    raw, _ := rdb.Get(ctx, alarmEventKey(id)).Bytes()
    json.Unmarshal(raw, &alarm)
    // 筛选、分页
}
不同规则类型的更新策略
规则类型 快照数量 更新策略
THRESHOLD 同车同规则最多一条 Upsert(先 UPDATE,未命中再 INSERT)
FAULT 同车同规则可多条 Insert(故障码不同即新告警)
EVENT 同车同规则可多条 Insert(事件码不同即新告警)

5. 恢复条件:自动清除快照

告警需要明确的恢复机制。不同规则类型支持不同的恢复策略:

阈值规则

配置 recover_condition_json,结构与 expression_json 相同(也是阈值表达式):

json 复制代码
{
  "field": "soc",
  "operator": ">",
  "value": 30
}

评估流程:

  1. 遥测数据到达时,先检查恢复条件是否满足
  2. 满足则删除该车该规则编码的所有阈值快照
  3. 再评估触发条件,若命中则新建快照
go 复制代码
func (e *RuleEngine) EvaluateTelemetry(ctx context.Context, telemetry *models.VehicleTelemetryEvent) {
    // 1. 检查恢复条件
    if e.thresholdRecoverConditionMatches(ver, telemetry) {
        e.alarmStore.DeleteThresholdAlarmsByVINAndRuleCode(ctx, vin, ruleCode)
    }

    // 2. 评估触发条件
    matched, msg := e.evalThreshold(ver.ExpressionJSON, telemetry)
    if !matched {
        return
    }

    // 3. 创建告警快照
    alarm := e.persistThresholdAlarm(ctx, result, suppressWin, supKey, suppressed)
}
故障规则

故障恢复由车端主动上报 end_time 字段触发:

  • end_time 恢复 :故障事件的 end_time 非空时,按各规则版本的 recover_condition_json.fault_recovery.end_time_clears_snapshot 配置逐条删除故障快照
go 复制代码
func (e *RuleEngine) EvaluateFault(ctx context.Context, fault *models.FaultEvent) {
    // 1. end_time 恢复:逐条删除允许的 FAULT 快照
    if faultEventHasRecoveryEndTime(fault) {
        rows, _ := e.alarmStore.ListFaultAlarmRowsByVINAndFaultCode(ctx, vin, fault.FaultCode)
        for _, row := range rows {
            ver := e.ruleVersions[row.RuleID]
            if faultRecoverEndTimeEnabled(ver) {
                e.alarmStore.DeleteAlarmEventByID(ctx, row.AlarmID)
            }
        }
        return nil
    }

    // 2. 评估规则,生成新告警
    ...
}

6. 告警推送:Redis Stream

规则命中后,告警通过 Redis Stream 推送到下游系统(业务平台、消息中心等)。

Stream 结构
  • Stream Keycollect:pushMessage:queue:v1
  • 消费组push-consumer-group
  • 消息内容:告警精简字段(VIN、模板编码、触发值、事件时间等)
go 复制代码
func PushAlarmMessage(ctx context.Context, rdb *redis.Client, alarm *alarmForPush) {
    values := map[string]interface{}{
        "event_msg_id":   alarm.EventMsgID,
        "vin":           alarm.VIN,
        "template_code":  alarm.TemplateCode,
        "trigger_value":  alarm.TriggerValue,
        "status":        alarm.Status,
        "event_time":     alarm.EventTime.Format("2006-01-02 15:04:05"),
        "source":        "tbox_collect",
    }
    rdb.XAdd(ctx, &redis.XAddArgs{
        Stream: pushMessageStreamKey,
        MaxLen: 10000,
        Approx: true,
        Values: values,
    })
}
消息幂等性

下游消费时可能重复处理(如消费组重平衡)。Stream 消息不含告警 ID,依赖 event_msg_id(标准事件 ID)去重,或下游系统根据 vin + rule_id + occurred_at 去重。

可观测性

全链路通过 Prometheus 指标监控:

指标 用途
collect_rule_evaluations_total 按规则类型和结果统计评估次数
collect_rule_evaluation_duration_seconds 评估耗时分布(直方图)
collect_rule_alarms_total 按严重程度和类型统计告警产生数
collect_rule_alarms_deduped_total 去重告警数
collect_rule_alarms_suppressed_total 抑制告警数
collect_rule_cache_refresh_total 缓存刷新次数及结果
collect_rule_active_count 当前活跃规则数

配合 Grafana 面板,可以实时观察规则刷新成功率、评估耗时分布、告警产生趋势。

经验总结

决策 理由
规则与版本分离 支持版本追溯、快速回滚,测试后再发布
全量刷新缓存 避免增量更新的并发问题,替换操作原子且快速
去重与抑制分离 去重防止数据库冗余,抑制防止下游重复通知
Redis 快照 + Sorted Set 索引 高效支持实时列表查询、按时间倒序、多维度筛选
恢复条件配置化 不同规则类型灵活配置恢复策略,无需修改代码
Redis Stream 推送 解耦规则引擎与下游,天然支持多消费者、消息持久化

后续演进

当前设计支撑百到千级规则量、每秒数百次评估。后续优化方向:

  1. 规则分组:按 VIN 范围、规则类型等维度分组评估,减少单次评估的规则遍历量
  2. 表达式编译:将 JSON 表达式预编译为可执行代码(如 Go 代码生成或表达式引擎),提升评估性能
  3. 规则灰度:支持规则在小范围车辆上灰度验证,再全量发布
  4. 告警聚合:短时间内多规则命中时生成聚合告警,减少下游处理压力
相关推荐
代码中介商1 小时前
Linux多线程编程完全指南(下):线程同步与互斥锁
linux·redis·线程·互斥锁
Lyyaoo.1 小时前
Session粘滞性问题->Redis实现session共享
数据库·redis·缓存
咬_咬1 小时前
go语言学习(函数)
开发语言·学习·golang
Mr_sst2 小时前
文件上传并发控制:为什么选Redisson可过期信号量?(避坑指南)
网络·数据库·redis·分布式·安全架构
倚楼盼风雨2 小时前
Redis 为什么快
数据库·redis·缓存
xiaoliuliu123452 小时前
redis-windows-7.2.3安装步骤详解(附Redis配置与Windows服务注册)
数据库·windows·redis
y = xⁿ2 小时前
Redis八股学习日记:数据结构;跳表的底层;Reids的事务机制
数据结构·redis·学习
初心未改HD2 小时前
Go语言Goroutine与Channel深度解析
开发语言·golang
yuzhiboyouye2 小时前
java redis(缓存)
java·redis·缓存