PAYATHON 2026

如何设计高效的月度 postpaid invoice module?

支付阿杰

结论

不要等到月末再扫描并计算用户当月的数百万条交易。更合适的做法是采用“持续增量汇总 + 月末快照结算”:

  • 每笔交易入账时,同步或异步更新对应用户、账期和计费维度的累计金额。
  • 月末只读取汇总结果,并生成不可变的 invoice snapshot。
  • 原始 transactions 继续作为事实数据保留,供审计、争议处理和定期对账使用。
  • 采用异步处理时,通过 Transactional Outbox、幂等消费和 reconciliation 保证财务数据正确。

这种设计可以归入 CQRS read model、materialized view 或 event-driven aggregation。是否拆分 invoice service 属于部署层面的选择,本身解决不了全表扫描问题。应先改变计算模型,再考虑服务拆分。

为什么月末集中扫描成本高

现有流程的时间复杂度基本与当月交易数量成正比:

invoice generation cost ≈ O(number_of_monthly_transactions)

当单个用户每月有数百万条交易时,即使查询使用了正确的索引,系统仍要完成大量工作:

  • 从数据库读取大量记录;
  • 解析并执行计费规则;
  • 完成分组、求和和税费计算;
  • 构造账单明细;
  • 在 billing point 与其他在线业务争抢 CPU、I/O 和数据库连接。

问题不在于 invoice module 是否位于 monolith,而在于整个月的计算工作都堆到了月末。把相同逻辑直接迁移到独立服务,只会把瓶颈从 monolith 搬到新服务。

推荐的数据模型

至少应保留三类数据:

  1. transactions:原始交易事实,原则上不可变。
  2. billing_aggregates:按用户、账期、币种、产品或其他计费维度持续汇总的数据。
  3. invoices 和 invoice_lines:生成账单时固化的快照。

示例结构如下:

CREATE TABLE transactions (
    id               BIGINT PRIMARY KEY,
    user_id          BIGINT NOT NULL,
    occurred_at      TIMESTAMP NOT NULL,
    amount_minor     BIGINT NOT NULL,
    currency         CHAR(3) NOT NULL,
    product_code     VARCHAR(64) NOT NULL,
    status           VARCHAR(32) NOT NULL,
    created_at       TIMESTAMP NOT NULL
);

CREATE INDEX idx_transactions_user_time
    ON transactions (user_id, occurred_at);

CREATE TABLE billing_aggregates (
    user_id          BIGINT NOT NULL,
    billing_period   DATE NOT NULL,
    currency         CHAR(3) NOT NULL,
    product_code     VARCHAR(64) NOT NULL,
    transaction_count BIGINT NOT NULL DEFAULT 0,
    amount_minor     BIGINT NOT NULL DEFAULT 0,
    updated_at       TIMESTAMP NOT NULL,
    PRIMARY KEY (
        user_id,
        billing_period,
        currency,
        product_code
    )
);

CREATE TABLE invoices (
    id               BIGINT PRIMARY KEY,
    user_id          BIGINT NOT NULL,
    billing_period   DATE NOT NULL,
    status           VARCHAR(32) NOT NULL,
    subtotal_minor   BIGINT NOT NULL,
    tax_minor        BIGINT NOT NULL,
    total_minor      BIGINT NOT NULL,
    currency         CHAR(3) NOT NULL,
    issued_at        TIMESTAMP,
    version          INTEGER NOT NULL,
    UNIQUE (user_id, billing_period, version)
);

金额应使用最小货币单位的整数,例如分,不要使用 FLOAT 或 DOUBLE。如果复杂费率计算需要超过整数精度,应使用精度明确的 DECIMAL,并统一舍入规则。

billing_period 可以保存账期第一天,例如 2026-08-01。不要依赖服务器本地时间临时推算账期。业务时区和账期边界都应明确定义。

方案一:交易写入时同步汇总

如果 transaction module 和 invoice module 仍使用同一个数据库,最简单可靠的起点,是在写入交易的同一数据库事务中更新汇总表。

BEGIN;

INSERT INTO transactions (
    id,
    user_id,
    occurred_at,
    amount_minor,
    currency,
    product_code,
    status,
    created_at
) VALUES (
    :transaction_id,
    :user_id,
    :occurred_at,
    :amount_minor,
    :currency,
    :product_code,
    'POSTED',
    CURRENT_TIMESTAMP
);

INSERT INTO billing_aggregates (
    user_id,
    billing_period,
    currency,
    product_code,
    transaction_count,
    amount_minor,
    updated_at
) VALUES (
    :user_id,
    :billing_period,
    :currency,
    :product_code,
    1,
    :amount_minor,
    CURRENT_TIMESTAMP
)
ON CONFLICT (
    user_id,
    billing_period,
    currency,
    product_code
)
DO UPDATE SET
    transaction_count =
        billing_aggregates.transaction_count + 1,
    amount_minor =
        billing_aggregates.amount_minor + EXCLUDED.amount_minor,
    updated_at = CURRENT_TIMESTAMP;

COMMIT;

这段语法以 PostgreSQL 为例,其他数据库需要改用相应的 UPSERT 或 MERGE 语法。

月末生成账单时,只需要读取少量汇总行:

SELECT
    currency,
    product_code,
    transaction_count,
    amount_minor
FROM billing_aggregates
WHERE user_id = :user_id
  AND billing_period = :billing_period;

这种方式的一致性较强。只要交易提交成功,汇总数据也会一同提交。代价是交易写入路径多了一次更新,而且高并发用户可能频繁更新同一条汇总记录,引发锁竞争。

可以增加汇总粒度来缓解热点,例如先按天或分片汇总:

(user_id, billing_period, shard_id, product_code, currency)

月末只需合并几十或几百个分片,不必处理数百万条交易。

方案二:事件驱动的异步汇总

如果不想增加交易写入路径的负担,或者计划拆分 invoice service,可以在交易确认后发布事件,由 invoice service 维护自己的 billing projection。

典型流程如下:

Transaction committed
        ↓
Outbox event created in the same DB transaction
        ↓
Publisher sends event to message broker
        ↓
Invoice service consumes event
        ↓
Idempotently updates billing aggregate
        ↓
Billing period closes
        ↓
Invoice snapshot is generated

不要采用“先写数据库,再直接发送消息”的简单双写:

write transaction → publish message

如果数据库写入成功,但消息发送失败,账单侧就会永久遗漏这笔交易。更稳妥的方案是 Transactional Outbox:在同一个本地事务中写入交易和待发送事件,再由后台进程可靠地发布事件。

BEGIN;

INSERT INTO transactions (...) VALUES (...);

INSERT INTO outbox_events (
    event_id,
    aggregate_type,
    aggregate_id,
    event_type,
    payload,
    created_at
) VALUES (
    :event_id,
    'transaction',
    :transaction_id,
    'TransactionPosted',
    :payload,
    CURRENT_TIMESTAMP
);

COMMIT;

消费端必须按消息可能重复投递来设计。可以使用事件 ID 建立去重表:

BEGIN;

INSERT INTO processed_events (
    consumer_name,
    event_id,
    processed_at
) VALUES (
    'invoice-aggregator',
    :event_id,
    CURRENT_TIMESTAMP
)
ON CONFLICT (consumer_name, event_id) DO NOTHING;

应用程序需要检查受影响的行数。如果插入结果为零,说明该事件已经处理,可以直接结束事务。否则继续更新汇总数据:

INSERT INTO billing_aggregates (
    user_id,
    billing_period,
    currency,
    product_code,
    transaction_count,
    amount_minor,
    updated_at
) VALUES (
    :user_id,
    :billing_period,
    :currency,
    :product_code,
    1,
    :amount_minor,
    CURRENT_TIMESTAMP
)
ON CONFLICT (
    user_id,
    billing_period,
    currency,
    product_code
)
DO UPDATE SET
    transaction_count =
        billing_aggregates.transaction_count + 1,
    amount_minor =
        billing_aggregates.amount_minor + EXCLUDED.amount_minor,
    updated_at = CURRENT_TIMESTAMP;

COMMIT;

这里要实现的是业务层面的 exactly-once effect,而不是假设消息系统绝不会重复投递。

月末应生成不可变快照

月末关账时,已经出具的 invoice 不应继续引用仍在变化的汇总表。应把最终结果复制到 invoice 和 invoice lines,保存为当时的财务快照。

一种可行的状态流转是:

OPEN → CLOSING → CLOSED → INVOICED

各状态分别承担以下职责:

  • OPEN:继续接收并汇总当前账期的交易。
  • CLOSING:停止直接修改该账期,等待在途事件处理完成。
  • CLOSED:汇总值已经冻结,可以计算税费和折扣。
  • INVOICED:账单快照已经生成,并获得唯一 invoice number。

生成账单的操作也必须幂等:

INSERT INTO invoices (
    id,
    user_id,
    billing_period,
    status,
    subtotal_minor,
    tax_minor,
    total_minor,
    currency,
    issued_at,
    version
) VALUES (
    :invoice_id,
    :user_id,
    :billing_period,
    'ISSUED',
    :subtotal_minor,
    :tax_minor,
    :total_minor,
    :currency,
    CURRENT_TIMESTAMP,
    1
)
ON CONFLICT (user_id, billing_period, version) DO NOTHING;

即使月末任务发生重试,也不能为同一用户和账期重复生成相同版本的 invoice。

延迟交易、退款和冲正怎么处理

实际系统很难保证所有交易都能在自然月最后一秒前处理完,因此要提前定义业务规则。

常见做法有:

  • 设置 grace period,在账期结束后等待一段由业务规定的时间再关账;
  • 根据事件的业务发生时间 occurred_at 判断所属账期,而不是使用消费时间;
  • 对账单出具后才到达的交易生成 adjustment;
  • 退款和冲正通过新的负向交易记录,不直接修改历史交易;
  • 必要时生成 credit note、debit note 或新版本账单。

具体等待时间和会计处理方式取决于业务合同、税务要求及当地监管规定,不能仅从技术角度决定。

事件还可能乱序到达。例如,退款事件可能先于原交易抵达 invoice service。消费端需要暂存无法关联的事件,或者按照交易状态模型处理,不能依赖消息严格有序。

原始交易明细仍然需要保留

增量汇总不能取代原始交易数据,两者用途不同:

  • 汇总表用于高效生成账单;
  • 原始交易用于审计、重算、争议处理和故障恢复。

如果账单必须展示每一笔交易,生成 PDF 时也不应一次加载数百万条记录。可以把内容拆开处理:

  • 主 invoice:展示总额、税费,以及按产品或日期汇总的数据;
  • transaction statement:作为独立附件或下载文件;
  • 明细查询 API:使用 cursor pagination 分页;
  • 大文件导出:异步生成 CSV、Parquet 或压缩文件,并存入对象存储。

数据库查询应使用稳定游标,避免大偏移量分页:

SELECT
    id,
    occurred_at,
    amount_minor,
    currency,
    product_code
FROM transactions
WHERE user_id = :user_id
  AND occurred_at >= :period_start
  AND occurred_at < :period_end
  AND id > :last_seen_id
ORDER BY id
LIMIT :page_size;

这段查询的前提是 id 可以提供稳定顺序。如果交易 ID 与时间顺序无关,应使用 (occurred_at, id) 作为复合游标。

对账和重算不能省略

异步架构采用最终一致性,但这不代表可以容忍金额永久不一致。至少应设置两层校验。

日常增量校验

记录每个账期的以下信息:

  • 交易数量;
  • 金额合计;
  • 最后处理的事件位置;
  • 消费延迟;
  • 重复事件和失败事件数量。

周期性全量对账

在低峰期按用户、日期或数据分区重新聚合原始交易,再与 billing_aggregates 比较:

SELECT
    user_id,
    DATE_TRUNC('month', occurred_at) AS billing_period,
    currency,
    product_code,
    COUNT(*) AS transaction_count,
    SUM(amount_minor) AS amount_minor
FROM transactions
WHERE occurred_at >= :range_start
  AND occurred_at < :range_end
  AND status = 'POSTED'
GROUP BY
    user_id,
    DATE_TRUNC('month', occurred_at),
    currency,
    product_code;

这类查询不在开票的关键路径上,可以按分区执行、限制速度,并放在只读副本或分析系统中运行。发现差异后,不要直接覆盖已经出具的账单。应先查明原因,再通过 adjustment 或受控的账单版本机制修正。

是否应该拆分为独立 invoice service

出现以下情况时,拆分通常更有价值:

  • transaction 和 invoice 的扩缩容需求明显不同;
  • 开票规则变化频繁,需要独立发布;
  • 多个业务系统都向同一个计费系统发送交易;
  • invoice 有独立的权限、合规或数据保留要求;
  • 团队已经具备消息投递、监控、追踪和故障恢复能力。

如果眼下的主要问题只是月末查询太慢,可以先在 monolith 内建立汇总表和 outbox,以较低风险验证数据模型。以后拆分服务时,现有的事件契约和 projection 仍可继续使用。

拆分后,invoice service 应拥有自己的数据库,不应跨服务直接查询 transaction service 的内部表。它可以通过以下方式取得所需数据:

  • 消费 transaction domain events;
  • 调用受控的 transaction API,获取少量补充信息;
  • 对账时读取专门提供的导出数据或数据仓库。

“两个服务共享同一数据库”虽然迁移起来简单,却会保留强耦合,使服务难以独立部署、修改 schema 和控制负载。

建议的落地顺序

可以分阶段实施:

  1. 明确账期边界、时区、交易状态、舍入规则和迟到交易策略。
  2. 为现有查询补齐 (user_id, occurred_at) 等必要索引,并检查执行计划。
  3. 在 monolith 内新增 billing_aggregates,从新交易开始维护增量汇总。
  4. 使用历史交易回填当前账期的汇总数据。
  5. 建立对账任务,确认增量结果可以根据原始交易重算。
  6. 将月末开票流程改为读取汇总表,并生成 invoice snapshot。
  7. 引入 outbox,把同步汇总逐步改为异步 projection。
  8. 等事件契约、幂等机制和监控稳定后,再决定是否拆分 invoice service。

迁移期间可以暂时同时运行新旧两套计算流程,只比较结果,不让新流程正式出账。确认金额、交易数、税费和舍入结果一致后,再切换开票数据来源。

需要特别注意的事项

  • 不要把 cache 当作财务事实来源。缓存丢失后,所有数据都必须能够恢复。
  • 不要使用浮点数计算金额。
  • 不要假设消息只会投递一次,或一定会严格按顺序到达。
  • 不要静默修改已经出具的 invoice 金额。
  • 不要只保存累计总额而丢掉计费维度,否则税费、折扣和审计数据可能无法重算。
  • 汇总逻辑和历史重算逻辑应共用同一套计费规则,避免两套实现出现偏差。
  • 计费规则需要版本化,历史账期应能识别当时使用的规则版本。
  • 对于热点用户,可以按天、产品或 hash shard 分散写入,再在关账时合并。
  • 原始交易表可以按时间分区,但分区只能降低查询和维护成本,不能替代增量汇总。
  • 开票任务应支持 checkpoint、重试、限流和单用户隔离,避免一个超大账户阻塞整个批次。

核心原则是把集中在月末的批量计算,分散为整个月持续进行的小规模计算。月末只负责冻结数据、校验结果和生成账单快照。服务拆分可以放在后面考虑。数据一致性、幂等、关账和对账机制,才决定这套架构能否可靠运行。

备注:内容仅供参考。