招聘通的会员体系里,普通会员有各种权益限制:每天能刷新多少职位、能看多少简历、能下载多少联系方式、能发多少面试邀请。这些规则业务方经常改,一会说刷新次数从5次改成10次,一会说新用户前7天不限次数。最早是硬编码在代码里,每次改规则都要发版,烦不胜烦。2021年做了次重构,把会员策略抽成规则引擎,今天把设计思路分享出来。

一、原始方案的问题

最早的代码长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MemberPrivilege
{
    public function canRefreshJob($userId)
    {
        $member = Member::findOne(['user_id' => $userId]);
        $todayCount = JobRefreshLog::find()
            ->where(['user_id' => $userId])
            ->andWhere(['>=', 'created_at', strtotime('today')])
            ->count();

        // 普通会员每天5次
        if ($member->level == 'normal') {
            return $todayCount < 5;
        }
        // VIP 不限
        return true;
    }

    public function canViewResume($userId)
    {
        // 类似的逻辑...
    }

    public function canDownloadContact($userId)
    {
        // 类似的逻辑...
    }
}

问题很明显:

  1. 硬编码:次数、条件都写死在代码里,改规则要改代码发版
  2. 分散:各种权益的判断逻辑散落在不同方法里,有的甚至散落在业务代码里
  3. 不灵活:加新规则(如新用户前7天不限次数)要写一堆 if-else
  4. 无法热更新:业务方想临时调整活动期间的权益,必须等发版
  5. 测试困难:各种规则组合太多,测试覆盖不全

二、规则引擎设计

核心思路:把规则从代码里抽出来,变成数据,存储在数据库或配置中心,运行时动态加载和执行

2.1 规则模型

一条规则由三部分组成:

  • 条件(Condition):什么情况下适用这条规则
  • 动作(Action):满足条件时做什么(限制次数、放行、拒绝等)
  • 优先级(Priority):多条规则同时满足时,优先级高的先执行
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
CREATE TABLE `member_rule` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL COMMENT '规则名称',
  `privilege_type` varchar(50) NOT NULL COMMENT '权益类型:refresh_job/view_resume/download_contact',
  `condition` text NOT NULL COMMENT '条件表达式(JSON)',
  `action` varchar(20) NOT NULL COMMENT '动作:allow/deny/limit',
  `limit_count` int(11) DEFAULT NULL COMMENT '限制次数(action=limit时有效)',
  `limit_period` varchar(20) DEFAULT NULL COMMENT '限制周期:day/week/month/total',
  `priority` int(11) NOT NULL DEFAULT '100' COMMENT '优先级,数字越大越优先',
  `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用',
  `start_time` int(11) DEFAULT NULL COMMENT '生效开始时间',
  `end_time` int(11) DEFAULT NULL COMMENT '生效结束时间',
  `created_at` int(11) NOT NULL,
  `updated_at` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `privilege_type` (`privilege_type`),
  KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2 条件表达式

条件用 JSON 表示,支持 AND/OR 嵌套和比较运算:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "operator": "AND",
  "conditions": [
    {"field": "member_level", "op": "==", "value": "normal"},
    {"field": "register_days", "op": ">=", "value": 7},
    {
      "operator": "OR",
      "conditions": [
        {"field": "is_new_user", "op": "==", "value": false},
        {"field": "activity_period", "op": "==", "value": false}
      ]
    }
  ]
}

支持的运算符:==, !=, >, >=, <, <=, in, not_in, between

2.3 规则执行引擎

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
class RuleEngine
{
    private $rules = [];

    public function __construct($privilegeType)
    {
        $this->loadRules($privilegeType);
    }

    private function loadRules($privilegeType)
    {
        // 从缓存或数据库加载启用的规则,按优先级排序
        $this->rules = MemberRule::find()
            ->where(['privilege_type' => $privilegeType, 'status' => 1])
            ->andWhere(['or', ['start_time' => null], ['<=', 'start_time', time()]])
            ->andWhere(['or', ['end_time' => null], ['>=', 'end_time', time()]])
            ->orderBy(['priority' => SORT_DESC])
            ->all();
    }

    public function evaluate($context)
    {
        foreach ($this->rules as $rule) {
            if ($this->matchCondition(json_decode($rule->condition, true), $context)) {
                return [
                    'action' => $rule->action,
                    'limit_count' => $rule->limit_count,
                    'limit_period' => $rule->limit_period,
                    'rule_id' => $rule->id,
                    'rule_name' => $rule->name,
                ];
            }
        }
        // 没有匹配的规则,默认放行
        return ['action' => 'allow'];
    }

    private function matchCondition($condition, $context)
    {
        if (isset($condition['operator'])) {
            // 复合条件
            $results = [];
            foreach ($condition['conditions'] as $sub) {
                $results[] = $this->matchCondition($sub, $context);
            }
            if ($condition['operator'] == 'AND') {
                return !in_array(false, $results, true);
            } else {
                return in_array(true, $results, true);
            }
        } else {
            // 原子条件
            $value = $context[$condition['field']] ?? null;
            return $this->compare($value, $condition['op'], $condition['value']);
        }
    }

    private function compare($value, $op, $target)
    {
        switch ($op) {
            case '==': return $value == $target;
            case '!=': return $value != $target;
            case '>': return $value > $target;
            case '>=': return $value >= $target;
            case '<': return $value < $target;
            case '<=': return $value <= $target;
            case 'in': return in_array($value, (array)$target);
            case 'not_in': return !in_array($value, (array)$target);
            case 'between': return $value >= $target[0] && $value <= $target[1];
            default: return false;
        }
    }
}

2.4 权益校验服务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class PrivilegeService
{
    public function check($userId, $privilegeType)
    {
        // 构建上下文
        $context = $this->buildContext($userId);

        // 执行规则引擎
        $engine = new RuleEngine($privilegeType);
        $result = $engine->evaluate($context);

        if ($result['action'] == 'deny') {
            return ['allowed' => false, 'reason' => '权限不足', 'rule' => $result['rule_name']];
        }

        if ($result['action'] == 'limit') {
            $used = $this->getUsedCount($userId, $privilegeType, $result['limit_period']);
            $remaining = $result['limit_count'] - $used;
            return [
                'allowed' => $remaining > 0,
                'limit' => $result['limit_count'],
                'used' => $used,
                'remaining' => max(0, $remaining),
                'rule' => $result['rule_name'],
            ];
        }

        // allow
        return ['allowed' => true, 'limit' => -1, 'remaining' => -1];
    }

    private function buildContext($userId)
    {
        $member = Member::findOne(['user_id' => $userId]);
        return [
            'user_id' => $userId,
            'member_level' => $member->level,
            'register_days' => floor((time() - $member->created_at) / 86400),
            'is_new_user' => (time() - $member->created_at) < 86400 * 7,
            'activity_period' => $this->isActivityPeriod(),
            // ... 更多上下文字段
        ];
    }

    private function getUsedCount($userId, $privilegeType, $period)
    {
        $startTime = $this->getPeriodStart($period);
        return PrivilegeLog::find()
            ->where(['user_id' => $userId, 'privilege_type' => $privilegeType])
            ->andWhere(['>=', 'created_at', $startTime])
            ->count();
    }
}

三、规则配置后台

做了个简单的后台页面,运营人员可以可视化配置规则:

  • 规则列表:查看所有规则,启用/禁用,调整优先级
  • 规则编辑:选择权益类型,配置条件(可视化表单),设置动作和限制
  • 规则测试:输入用户ID,模拟执行,看哪条规则命中、结果是什么
  • 变更记录:记录规则的变更历史,支持回滚

条件配置用可视化表单,运营人员不需要写 JSON,选择字段、运算符、值就行,后台自动转成 JSON 存储。

四、缓存与热更新

规则变更后要立即生效,不能等缓存过期。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class RuleCache
{
    const CACHE_KEY = 'member_rules';
    const CACHE_TTL = 3600;

    public static function get($privilegeType)
    {
        $redis = Yii::$app->redis;
        $key = self::CACHE_KEY . ':' . $privilegeType;
        $data = $redis->get($key);
        if ($data !== false) {
            return json_decode($data, true);
        }

        $rules = MemberRule::find()
            ->where(['privilege_type' => $privilegeType, 'status' => 1])
            ->orderBy(['priority' => SORT_DESC])
            ->asArray()
            ->all();

        $redis->setex($key, self::CACHE_TTL, json_encode($rules));
        return $rules;
    }

    public static function clear($privilegeType = null)
    {
        $redis = Yii::$app->redis;
        if ($privilegeType) {
            $redis->del(self::CACHE_KEY . ':' . $privilegeType);
        } else {
            // 清所有
            $keys = $redis->keys(self::CACHE_KEY . ':*');
            foreach ($keys as $key) {
                $redis->del($key);
            }
        }
    }
}

规则保存时调用 RuleCache::clear() 清缓存,下次请求时重新加载,实现热更新。

五、踩过的坑

1. 规则优先级冲突

两条规则条件有重叠,优先级设置不合理,导致命中了错误的规则。后来加了规则测试功能,配置规则时可以模拟执行,确认命中的是预期规则。

2. 条件表达式解析性能

每次请求都解析 JSON 条件、递归匹配,QPS 高的时候有性能开销。后来把规则和解析后的条件树都缓存起来,避免重复解析。

3. 次数统计的并发问题

限制次数的权益,高并发下同时请求可能超过限制(两个请求同时查到剩余1次,都放行)。用 Redis 原子计数(INCR)替代数据库 COUNT,限制判断和计数在一个原子操作里完成。

4. 规则变更的灰度

运营改规则后直接全量生效,有次改错了导致所有普通会员都不能刷新职位。后来加了灰度功能,规则可以先对部分企业生效,观察没问题再全量。

5. 上下文构建的开销

每次校验都要查会员信息、注册天数等,构建上下文有数据库查询。把上下文信息缓存到 Redis,用户信息变更时更新缓存,减少数据库查询。

六、总结

会员策略引擎重构的核心思路:

  1. 规则数据化:把硬编码的 if-else 变成数据库里的规则记录
  2. 条件表达式:用 JSON 表示复杂条件,支持 AND/OR 嵌套和多种运算符
  3. 规则引擎:通用的条件匹配和规则执行逻辑,新增规则不用改代码
  4. 配置化后台:运营人员可视化配置规则,支持测试和灰度
  5. 热更新:规则变更即时生效,不用发版
  6. 缓存优化:规则和上下文缓存,减少数据库查询和解析开销

重构后,业务方改规则不用找开发了,自己在后台配置就行,即时生效。开发也不用每次改几个数字就发版,省心很多。

规则引擎不是银弹,适合规则频繁变更、条件复杂的场景。如果规则很简单且基本不变,硬编码反而更直接。技术选型要根据实际情况,不要为了用规则引擎而用规则引擎。