Google Cloud Spanner 现在为 DML 事务提供了更灵活的 Mutation 限制模型。过去,一个事务中所有 DML 语句产生的 mutation mods 会累计计算,并受到 80,000 的事务级上限约束;现在,这个上限改为逐条 DML 语句计算。
这意味着应用可以在一个 ACID 事务中组织更多 INSERT、UPDATE 和 DELETE 操作,而不必为了绕过累计上限,过早拆分业务流程。不过,单条 DML 语句的 80,000 mods 限制、事务大小限制,以及锁竞争风险并没有消失。
从“事务累计限制”变成“语句独立限制”
此前,如果一个事务包含多条 DML 语句,所有语句产生的 mutation mods 会加总。例如:
UPDATE Products ... -> 30,000 mods
UPDATE Orders ... -> 25,000 mods
INSERT OrderItems ... -> 30,000 mods
---------------------------------------
事务累计 -> 85,000 mods,超过限制
即使每条语句单独都没有超过 80,000 mods,整个事务仍可能失败。
现在,Spanner 会分别检查每一条 DML 语句:
UPDATE Products ... -> 30,000 mods,允许
UPDATE Orders ... -> 25,000 mods,允许
INSERT OrderItems ... -> 30,000 mods,允许
只要每条 DML 语句产生的 mods 少于 80,000,多个 DML 语句就可以继续放在同一个事务中。这样,应用可以按照业务原子性来划分事务,而不是围绕一个累计 mutation 上限被迫拆分。
这里的 mods 并不只是“影响了多少行”。它通常还会考虑被修改的单元格、主键,以及相关二级索引产生的更新。因此,更新一行包含多个索引的表,实际 mutation 成本可能明显高于直觉中的一行。
一个典型场景:订单、库存和审计记录一起提交
电商结算过程经常需要同时完成多类修改:更新商品库存、写入订单明细、更新时间戳,甚至记录风控结果。只要这些变化必须同时成功或同时回滚,就应该考虑使用同一个读写事务。
下面的 Java 示例使用 Spanner 客户端的 DML API,在同一事务中执行多条语句。示例假设 dbClient 已经完成初始化,并且数据库连接配置已经准备好。
import com.google.cloud.spanner.DatabaseClient;
import com.google.cloud.spanner.Statement;
import com.google.cloud.spanner.TransactionContext;
import com.google.cloud.spanner.TransactionRunner.Work;
public class CheckoutService {
private final DatabaseClient dbClient;
public CheckoutService(DatabaseClient dbClient) {
this.dbClient = dbClient;
}
public void completeCheckout(long orderId, long productId, int quantity) {
dbClient
.readWriteTransaction()
.run(
new Work<Void>() {
@Override
public Void doWork(TransactionContext transaction) {
Statement reserveProduct =
Statement.newBuilder(
"UPDATE Products "
+ "SET InStock = FALSE "
+ "WHERE ProductId = @productId")
.bind("productId")
.to(productId)
.build();
transaction.executeUpdate(reserveProduct);
Statement insertItem =
Statement.newBuilder(
"INSERT INTO OrderItems "
+ "(OrderId, ItemId, Quantity) "
+ "VALUES (@orderId, @itemId, @quantity)")
.bind("orderId")
.to(orderId)
.bind("itemId")
.to(productId)
.bind("quantity")
.to(quantity)
.build();
transaction.executeUpdate(insertItem);
Statement updateOrder =
Statement.newBuilder(
"UPDATE Orders "
+ "SET LastUpdated = PENDING_COMMIT_TIMESTAMP() "
+ "WHERE OrderId = @orderId")
.bind("orderId")
.to(orderId)
.build();
transaction.executeUpdate(updateOrder);
return null;
}
});
}
}
在新的限制逻辑下,示例中的每次 executeUpdate 都会独立对照 80,000 mods 上限。三条语句产生的 mods 仍会影响事务整体资源消耗,但不会因为 DML 语句之间的累计值超过 80,000 而直接触发原先的事务级 DML mutation 限制。
这并不意味着可以无限扩大事务。每条语句仍不能独自产生超过 80,000 mods,事务的字节大小等其他限制也仍然有效。
DML 和 Mutation API 不能混为一谈
这次调整针对的是 DML 语句,不是所有写入方式。
DML 语句
使用 executeUpdate 执行 INSERT、UPDATE 或 DELETE 时,每条语句单独接受 80,000 mods 检查。多个 DML 语句可以放在同一个事务中。
适合以下情况:
- 修改逻辑可以用 SQL 清晰表达;
- 需要在一个事务中组合多个业务步骤;
- 希望让 Spanner 根据查询条件执行更新,而不是在客户端构造大量行级 mutation。
Mutation API
如果应用使用客户端库中的 insert()、update() 等 Mutation API,mutation 通常会在 Commit 调用时一次性提交。此时,80,000 mods 限制仍然适用于该次提交中包含的整组 mutations。
因此,不能简单地把下面两种写法视为等价:
多个 executeUpdate() 调用
-> 每条 DML 独立检查
一次 Commit(mutations)
-> 整个 mutation 集合一起检查
如果现有应用已经使用 Mutation API,并且单次提交经常接近上限,那么这次 DML 限制调整不会自动解决问题。可以评估是否将部分逻辑改写为 DML,或者把批量写入拆成多个提交批次。
先测量,再扩大事务边界
事务扩大后,最容易被忽视的问题不是 mutation 数量,而是锁的持有时间。更大的事务通常意味着:
- 锁持续时间更长;
- 并发请求更容易发生锁竞争;
- 事务重试和 abort 的概率可能上升;
- 单次失败需要重做的工作更多。
建议在生产环境关注提交统计信息中的 mutation_count,了解一个已提交事务实际产生了多少 mutation。这个统计值会包含事务中所有 DML 语句以及其他 commit 操作产生的 mutations。
可以把事务设计成“业务上完整,但技术上简洁”:
- 只在事务中执行必须具备原子性的写入;
- 不要把远程 HTTP 请求、文件操作或长时间计算放在事务回调中;
- 对可能影响大量行的单条 DML 先估算行数、列数和索引数量;
- 观察锁等待、abort 和重试情况,再决定是否继续扩大事务;
- 为事务重试设计幂等逻辑,避免应用层副作用被重复执行。
单条批量操作超过上限怎么办
这次更新没有取消单条 DML 的 80,000 mods 限制。如果一条语句本身就会更新过多数据,例如:
UPDATE Products
SET InStock = FALSE
WHERE Category = 'seasonal-clearance';
并且该条件命中了大量行,那么它仍然可能失败。此时可以根据业务需求选择不同方案:
- 使用更窄的主键范围分页执行;
- 将一次大更新拆成多个短事务;
- 对不要求单一事务原子性的后台批处理使用 Partitioned DML;
- 减少不必要的更新列和索引写入;
- 重新评估这次操作是否真的需要与其他业务写入保持同一事务。
例如,按主键范围分页的思路可以这样实现。下面是可改造的伪代码,具体参数绑定方式取决于所使用的 Spanner 客户端库版本:
long pageSize = 1000;
long startProductId = 0;
while (true) {
Statement statement =
Statement.newBuilder(
"UPDATE Products "
+ "SET InStock = FALSE "
+ "WHERE ProductId > @startId "
+ "AND ProductId <= @endId")
.bind("startId")
.to(startProductId)
.bind("endId")
.to(startProductId + pageSize)
.build();
// 每个批次使用一个较短的读写事务执行。
// 提交成功后再推进 startProductId。
// transaction.executeUpdate(statement);
startProductId += pageSize;
// 实际代码还需要根据查询结果判断是否已经处理完毕。
}
分页不是无条件的最佳选择:如果业务要求所有行必须与订单状态变化严格原子提交,就不能用多个独立事务替代原来的事务。此时应优先优化单条语句范围,或者重新设计数据模型和业务边界。
上线前的检查清单
- 确认应用使用的是 DML 还是 Mutation API;两者的 80,000 mods 计算方式不同。
- 检查每条 DML 语句,而不是只看事务中修改的总行数。
- 把主键、修改列和二级索引纳入 mutation 成本估算。
- 通过
CommitStats记录并分析mutation_count。 - 压测锁竞争、事务 abort 和自动重试,而不是只验证提交成功率。
- 保留单条语句超过 80,000 mods 时的分页或 Partitioned DML 方案。
- 不要因为上限放宽,就把不相关的业务操作全部塞进一个长事务。
这项变化的核心价值,是让 Spanner 事务边界更多由业务一致性决定,而不是被 DML 的累计 mutation 上限牵着走。合理使用时,应用可以在保持 ACID 语义的同时组合更复杂的实时更新;但真正的工程目标仍然是短事务、可观测、可重试,并且让每条大规模 DML 都有明确的容量边界。