DynamoDB在很长一段时间里只提供最终一致性模型下的单操作原子性,想跨多个表或者多个键做原子修改几乎不可能,开发者只能靠条件写入配合应用层重试来模拟事务。直到官方推出TransactWriteItems和TransactGetItems两个API,跨项目的事务才真正落地。本文以Node.js为开发语言,结合AWS SDK v3的@aws-sdk/lib-dynamodb包,完整演示事务的写法、限制以及生产环境的踩坑经验。

一、事务API的基本概念与项目结构
DynamoDB提供两个事务接口:TransactWriteItems用于写入,TransactGetItems用于读取。两者最大的特点是强一致性,且整个事务内的所有操作要么全部成功,要么全部失败,不存在部分提交的中间状态。这一点和BatchWriteItem有本质区别,后者只是把多个请求打包发送,彼此之间没有任何原子性保证,失败的项目需要自己处理UnprocessedItems。
事务的操作类型有四种:Put用于新增或整体覆盖一条记录,Update用于部分字段更新,Delete用于删除,ConditionCheck用于只做条件校验而不修改数据。一个事务最多包含25个项目,这25个是所有操作类型的总和,且每个项目只能针对不同的分区键,也就是说同一个事务里不能对同一个Item操作两次。这一点在编写转账类逻辑时尤其要注意,如果对同一个账户先扣款再加款,必须把两次余额变动合并成一次Update。
写事务在底层使用两阶段提交,服务端会先锁定所有涉及的Item,再统一提交。代价是每个事务项目消耗的写入容量单位是普通操作的两倍,也就是写放大。所以事务适合用在真正需要原子性的场景,比如账户扣款、库存扣减、订单状态流转,而不是当作批量写入工具来提升吞吐量。
二、Node.js事务代码实战
下面用AWS SDK v3来演示。推荐使用@aws-sdk/lib-dynamodb的DynamoDBDocumentClient,它能把JavaScript对象直接映射成DynamoDB的属性格式,省去大量冗长的类型声明。先安装依赖:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
然后封装一个客户端,后续所有示例都基于它:
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb');
const client = new DynamoDBClient({ region: 'ap-northeast-1' });
const docClient = DynamoDBDocumentClient.from(client);
module.exports = { docClient };接下来是最经典的转账场景:从账户A扣100元,给账户B加100元,同时写入一条流水记录,三个操作放在同一个事务里。注意扣款时要加条件判断余额充足,否则整个事务回滚:
const { TransactWriteItemsCommand } = require('@aws-sdk/client-dynamodb');
const { docClient } = require('./db');
async function transfer(fromId, toId, amount, txId) {
const command = new TransactWriteItemsCommand({
TransactItems: [
{
Update: {
TableName: 'Accounts',
Key: { accountId: fromId },
UpdateExpression: 'SET balance = balance - :amt',
ConditionExpression: 'balance >= :amt',
ExpressionAttributeValues: { ':amt': amount }
}
},
{
Update: {
TableName: 'Accounts',
Key: { accountId: toId },
UpdateExpression: 'SET balance = balance + :amt'
}
},
{
Put: {
TableName: 'Transactions',
Item: {
txId: txId,
fromId: fromId,
toId: toId,
amount: amount,
createdAt: new Date().toISOString()
},
// 幂等保护:同一txId重复提交会直接报错
ConditionExpression: 'attribute_not_exists(txId)'
}
}
]
});
try {
await docClient.send(command);
return { success: true };
} catch (err) {
if (err.name === 'TransactionCanceledException') {
console.log('事务被取消', err.CancellationReasons);
return { success: false, reasons: err.CancellationReasons };
}
throw err;
}
}这里有一个关键细节:CancellationReasons数组会按事务项目的顺序给出每个操作失败的原因。如果余额不足,第一个项目的Code会是ConditionalCheckFailed。如果没有设置ClientRequestToken,DynamoDB会自动生成一个,保证同参数的请求在网络重试时不会被执行两次,这本身就是服务端层面的幂等保障。
三、读取事务与常见异常处理
TransactGetItems的用法类似,最多也是25个Key,返回的数据在Responses字段里,顺序和请求顺序一致。它的价值在于读取时能看到一组数据的一致性快照,比如同时读商品表和库存表做校验时,不会出现读到旧库存的情况:
const { TransactGetItemsCommand } = require('@aws-sdk/client-dynamodb');
async function getProductWithStock(productId) {
const command = new TransactGetItemsCommand({
TransactItems: [
{ Get: { TableName: 'Products', Key: { productId } } },
{ Get: { TableName: 'Stocks', Key: { productId } } }
]
});
const result = await docClient.send(command);
return result.Responses.map(r => r.Item);
}事务相关的异常主要有三种。TransactionConflictException表示你要修改的Item正被另一个事务锁定,这是最常见的高并发冲突,官方建议用带抖动的指数退避重试,一般重试几次就能成功。TransactionCanceledException表示条件检查失败,这种情况通常不该盲目重试,而是要根据CancellationReasons决定业务走向,比如余额不足就直接返回业务错误。TransactionInProgressException表示同一个Item已经有一个进行中的事务,处理方式和冲突类似。
在Node.js里可以写一个通用的重试包装器来处理冲突类异常,示例代码如下:
async function withRetry(fn, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (err) {
const retryable = ['TransactionConflictException', 'TransactionInProgressException'];
if (!retryable.includes(err.name)) throw err;
// 指数退避加随机抖动,避免重试风暴
const delay = Math.pow(2, i) * 50 + Math.random() * 100;
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error('事务重试次数耗尽');
}四、事务与批量操作的选型建议
事务和批量操作经常被混用,但两者定位完全不同。事务解决一致性问题,批量解决吞吐问题。下面这个对比可以帮你快速判断该用哪个:
| 对比维度 | TransactWriteItems | BatchWriteItem |
|---|---|---|
| 原子性 | 全部成功或全部失败 | 无保证,逐项独立 |
| 单次上限 | 25个项目 | 25个请求,共可含数据4MB |
| 写入容量消耗 | 普通写入的两倍 | 正常消耗 |
| 条件表达式 | 支持 | 不支持 |
| 同一Item多次操作 | 不允许 | 不允许 |
选型的核心判断是:数据不一致会不会造成资金或业务损失。转账、优惠券核销、库存扣减这类场景必须用事务;而日志批量写入、消息投递记录这类允许最终一致的场景,用批量写入更省钱也更高效。还有一种折中方案是事务只覆盖关键路径,其余数据用异步补偿写,这样能把事务规模控制在最小范围内,减少锁冲突概率。
另外要注意事务不支持嵌套,一个Lambda函数里如果先发一个事务再发另一个,两者之间没有任何关联,需要跨事务原子性时就得重新设计数据模型,比如把强关联的数据放到同一张表、用同一个分区键,这样单次操作或单个事务就能覆盖。合理利用单表设计,很多场景下甚至可以完全避开多表事务,这也是DynamoDB官方推崇的建模思路。掌握好这些边界,事务才能真正为业务保驾护航而不是成为性能瓶颈。
DynamoDBnodejs transactionsTransactWriteItems修改时间:2026-09-10 10:37:16