背景

  • 在业务需求迭代越来越频繁的情况下, 每个需求有对不同业务的一些不同的特性之类的会出现非常多的业务迭代规则, 整体上会把代码的复杂性拉的非常高, 特别是涉及到某些素材的过滤+实验的情况

Cuttlefish DSL

  • 使用 json 的格式存储规则, 本质上是 n 叉树, 通过 DFS 进行匹配, 短路递归, 一旦出现不匹配就立刻返回
  • 优点是对 ui 友好, json 结构能天然适配, 并且扩展起来很轻, 包括匹配树也很轻, 不需要编译, 运行时匹配
  • 缺点是 复杂之后对人没这么友好, 表达式容易膨胀, 没有成熟的生态可以吃
  {
    "s_id": 0,
    "root": {
      "child_conditions": {
        "logic": "and",
        "list": [
          {
            "data": {
              "key": "app_version",
              "opt": ">=",
              "value": 5,
              "type": "number"
            }
          },
          {
            "child_conditions": {
              "logic": "or",
              "list": [
                {
                  "data": {
                    "key": "current_lane",
                    "opt": "!=",
                    "value": "create_tab",
                    "type": "string"
                  }
                },
                {
                  "data": {
                    "key": "resource_id",
                    "opt": "eq",
                    "value": "1029473286148",
                    "type": "string"
                  }
                }
              ]
            }
          }
        ]
      }
    }
  }

其他类型方案

Drools

  rule "High value user"
  when
      $user : User(level == "VIP", age >= 18)
      $order : Order(amount > 1000)
  then
      $order.setDiscount(0.9);
  end

优点

  • 能够支持比较复杂的逻辑

缺点

  • 不支持go语言,自己做解析很麻烦。比较难维护
  • 不是 json 格式, 平台解析和存储起来麻烦

CEL/expr

device_score >= 80 && region in ["US", "JP"]

优点

  • 人读更加直观
  • 编译之后缓存 AST, 性能不错

缺点

  • 平台可视化麻烦, 不好弄类似拖拽选择式的ui, 对运营等配置人员不够友好
方案定位输入形态规则形态执行模型复杂规则能力前端规则树 UI回显能力校验能力Explain / 失败原因运行时成本适合场景主要问题对当前策略配置场景的判断
Drools完整规则引擎 / BRMSFact 对象 + Rule 文件when/then,规则可触发动作Working Memory + Agenda + rule firing最强,支持推理、优先级、规则流、决策表、DMN不贴合,需要额外 DSL 转 DRL/DMN难,尤其反解析 DRL强,但体系重能做但复杂高,Java/KIE session/规则编译/热加载都要治理规则平台、风控平台、定价、审批、理赔等复杂后台决策Java/KIE session/规则编译/热加载/执行顺序治理都比较重,前端条件树也不能天然映射不建议,为当前条件树场景引入成本明显大于收益
CEL/expr表达式引擎上下文 map + 表达式字符串a > 1 && b in list编译表达式后求值中等,表达式组合强中等,需要规则树转表达式中等,最好保存 DSL 而不是表达式强,compile 阶段可校验默认不强,需要自己做 explain低到中,Go 内可嵌入工程配置、策略表达式、准入校验和前端规则树不是天然同构,需要 DSL 转表达式;默认 explain 能力弱可以作为未来表达式层候选,但第一阶段不必急着替换
cuttlefish轻量 JSON AST 规则执行器reqData map + 策略树 JSONAND/OR + field + op + value递归执行规则树中等偏简单,适合条件树最贴合,UI 树和 AST 基本同构简单,JSON 树直接回显当前偏弱,需要补 validate天然可按节点返回 reason低,当前 Go 内直接执行运营配置条件树、名单过滤、分级匹配工程治理偏弱,缺少完整 validate/compile,类型错误更多运行期暴露,历史扩展点容易误导当前最务实,建议保留并补强

Strategy Engine

两种类型

20260776170113pasteboard.paste

ListMatch 名单匹配

  • 返回某个名单是否命中以及不命中的原因

GradeMatch 策略匹配

  • 一个分级策略里面会有多个小的匹配的名单unit, 不匹配就到下一个优先级中, 返回的是匹配上的 unit 的 config

cache_switch

20260772213147pasteboard.paste

  • 默认有三种模式
    • memory: 纯内存模式, 适合 hot 的小数据, 常态加载到内存中的, 然后定时刷新, 缓存命中率 100%
    • grade: 分级模式, 适合没这么hot 的大数据, 三级缓存的架构(local cache-> redis -> DB)
    • combine: 混合模式, 统计 QPS, 以及请求比例, 满足的情况下直接使用 memory 的机制, 否则就是使用 grade 的机制
  • combine 使用滑动窗口, 100个 bucket, 每个 bucket 1分钟的 map[string]int , 每分钟 tick 一次超时间就清空老的 bucket, 同时维护一个这100个 bucket 的map, 计算的时候用所有 bucket 的统计值计算是否是热key

优化效果

  • 缓存命中率:+5%
  • Redis QPS:-15%
  • GC:-5%
  • CPU:-2%
  • 内存:+4%

相当于内存换 CPU 和下游调用的方式

灰度/放量

  • 通过 绑定特定的 uid/did 实现局部灰度放量
  • 放量进行 uid%100 判定是否命中放量条件

旁路优化

  • 优化前:
    1. 大量的并发请求, 并且每个请求打脸的 json序列化, 消耗非常多的 CPU
    2. 接收端反序列化也消耗非常多的 CPU
    3. 多次数据传输的过程中延迟会显著增加 20260776153956pasteboard.paste
  • 优化后
    1. 从 rpc 只拉策略, 匹配过程在本地进行, 使用 struct, 不需要序列化成 json, 大大减少了 CPU 的损耗
    2. rpc 请求从随着数字线性增长变成固定次数(只需要拉策略 list), 匹配直接发生在本地, 减少了延迟 20260776153907pasteboard.paste

效果以及缺点

  • 接口平均延迟降低4ms, 线上CPU 负载降级 2%
  • 缺点是 sdk 更新的情况下, 线上的服务可能跑的还是老版本更新不及时, 会出现兼容性问题
    • 解决方式是 strategy加上版本的方式, 兼容性小版本可以直接计算, 不兼容性大版本的情况下直接不参与匹配并报 warning

[!NOTE] 一个反直觉的点:为什么调用方的 CPU 不升反降 从实现的角度上看似乎是本来在 strategy service 的策略匹配的过程和规则通过 sdk 给调用方自己匹配了, 理论上调用方的 CPU应该上升, 实际上下降非常明显 因为链路的核心消耗 CPU 的点并不是那一大堆 ifelse 的策略匹配, 而是大 json 序列化和 rpc 请求的网络传输成本, 这个方案解决这部分因此CPU 损耗下降

后续优化

  1. 没有做多版本的放量体系, 目前只支持单版本的, 主要是为了简化考虑避免引入每个版本的放量的问题(list 加上 version 的字段即可)
  2. hash 指纹, 现在非常多策略是一样的, 实际上整个策略加载到内存中很多是重复的, 非常浪费内存, 特别是策略非常大的情况下, 这一步实际上可以做成
    1. 策略 cuttlefish 存储的时候带上 json 计算出的 hash(计算 hash 这一步有点意思, 得先写入策略的时候(离线不影响性能)进行正则化后 hash ) 20260776152634pasteboard.paste
    2. 缓存的时候分成两部分, 从 mysql 拿出来的时候同时缓存 strategy_key-> hash 和 hash->strayegy_body (目前是直接缓存的 strategy_key -> strayegy_body), strategy_key-> hash 这块非常轻, 直接做成memory缓存+LFU, strategy 的做成 combimne 就行
    3. 取的时候如果 hash 没缓存, 通过 key 把 hash 和 body 从 mysql 拿出来缓存, 如果拿出来的时候发现 body 有了的情况下就进行访问替换来增大 LFU 的值, 如果hash 有 body 没有, 也是走通过 key 把 hash 和 body 从 mysql 拿出来缓存,
  3. 策略加载预编译, 策略加载之后现在即使在缓存中也是需要拿到之后 local 的反序列化+ mapping到结构体+结构体参数一大堆 interface和断言, 但是实际上完全可以策略加载的时候直接 mapping 成下面的格式, 然后存储指针就行, 进一步降低策略匹配的参数的成本
  原始策略:
  {
    "key": "age",
    "type": "number",
    "op": "ge",
    "value": 18
  }
  提前编译后:
  NumberNode{
      Key: "age",
      Op:  GE,
      Val: 18,
  }