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
这相当于把多对多关系藏在实体属性中,会带来几个问题:
-
关联关系无法方便地保存自身信息
假设一笔$300的付款分别冲销两张发票,其中一张$100,另一张$200。单纯保存发票 Key 列表,无法表示每张发票具体分配了多少钱。 -
修改关系通常需要重写整个实体
付款关联的发票发生变化时,需要更新Payment中的整个列表。出现并发修改时,不同操作更容易互相覆盖。 -
列表可能不断膨胀
Datastore 实体存在大小限制,多值索引还可能大幅增加索引项数量和写入成本。 -
查询链会逐渐变长
“查预约 → 查明细 → 查发票 → 查付款”还只是第一层。加入退款、部分付款、作废发票和贷项后,读取流程会变得更复杂。
因此,多对多关系更适合用关联实体表示,不宜在两端分别维护 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 集合都能通过一次查询完成。
可以按以下顺序优化:
- 先建立清晰的权威数据模型。
- 对已知 Key 使用批量读取。
- 为日历维护专用投影。
- 对结果分页,并限制查询的时间范围。
- 使用真实数据测量延迟、读取次数和索引成本。
- 确认瓶颈后,再增加新的冗余字段或缓存。
其他注意事项
不要直接存储信用卡信息
应用通常不应自行保存完整卡号或安全码。应使用支付服务商提供的 token、payment method ID 或 vault reference,尤其不能保存 CVV。具体的合规要求取决于支付流程和适用的 PCI DSS 范围。
财务记录不要直接覆盖
已入账的付款、分配和发票明细不宜通过修改旧金额来“修正”。更稳妥的做法是创建冲销、退款、贷项或调整记录,保留完整的审计轨迹。
使用幂等键防止重复收费
支付请求和任务队列都可能重试。可以为业务操作保存唯一的 idempotency_key 或外部交易号,并在创建付款前检查该操作是否已经处理。
明确状态,而不是只保存布尔值
付款状态通常不只有“已付款”和“未付款”:
UNPAID
PARTIALLY_PAID
PAID
OVERPAID
REFUNDED
VOID
预约、发票和付款也应分别使用各自的状态机,避免把多种业务含义塞进单个 completed 字段。
合适的做法不是在“规范化”和“反规范化”之间二选一,而是同时维护两套模型。关联实体和不可变财务流水是权威数据,日历投影是用于快速读取的模型。前者保证应收账款可追溯,后者避免常用页面在每次请求中重建完整的关系图。
备注:内容仅供参考。
