Building a Real-Time Risk Control Decision Engine with Spring Boot: Rule Orchestration, List Services, Feature Computation & Decision Traceability

This article details a production-grade risk control engine built with Spring Boot 3 and Java 17, covering five-layer architecture, DSL-based rule modeling with decision tables, Bloom-filter-accelerated list services, sliding-window real-time feature computation via Redis+Lua, timeout-aware orchestration with tiered degradation, and full decision traceability for audit and tuning.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Building a Real-Time Risk Control Decision Engine with Spring Boot: Rule Orchestration, List Services, Feature Computation & Decision Traceability

1. Why Risk Control Engines Differ from Regular Rule Engines

The author contrasts typical business rule engines (async, low concurrency, seconds-level latency acceptable, monthly rule changes, restart-friendly) with risk control engines that must:

Run synchronously in the transaction critical path

Meet P99 latency ≤ 50 ms with configurable fallback

Support hot rule updates without restart

Depend on request params, lists, real-time features, and offline profiles simultaneously

Offer multiple hit policies: FIRST_HIT, ALL_HIT, SCORE

Provide full explainability ("why was I rejected?") within seconds

Implement tiered degradation (allow vs. block) per business line with versioning, canary, appeal, and audit

Three real-world scenarios illustrate the gap: marketing coupon abuse (3,000 accounts from one device fingerprint in 10 min), flash sale fraud (20× order spike, new accounts, duplicate addresses), and stolen-card payment (geo-velocity anomaly requiring intercept / manual review / 2FA).

2. Five-Layer Architecture

┌─────────────────────────────────────────────────────────┐
│ 接入层  risk-gateway                                    │
│  鉴权/限流/场景路由/降级开关/SDK 与 HTTP 双协议          │
└───────────────────────────┬─────────────────────────────┘
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 决策编排层  decision-orchestrator                       │
│  场景决策流编排(pre → list → feature → rule → post)     │
│  并行/串行编排、超时控制、降级兜底、结果组装             │
└───┬──────────┬──────────┬──────────┬──────────┬─────────┘
    ▼          ▼          ▼          ▼          ▼
┌────────┐┌────────┐┌──────────┐┌────────┐┌──────────────┐
│规则引擎 ││名单服务 ││特征中心   ││决策日志 ││人工审核服务   │
│DSL/决策表││黑白灰   ││实时+离线  ││流水+快照││工单/申诉      │
└────────┘└────────┘└──────────┘└────────┘└──────────────┘
                            ▼
                    Redis / MySQL / Kafka / ES

risk-gateway : unified entry, auth, rate-limit, scene routing, global kill-switch (config-center push → "list-only" mode for big promotions).

decision-orchestrator : controls step sequencing (serial/parallel), per-step timeout budget, and fallback policy (skip vs. full degrade).

Rule engine : pure computation, stateless, concurrent, expression pre-compiled.

List service : fast negative layer, Bloom filter front-end.

Feature center : real-time + offline features with cache and degradation.

Decision log : black box → Kafka → async MySQL/ES for replay, audit, rule tuning.

Design principle: compute/IO separation, fast/slow separation, degradable, traceable .

3. Rule Modeling: DSL, Decision Tables, Hit Policies & Canary

3.1 Structured DSL + Lightweight Expression Engine

Operators do not write raw QLExpress/SpEL strings. Instead, a JSON-structured DSL describes the condition tree (AND/OR/NOT composite nodes, leaf nodes with field/op/value). Leaf evaluation uses pre-compiled Aviator expressions — orders of magnitude faster than runtime eval at thousands of QPS.

Example rule JSON:

{
  "ruleId": "R_COUPON_001",
  "ruleName": "新设备高频领券拦截",
  "scene": "COUPON_CLAIM",
  "version": "20240612.3",
  "priority": 100,
  "enabled": true,
  "severity": "REJECT",
  "condition": {
    "logic": "AND",
    "children": [
      { "field": "feature.deviceAccountCnt1h", "op": "GTE", "value": 5 },
      { "field": "feature.userAgeDays", "op": "LT", "value": 3 },
      { "logic": "OR", "children": [
        { "field": "event.ipProvince", "op": "IN", "value": ["XX省", "YY省"] },
        { "field": "list.hitBlackDevice", "op": "EQ", "value": true }
      ]}
    ]
  },
  "action": { "decision": "REJECT", "reason": "DEVICE_FARM", "score": 80 },
  "gray": { "mode": "PERCENT", "percent": 10 }
}

Composite pattern implementation:

public interface ConditionNode {
    boolean evaluate(EvalContext ctx);
}

public class LeafNode implements ConditionNode {
    private final String field;
    private final Operator op;
    private final Object expect;
    private final CompiledExpression<Boolean> compiled; // pre-compiled at load

    @Override
    public boolean evaluate(EvalContext ctx) {
        Object actual = ctx.get(field);
        ctx.trace(field, actual, op, expect); // cheap trace
        if (actual == null) return false; // missing defaults to no-hit
        return compiled.execute(ctx.asEnv());
    }
}

public class AndNode implements ConditionNode {
    private final List<ConditionNode> children;
    public boolean evaluate(EvalContext ctx) {
        for (ConditionNode c : children) {
            if (!c.evaluate(ctx)) {
                ctx.shortCircuit(c); // record short-circuit position
                return false;
            }
        }
        return true;
    }
}

Missing-value defaults to false after a production incident where a field rename ( userAgeDays → user_age_days) caused nulls to pass a LT check, incorrectly blocking legitimate users. Every evaluation writes a trace (field, actual, op, expect) for later debugging.

3.2 Decision Tables for Homogeneous Rules

Many risk rules share the same structure (e.g., "deviceAccountCnt1h ≥ N AND userAgeDays < M → REJECT"). A decision table lets ops edit cells in a grid; the backend compiles rows into condition trees at save time.

Row | deviceAccountCnt1h≥N | userAgeDays<N | List            | → Decision | Reason         | Score
1   | 10                   | 3              | Hit black device| REJECT     | DEVICE_FARM    | 100
2   | 5                    | 7              | —               | REVIEW     | SUSPECT_DEVICE | 60
3   | 3                    | 30             | —               | PASS       | —              | 10

Real-time preview shows which rows would hit; training cost drops by half compared to raw DSL editing.

3.3 Three Hit Policies (Scene-Configurable)

FIRST_HIT : rules sorted by priority, stop at first match. Lowest latency, for hard blocks.

ALL_HIT : execute all, aggregate hits, pick most severe severity. Payment scenarios need full explanation.

SCORE : accumulate score from all hits, map to decision bands (e.g., <50 PASS, 50-80 REVIEW, >80 REJECT). Closest to real risk practice because single rules are weak signals.

Core decision loop:

public Decision decide(List<Rule> rules, EvalContext ctx, HitPolicy policy) {
    List<RuleHit> hits = new ArrayList<>();
    int score = 0;
    for (Rule rule : rules) { // already priority-sorted
        ctx.beginRule(rule.getId());
        boolean matched = rule.match(ctx);
        ctx.endRule();
        if (!matched) continue;
        hits.add(new RuleHit(rule.getId(), rule.getAction(), ctx.traceOf(rule.getId())));
        if (policy == HitPolicy.FIRST_HIT) {
            score = rule.getScore();
            break;
        }
        score += rule.getScore();
    }
    Decision d = switch (policy) {
        case FIRST_HIT -> hits.isEmpty() ? Decision.PASS : hits.get(0).toDecision();
        case ALL_HIT   -> mostSevere(hits);
        case SCORE     -> mapScore(score);
    };
    return d.withHits(hits).withScore(score);
}
beginRule/endRule

delimit trace segments so traceOf(ruleId) returns only that rule's evaluation path. FIRST_HIT pitfall: an over-broad high-priority rule can shadow more precise later rules; must validate with historical traffic replay before release.

3.4 Immutable Versioning & Two-Level Canary

Each publish creates an immutable snapshot (e.g., 20240612.3), full rule set serialized to Redis, nodes cache via AtomicReference<RuleSet> — swap reference, no intermediate state.

Canary: version-level config holds stableVersion, grayVersion, grayPercent; request-level bucket = hash(sceneCode + userId) % 100 (using Math.floorMod because Objects.hash can be negative). Config-center push + Redis pub/sub broadcasts change; nodes atomically swap reference. Auto-rollback on reject-rate divergence is mandatory — human reaction is too slow.

4. List Service: Bloom Filter, Sharded Hash & Hot Update

4.1 Storage Model

MySQL authoritative source:

CREATE TABLE risk_list_entry (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  list_type TINYINT NOT NULL COMMENT '1黑 2灰 3白',
  dimension VARCHAR(32) NOT NULL COMMENT 'USER/DEVICE/IP/CARD/MOBILE',
  list_value VARCHAR(128) NOT NULL,
  scene_scope VARCHAR(64) DEFAULT '*' COMMENT '生效场景,*为全场景',
  expire_at DATETIME NULL,
  source VARCHAR(32) COMMENT 'MANUAL/RULE_AUTO/3RD_PARTY',
  operator VARCHAR(64),
  status TINYINT DEFAULT 1,
  UNIQUE KEY uk (list_type, dimension, list_value, scene_scope)
);

Redis read side: per-dimension Hash storing listValue → expiryTimestamp|reasonCode. Logical expiry + scheduled cleanup because Redis Hash lacks field-level TTL. Pipeline merges multi-dimension queries (4-5 per decision) into one RTT.

4.2 Bloom Filter Front-End

99% of list lookups miss. Five Redis RTTs would consume ~5 ms of the 50 ms budget. A Guava BloomFilter (200k capacity, 1% FPR, few hundred KB) sits in front:

@Component
public class ListBloomFilter {
    private volatile BloomFilter<String> bloom =
        BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), 200_000, 0.01);

    public boolean mightContain(Dimension d, String value) {
        return bloom.mightContain(d.name() + ":" + value);
    }
    public void onEntryAdded(Dimension d, String value) {
        bloom.put(d.name() + ":" + value);
    }
}

Read path: mightContain == false → definite miss; true → probe Redis (1% false positives). Bloom cannot delete; removals rely on Redis authoritative data. Full rebuild + atomic reference swap handles purges.

4.3 Sync & Hot Update Pipeline

运营后台/三方名单 → MySQL 写入
  → Canal 订阅 binlog → Kafka(risk-list-change)
  → 名单服务消费:写 Redis + 本地 bloom.put + 发 Redis Pub/Sub 广播
  → 各节点本地 Caffeine 缓存失效

Local Caffeine caches only negative results (TTL 5 s); positive reads go straight to Redis. Even if broadcast drops, worst-case staleness is 5 s — no "deleted list still blocking for 30 min" incidents.

Check logic collects hits across all dimensions (no early exit on blacklist) because whitelist > blacklist > gray; a user on both white and black (manual override vs. auto rule) must pass. Whitelist hit short-circuits the entire orchestration.

5. Real-Time Feature Computation: Sliding Windows & Consistency

5.1 Two Window Implementations

ZSet exact window : ZADD key score=ts member=eventId, count via ZCOUNT key (now-window) now, cleanup with ZREMRANGEBYSCORE. Precise but memory-heavy for large windows (24 h).

Bucketed counting (preferred) : 1 h window → 60 × 1-min buckets, each a counter. Sum buckets in window. Fixed memory, no cleanup, precision limited to bucket granularity — acceptable for risk.

Counting features use buckets; distinct-count features ("how many different accounts linked to this device in 1 h") use ZSet or HyperLogLog. No single solution fits all.

5.2 Atomic Redis+Lua Counter

Feature write must atomically "expire old buckets, increment current bucket, return window sum". Multi-round trips race under concurrency.

-- KEYS[1] = feature key prefix, ARGV[1]=now(ms), ARGV[2]=windowSec, ARGV[3]=bucketSec
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local bucket = tonumber(ARGV[3])
local startBucket = math.floor((now - window * 1000) / (bucket * 1000))
local curBucket = math.floor(now / (bucket * 1000))
local key = KEYS[1] .. ':b:' .. curBucket
redis.call('INCR', key)
redis.call('EXPIRE', key, window + bucket)
local sum = 0
for b = startBucket, curBucket do
    local v = redis.call('GET', KEYS[1] .. ':b:' .. b)
    if v then sum = sum + tonumber(v) end
end
return sum

Spring Boot side uses DefaultRedisScript — one RTT per feature:

public long incrAndSum(String entity, String feature, long windowSec) {
    String key = "ft:" + feature + ":" + entity;
    Long sum = redis.execute(SCRIPT, List.of(key),
        String.valueOf(System.currentTimeMillis()), String.valueOf(windowSec), "60");
    return sum == null ? 0L : sum;
}

Gotchas: time units (now in ms, window/bucket in sec, script converts), EXPIRE on current bucket key set to window+bucket so bucket survives full window, summation range [startBucket, curBucket] includes current bucket (approximate — current bucket only counts events so far). Acceptable for risk; exact reconciliation must account for bias.

5.3 Timeliness & Consistency

Time alignment : all nodes use Redis TIME or shared time source; bucket boundaries computed by Redis from passed timestamp. Node clock skew within seconds is harmless.

Feature TTL & dirty reads : on read failure, mark featureDegraded=true; orchestration skips dependent rules but tags them, logs degradation reason. Never fabricate a feature value.

Backfill : real-time stream occasionally loses events (MQ retry exhaustion, restarts). Offline (Flink/Spark) recomputes T+1 features from raw logs, diffs against real-time, writes corrections to "feature correction table"; real-time reads merge correction. Ultimate fix: Lambda architecture with shared feature definitions.

Real/offline definition drift causes longest investigations. Example: real-time counted "device-linked accounts", offline counted "device-linked verified accounts" — two-character difference, 0.3 pp false-positive increase, took three days to find. Unified Feature Definition registry eliminated this class of bugs.

6. Decision Execution Chain: Timeout, Degradation, Cache & Suspend

6.1 Synchronous Main Flow

public DecisionResult decide(DecisionRequest req) {
    long deadline = System.currentTimeMillis() + req.getTimeoutMs(); // default 80ms
    EvalContext ctx = EvalContext.of(req);

    // 1. List, whitelist first
    ListHitResult list = listService.check(req.getEvent(), req.getSceneScopes());
    if (list.hitWhite()) return DecisionResult.pass("WHITE_LIST");
    if (list.hitBlack()) return DecisionResult.reject("BLACK_LIST", list.meta());

    // 2. Parallel feature fetch with timeout
    CompletableFuture<Map<String, Object>> ftF = CompletableFuture
        .supplyAsync(() -> featureService.batchGet(req), featurePool)
        .orTimeout(Math.min(20L, remaining(deadline)), TimeUnit.MILLISECONDS)
        .exceptionally(e -> {
            ctx.markDegraded("FEATURE");
            return Map.of();
        });
    Map<String, Object> features = ftF.join();
    ctx.putAll(features);
    ctx.put("list", list);

    // 3. Rule execution
    RuleSet rs = ruleProvider.pick(req.getScene(), req.getUserId());
    Decision d = engine.decide(rs.getRules(), ctx, rs.getHitPolicy());

    // 4. Output & log
    DecisionResult result = DecisionResult.of(d, ctx);
    logService.asyncWrite(req, result, ctx); // Kafka, non-blocking
    if (d.needManualReview()) reviewService.createTicket(req, result);
    return result;
}

List step is synchronous Redis (client timeout fallback) — Bloom already filters most, extra future wrapper adds thread-hop overhead. featurePool is a dedicated thread pool isolated from business pool to prevent slow feature calls from starving main threads.

6.2 Tiered Timeout Budget & Degradation

Total 80 ms budget split: list 10 ms, feature 20 ms, rule compute 20 ms, log 0 ms (async). Remaining 30 ms for network/GC jitter.

Degradation is not simple PASS. On feature failure, retain only critical=true rules (no feature dependency, pure input checks) — black device/IP still blocked. Implemented as:

if (ctx.degraded("FEATURE")) {
    rules = rules.stream().filter(Rule::isCritical).toList();
    ctx.markDegraded("RULE_TRIMMED");
}

Every degradation tier is config-center hot-switchable; every degradation emits metrics. Degradation alerts must fire before business complaints.

6.3 Decision Result Caching

Two use cases:

Idempotency : same requestId retry → return cached result, avoid double-counting features.

Short TTL reuse : same user+scene within 10 s (e.g., product detail page polling) → Caffeine local cache, 30-50% hit rate, saves most feature reads.

Cache key includes scene + identity dimension. Reject decisions are not cached — user may supplement info and retry; stale reject would incorrectly persist.

6.4 Manual Review Suspend

Not all suspicious cases are rejected. REVIEW returns PENDING_REVIEW; business freezes operation (order → "awaiting review"). Async ticket created with full decision context snapshot (hit rules, feature values, score). Reviewer sees evidence chain, not opaque "high risk". Review conclusion writes back to business and feeds labeled data for rule tuning. Ticket must carry decisionId — primary key for traceability.

7. Decision Traceability & Effect Evaluation

7.1 Wide-Table Log with Snapshots

CREATE TABLE risk_decision_log (
  decision_id VARCHAR(40) PRIMARY KEY,
  request_id VARCHAR(64),
  scene VARCHAR(64),
  entity_key VARCHAR(128) COMMENT '用户/设备主键',
  decision VARCHAR(16) COMMENT 'PASS/REJECT/REVIEW',
  reason_code VARCHAR(64),
  score INT,
  hit_rules JSON COMMENT '命中规则ID+版本+条件trace',
  feature_snap JSON COMMENT '特征快照(关键特征值)',
  param_snap JSON COMMENT '请求入参脱敏快照',
  rule_version VARCHAR(32),
  degraded VARCHAR(64),
  cost_ms INT,
  create_time DATETIME(3)
) PARTITION BY RANGE (TO_DAYS(create_time));

Write path: app → Kafka → consumers → MySQL + ES. Sync path only publishes MQ — direct DB writes under load once dragged down the transaction chain. Kafka consumer failures spill to local disk for durability. Pre-create six months of daily partitions; otherwise midnight writes fail silently until next-day discovery.

7.2 Hit Rule Lineage

hit_rules

stores full evaluation trace, not just rule IDs:

[{
  "ruleId": "R_COUPON_001",
  "version": "20240612.3",
  "score": 80,
  "trace": [
    { "field": "feature.deviceAccountCnt1h", "op": "GTE", "expect": 5, "actual": 12, "pass": true },
    { "field": "feature.userAgeDays", "op": "LT", "expect": 3, "actual": 1, "pass": true },
    { "node": "OR#1", "pass": true, "detail": "ipProvince=XX省 命中" }
  ],
  "shortCircuit": null
}]

Support can answer "why rejected?" in 10 s. Aggregating field/actual across traces yields distributions (e.g., last 7 days userAgeDays distribution for R_COUPON_001) — primary input for threshold tuning.

7.3 Appeal & Tuning Closed Loop

Full loop: decision → complaint/appeal → manual review labeling → rule effect attribution → rule adjust → canary → verify. Missing any link = flying blind.

Key metrics (pre-aligned definitions):

Hit rate = rule hits / total requests (detect over-broad rules)

False-positive rate = upheld appeals / rejects (core UX metric, internal red line 0.1%)

Review consistency = manual conclusion == engine conclusion (rule accuracy)

Post-reject conversion loss = rejected users' 30-day GMV delta (business cost)

Rule contribution = intercept delta when rule removed (identify redundancy)

Tuning always via canary with auto-rollback: e.g., 5% gray, 24 h observation, false-positive rise > 0.05 pp → instant rollback. Manual rollback too slow for live incidents.

8. Performance Testing & Governance

8.1 Test Targets

Single 4C8G node targets: pure rule compute 3,000-5,000 QPS; end-to-end with list+feature P99 ≤ 30 ms; ≤ 300 rules per scene; G1 YGC ≤ 50 ms. Numbers derived from load tests, not guesswork.

Gatling simulates realistic mix: 80% normal, 15% suspicious, 5% blacklist. More valuable: shadow traffic replay — mirror live requests to new engine version, diff decisions. Catches logic bugs synthetic loads miss because synthetic distributions are fabricated.

8.2 Rule Bloat Governance

Rules inevitably grow from 50 to 800+. Preemptive governance:

Tiering : critical (must run, no IO), normal (use features), observe ( mode=OBSERVE, log only). New rules spend a week in observe before enforcement.

Scene isolation : each sceneCode loads own rule subset; payment never runs marketing rules.

Expression pre-compilation (re-emphasized): all leaf conditions compiled to Aviator at load; saves 10+ ms at scale.

Inverted index : index rules by key dimensions (userId in blacklist, amount range) to candidate-filter before evaluation. Essential beyond 1,000 rules.

Automatic retirement : rules with zero hits for 30 consecutive days flagged for archival. Rules only accumulate; nobody dares delete — chronic problem.

8.3 LiteFlow / Drools vs. Custom DSL

LiteFlow : flow & component orchestration, easy learning, hot reload, but rule source DIY. Fine for pure business flow orchestration.

Drools : general BRMS, strong DRL, built-in decision tables, mature. However, DRL steep learning curve; Rete network memory/GC pressure under frequent changes + high concurrency; trace granularity hard to customize. Overkill for few-hundred-rule, ultra-low-latency, high-explainability needs.

Custom DSL + lightweight expression engine : highest performance (no Rete overhead), fully controllable trace. Cost: must maintain compilation, versioning, canary tooling — non-trivial engineering investment.

Chosen: custom DSL. Hybrid alternative: LiteFlow orchestrates "list → feature → rule → review" flow; rule evaluation embeds custom DSL engine. Orchestration and rule compute are separate concerns; no need to force them into one framework.

9. Closing: The Four Hard Things

Data : list second-level effectiveness, real-time/offline feature consistency. Bloom front, authoritative source trace, feature backfill — supporting actors that are actually protagonists.

Timeout posture : transaction path cannot wait. Pre-allocated budget, tiered degradation, every degradation metered. Configurable fail-open/fail-closed, never hard-coded.

Explainability & replay : every reject must answer "why"; every rule change must verify effect. Trace + snapshot from day one, not retrofitted after incidents.

Rule rot : rules only grow. Observe mode, hit stats, auto-retire, canary rollback — governance mechanism matters more than any single rule.

Spring Boot provides the engineering chassis (concurrency, config, observability, async). The differentiator is doing compute/IO separation, fast/slow separation, degradability, traceability. Treat decision logs and degradation plans as first-class design citizens — only then dare you hang the engine on the transaction critical path.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Rule EngineRedisSpring BootBloom FilterReal-time ComputingRisk ControlCanary ReleaseDecision Traceability
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.