PAYATHON 2026

Google App Engine 中 Big Table 数据库设计

支付小周

结论

这四个概念应该分开建模,但不适合照搬关系型数据库中“完全规范化 + 查询时 JOIN”的做法。

比较合适的方案是:

  • Appointment 只记录工作的执行时间和地点。
  • Invoice 只记录客户应付的账款。
  • Payment 只记录实际收到的一笔资金。
  • LineItem 记录收费、折扣和工时等业务明细。
  • 多对多关系通过独立的关联实体表达。
  • 为日历页面维护专门的读取汇总数据,避免每次加载都执行四级查询。
  • 财务记录以不可变流水和分配记录为准,不用日历中的冗余字段计算应收账款。

四次查询未必会马上带来性能问题。更需要注意的是,查询数量可能随着预约、发票和付款数量增长,最终形成类似关系型数据库中的 N+1 查询。跨实体的异步冗余更新也不能作为财务一致性的基础。

先澄清 Bigtable 与 App Engine Datastore

如果使用的是:

com.google.appengine.api.datastore.*

这里讨论的通常是 App Engine Datastore,而不是直接使用 Cloud Bigtable API。两者都不是支持任意 JOIN 的关系型数据库,但它们的数据模型、事务能力和查询接口并不相同。

下面的设计基于 App Engine low-level Datastore API,或者兼容的 Firestore in Datastore mode。事务范围、批量读取数量和查询限制可能因运行环境而异,实施时需要根据当前使用的 SDK 和服务文档核对。

原结构存在的主要问题

原方案包含两个列表字段:

Payment.invoice_Key_List
Line_Item.appointment_Key_List

这相当于把多对多关系藏在实体属性中,会带来几个问题:

  1. 关联关系无法方便地保存自身信息
    假设一笔 $300 的付款分别冲销两张发票,其中一张 $100,另一张 $200。单纯保存发票 Key 列表,无法表示每张发票具体分配了多少钱。

  2. 修改关系通常需要重写整个实体
    付款关联的发票发生变化时,需要更新 Payment 中的整个列表。出现并发修改时,不同操作更容易互相覆盖。

  3. 列表可能不断膨胀
    Datastore 实体存在大小限制,多值索引还可能大幅增加索引项数量和写入成本。

  4. 查询链会逐渐变长
    “查预约 → 查明细 → 查发票 → 查付款”还只是第一层。加入退款、部分付款、作废发票和贷项后,读取流程会变得更复杂。

因此,多对多关系更适合用关联实体表示,不宜在两端分别维护 Key 列表。

推荐的数据模型

可以将“工作安排”“收费归属”和“资金结算”拆成三条相对独立的链路。

Appointment

Appointment
  customer_key
  start_time
  end_time
  location
  status
  calendar_total_price
  calendar_total_duration
  payment_status
  projection_version

最后四个字段可以作为日历使用的冗余投影,但不能成为财务账本的最终依据。

Service 或 WorkOrder

建议在预约和收费之间增加一个业务实体,例如 ServiceOrder 或 WorkOrder:

ServiceOrder
  customer_key
  description
  status

一次完整的美容服务可能包含多次上门预约。因此,“下雨后第二天继续施工”可以表示为:

ServiceOrder A
  ├── Appointment 1
  └── Appointment 2

这样不必创建虚假的 $0 Finish Up 收费项目。

AppointmentWork

使用关联实体记录预约与工作内容的关系:

AppointmentWork
  appointment_key
  service_order_key
  estimated_minutes
  allocation_ratio
  status

如果一个工作项目跨两个预约执行,可以分别记录每次安排的预计工时或完成比例。

LineItem

LineItem
  service_order_key
  name
  quantity
  unit_price_minor
  commission_minor
  estimated_minutes
  type

金额建议使用最小货币单位的整数,例如美分,不要使用 double:

$160.00 -> 16000
-$10.00 -> -1000

type 可以是:

SERVICE
FEE
DISCOUNT
TAX
CREDIT

Invoice

Invoice
  customer_key
  issued_at
  due_date
  currency
  status
  total_minor
  paid_minor
  balance_minor

total_minor、paid_minor 和 balance_minor 可以作为事务内维护的汇总字段,方便快速读取。

InvoiceLine

不要只在 LineItem 中保存一个 invoice_key。使用关联实体后,一项服务可以拆分到不同发票,多个工作项目也可以合并到同一张发票:

InvoiceLine
  invoice_key
  line_item_key
  description_snapshot
  quantity_snapshot
  unit_price_minor
  amount_minor

这里需要保留描述和价格快照。即使服务目录中的价格后来发生变化,已经开具的发票也不应随之改变。

如果业务明确规定一个 LineItem 永远只能属于一张发票,也可以直接保存 invoice_key。不过,从题目中的组合结算需求来看,使用独立关联实体会更稳妥。

Payment

Payment
  customer_key
  received_at
  currency
  amount_minor
  method
  external_reference
  status

Payment 表示现实中实际收到的一笔钱。例如,一张支票就是一个 Payment,不能为了匹配不同预约而拆成多笔虚假的付款记录。

PaymentAllocation

使用分配实体记录一笔付款如何冲销一张或多张发票:

PaymentAllocation
  payment_key
  invoice_key
  amount_minor
  created_at
  status

例如,一张 $300 的支票用于支付两张发票:

Payment
  amount_minor = 30000

PaymentAllocation
  invoice_key = Invoice A
  amount_minor = 10000

PaymentAllocation
  invoice_key = Invoice B
  amount_minor = 20000

这样既保留了真实的付款记录,也能准确计算每张发票的应收余额。

三种特殊情况如何表示

一项服务需要两次上门

ServiceOrder A
LineItem A: Full Detail, $160

Appointment 1 -> AppointmentWork -> ServiceOrder A
Appointment 2 -> AppointmentWork -> ServiceOrder A

Invoice A -> InvoiceLine -> LineItem A
Payment A -> PaymentAllocation -> Invoice A

预约次数和收费次数彼此独立,不会互相干扰。

一次集中上门服务多个客户

Appointment A
  ├── AppointmentWork -> ServiceOrder Customer 1
  ├── AppointmentWork -> ServiceOrder Customer 2
  └── AppointmentWork -> ServiceOrder Customer 3

每个客户都可以拥有自己的 Invoice 和 Payment。如果实际业务要求使用不同地址或时间段,也可以创建多个预约,再通过批次实体或路线实体将它们关联起来。

一张支票支付两个预约

两个预约产生的收费可以放进同一张发票,也可以分别进入两张发票。无论采用哪种方式,现实中都只创建一个 Payment,再通过一个或多个 PaymentAllocation 分配金额。

日历页面不应临时重建完整财务关系

日历通常需要展示:

  • 起止时间
  • 地点
  • 服务摘要
  • 预计工时
  • 预计金额
  • 未付、部分支付或已支付状态

这些字段可以直接保存在 Appointment 中,也可以放在单独的 AppointmentCalendarView 中:

AppointmentCalendarView
  appointment_key
  start_time
  end_time
  customer_display_name
  service_summary
  total_price_minor
  estimated_minutes
  payment_status
  updated_at

加载日历时,只需按时间范围执行一次查询:

Query query = new Query("AppointmentCalendarView")
    .setFilter(CompositeFilterOperator.and(
        new FilterPredicate(
            "start_time",
            Query.FilterOperator.GREATER_THAN_OR_EQUAL,
            rangeStart),
        new FilterPredicate(
            "start_time",
            Query.FilterOperator.LESS_THAN,
            rangeEnd)))
    .addSort("start_time", Query.SortDirection.ASCENDING);

List<Entity> calendarRows =
    datastore.prepare(query).asList(FetchOptions.Builder.withLimit(pageSize));

对 start_time 同时执行范围查询和排序时,可能需要复合索引,具体以部署时生成的索引提示为准。

用户点击某个预约查看详情后,再根据 Key 或关联实体读取完整的服务、发票和付款信息。这样可以降低常用页面的读取成本,只有真正需要查看详情时才执行复杂查询。

多对多关系的查询方式

关联实体应保存两端的 Key,并为常用查询方向建立索引。例如,查询某个预约对应的工作项目:

Query query = new Query("AppointmentWork")
    .setFilter(new Query.FilterPredicate(
        "appointment_key",
        Query.FilterOperator.EQUAL,
        appointmentKey));

List<Entity> links =
    datastore.prepare(query).asList(FetchOptions.Builder.withDefaults());

查询一笔付款的分配记录:

Query query = new Query("PaymentAllocation")
    .setFilter(new Query.FilterPredicate(
        "payment_key",
        Query.FilterOperator.EQUAL,
        paymentKey));

如果实体 Key 已知,应优先使用批量 Key 读取,不要为每个 Key 分别发起查询:

Map<Key, Entity> entities = datastore.get(keys);

keys 的数量需要控制在合理范围内,并根据情况分页或分批处理。单次请求的具体限制应根据实际使用的 SDK 和当前服务限制确认。

财务一致性如何保证

财务数据和日历投影需要采用不同的一致性策略。

财务写入必须同步完成

创建付款分配时,至少要验证:

所有 PaymentAllocation 金额之和 <= Payment.amount_minor
Invoice.paid_minor <= Invoice.total_minor
Payment.currency == Invoice.currency

如果业务不允许超额付款,还需要验证:

allocation.amount_minor <= Invoice.balance_minor

关键写入应放在可用的事务范围内:

Transaction tx = datastore.beginTransaction();

try {
    Entity payment = datastore.get(tx, paymentKey);
    Entity invoice = datastore.get(tx, invoiceKey);

    long paymentAmount = (Long) payment.getProperty("amount_minor");
    long allocatedAmount = calculateAllocatedAmount(tx, paymentKey);
    long invoiceBalance = (Long) invoice.getProperty("balance_minor");

    if (amountMinor <= 0) {
        throw new IllegalArgumentException("Allocation must be positive");
    }
    if (allocatedAmount + amountMinor > paymentAmount) {
        throw new IllegalStateException("Payment is over-allocated");
    }
    if (amountMinor > invoiceBalance) {
        throw new IllegalStateException("Invoice would be overpaid");
    }

    Entity allocation = new Entity("PaymentAllocation");
    allocation.setProperty("payment_key", paymentKey);
    allocation.setProperty("invoice_key", invoiceKey);
    allocation.setProperty("amount_minor", amountMinor);
    allocation.setProperty("status", "POSTED");

    invoice.setProperty("paid_minor",
        (Long) invoice.getProperty("paid_minor") + amountMinor);
    invoice.setProperty("balance_minor", invoiceBalance - amountMinor);

    datastore.put(tx, allocation);
    datastore.put(tx, invoice);
    tx.commit();
} finally {
    if (tx.isActive()) {
        tx.rollback();
    }
}

这段代码主要展示事务边界和校验方式。能否在同一事务中操作这些实体,取决于所用 Datastore 模式的事务规则和实体组设计,不能只根据数据模型作出判断。

如果事务范围不足,可以考虑:

  • 将分配记录设为发票的子实体,让分配记录和发票余额更新位于同一实体组。
  • 使用幂等命令和状态机完成跨实体流程。
  • 保留不可变的财务流水,并通过校对任务检查汇总字段是否一致。

日历汇总可以异步更新

AppointmentCalendarView.payment_status 等展示字段可以通过任务队列更新,因为页面在短时间内显示旧状态通常可以接受。

任务必须具备幂等性。例如,应根据源数据重新计算目标值,而不是简单执行“余额减去 $100”。后者在任务重试时可能造成重复扣减。

void rebuildCalendarProjection(Key appointmentKey) {
    CalendarTotals totals = calculateFromSourceRecords(appointmentKey);

    Entity view = loadOrCreateCalendarView(appointmentKey);
    view.setProperty("total_price_minor", totals.totalPriceMinor());
    view.setProperty("estimated_minutes", totals.estimatedMinutes());
    view.setProperty("payment_status", totals.paymentStatus());
    view.setProperty("projection_version", totals.sourceVersion());

    datastore.put(view);
}

异步投影失败只应影响页面数据的新鲜程度,不能改变付款是否真实存在,也不能改变发票余额的权威结果。

是否可以保留四次查询

如果数据量很小,而且日历访问不频繁,四次有明确边界的查询可能完全可以接受。在内存中按 Key 分组通常不是主要瓶颈,更值得关注的是网络往返、索引扫描和查询结果数量。

不过,不应把“四次查询”视为固定成本。实际读取过程可能逐渐变成:

1 次预约查询
+ 1 次明细查询
+ N 次发票读取
+ M 次付款或分配查询

使用大量 Key 构造 IN 查询时,还会受到查询展开、索引和请求大小等限制。不能假设任意规模的 Key 集合都能通过一次查询完成。

可以按以下顺序优化:

  1. 先建立清晰的权威数据模型。
  2. 对已知 Key 使用批量读取。
  3. 为日历维护专用投影。
  4. 对结果分页,并限制查询的时间范围。
  5. 使用真实数据测量延迟、读取次数和索引成本。
  6. 确认瓶颈后,再增加新的冗余字段或缓存。

其他注意事项

不要直接存储信用卡信息

应用通常不应自行保存完整卡号或安全码。应使用支付服务商提供的 token、payment method ID 或 vault reference,尤其不能保存 CVV。具体的合规要求取决于支付流程和适用的 PCI DSS 范围。

财务记录不要直接覆盖

已入账的付款、分配和发票明细不宜通过修改旧金额来“修正”。更稳妥的做法是创建冲销、退款、贷项或调整记录,保留完整的审计轨迹。

使用幂等键防止重复收费

支付请求和任务队列都可能重试。可以为业务操作保存唯一的 idempotency_key 或外部交易号,并在创建付款前检查该操作是否已经处理。

明确状态,而不是只保存布尔值

付款状态通常不只有“已付款”和“未付款”:

UNPAID
PARTIALLY_PAID
PAID
OVERPAID
REFUNDED
VOID

预约、发票和付款也应分别使用各自的状态机,避免把多种业务含义塞进单个 completed 字段。

合适的做法不是在“规范化”和“反规范化”之间二选一,而是同时维护两套模型。关联实体和不可变财务流水是权威数据,日历投影是用于快速读取的模型。前者保证应收账款可追溯,后者避免常用页面在每次请求中重建完整的关系图。

备注:内容仅供参考。