事件溯源在 credit 系统里的应用 ## 一个常见需求

做一个"用户钱包"系统:用户充值、消费、退款。看起来简单,就是数据库一张表,每次操作 UPDATE balance SET balance = balance + N

但实际做起来,会发现一堆问题:

  • 高频写导致主从延迟
  • 写热行(balance 那一行)锁竞争
  • 审计困难(谁改的、改之前多少)
  • 退款时怎么记录(覆盖之前的扣款?)

解决:事件溯源 (Event Sourcing)

不存"当前余额",只存"流水"。余额是从流水求和算出来的。

// 事件(append-only,不可变)
{
  "event_id": "evt_a3f9b2c1",
  "ts": "2026-06-19T12:00:00Z",
  "account_id": "user_123",
  "type": "deduct",   // topup / deduct / refund / expire
  "amount": -1,
  "request_id": "req_xyz",
  "reason": "search_api_call"
}

当前余额:

def get_balance(account_id):
    events = clickhouse.query(
        "SELECT sum(amount) FROM events WHERE account_id = %(id)s",
        {"id": account_id}
    )
    return events

4 个好处

1. 审计天然

每笔 credit 变动都有完整来源(谁、什么时候、为什么、改多少)。要查"上个月为什么扣了 100",grep 事件就行。

2. 可重放

任意时间点的余额都能算出来。要做"上季度末余额对账",按时间点过滤事件求和。

3. 无锁竞争

append-only 写,没有"先读后写"的锁。新事件直接 insert。

4. 易扩展

加新事件类型不破坏旧数据(老事件还在)。比如后来加 “promo_bonus” 事件,旧的不动,新的按新逻辑算。

4 个挑战

1. 余额是 derived,不是 source of truth

理论上事件丢失 → 余额不准。所以事件存储要:

  • 多副本(至少 3 份)
  • 不可变(immutable)
  • 定期 snapshot(每隔 1 小时把余额快照存起来,加速查询)

2. 实时性

每次查余额都要 sum 全部事件,慢。所以加一层缓存(Redis),缓存丢了从 ClickHouse 重建。

3. 过期逻辑难

“1 个月后过期"这种逻辑,事件溯源下要算"截止 X 时间前的余额”:

SELECT sum(amount) FROM events
WHERE account_id = %(id)s
  AND ts < %(expire_ts)s

慢,需要单独的 expire job。

4. Schema 演进

事件 schema 改了怎么办?老事件是旧 schema,新事件是新 schema。要做 schema 兼容:

  • 加字段:新字段 nullable,老事件没值
  • 删字段:忽略,parser 不读
  • 改字段类型:加 version 字段,parser 按 version 分流

实现

class CreditSystem:
    def __init__(self, clickhouse, redis):
        self.ch = clickhouse
        self.redis = redis

    def add_event(self, account_id, event_type, amount, request_id, reason):
        # 1. 写事件(append-only)
        event = {
            "event_id": str(uuid.uuid4()),
            "ts": datetime.utcnow().isoformat(),
            "account_id": account_id,
            "type": event_type,
            "amount": amount,
            "request_id": request_id,
            "reason": reason,
        }
        self.ch.insert("events", event)

        # 2. 失效缓存(下次查时重算)
        self.redis.delete(f"balance:{account_id}")

        return event

    def get_balance(self, account_id):
        # 1. 查缓存
        cached = self.redis.get(f"balance:{account_id}")
        if cached is not None:
            return float(cached)

        # 2. 缓存 miss,从事件流算
        result = self.ch.query(
            "SELECT sum(amount) FROM events WHERE account_id = %(id)s",
            {"id": account_id}
        )
        balance = result[0][0] if result else 0

        # 3. 写回缓存(短 TTL,5 分钟)
        self.redis.setex(f"balance:{account_id}", 300, balance)
        return balance

性能 trade-off

操作直接 UPDATE事件溯源
单笔扣款1ms5-10ms(写事件 + 失效缓存)
查余额(缓存命中)1ms1ms(读 Redis)
查余额(缓存未命中)1ms50-200ms(sum 全部事件)
审计追溯难(数据已覆盖)简单(grep 事件)
数据恢复难(无历史)简单(重放事件)

高频写场景(每秒 200+ 写),事件溯源反而更快,因为没锁。

什么时候用

适合:

  • 需要完整审计(金融、积分、credit)
  • 业务有"过去"的概念(版本、回放、撤销)
  • 写多读少(查余额不频繁)
  • 数据可重建(就算丢失能从其他源补)

不适合:

  • 简单 CRUD(直接 UPDATE 更省事)
  • 强实时余额(电商库存,几毫秒必须准)
  • 数据规模超大(SUM 慢,需要物化视图)

我们怎么做

做 SERP API 的 credit 系统用的就是事件溯源:ClickHouse 存事件,Redis 缓存余额。

每天 200 万次扣款,响应延迟 P99 < 10ms(主要是写 ClickHouse)。Redis 命中率 95%,余额查询 < 1ms。

偶尔有客户争议"为什么扣了 100",我们 grep 事件流,5 分钟内给客户完整账目。

小结

事件溯源不是"银弹架构",是"特定场景的好架构":

  • 需要审计 + 历史 + 重建 → 用
  • 简单 CRUD + 强实时 → 别用,直接 UPDATE

判断标准:你的系统需要"过去"吗? 需要就用事件溯源,不需要就别上。


SerpBase 实践

SerpBase credit 系统用事件溯源:

  • 事件存储:ClickHouse(每天 200 万事件)
  • 余额缓存:Redis(命中率 95%)
  • 缓存窗口:5 分钟
  • 过期逻辑:Starter Boost 1 个月,其他永不过期

效果:P99 扣款延迟 < 10ms,客户审计查询 5 分钟内完成。


*本文来自 SerpBase 工程团队。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值