事务提交失败后session会变为不可用,必须新建ClientSession重试整个事务体而非仅重试commit;事务体需幂等,读操作须在新会话中重新执行,并显式设置readConcern: "snapshot"保证一致性。

事务失败时为什么不能简单重试 session.commitTransaction()
MongoDB 事务提交失败(比如因写冲突、超时或主节点切换)后,session 状态会变为「不可用」——再调用 commitTransaction() 或 abortTransaction() 都会抛出 InvalidSession 错误。这不是重试逻辑没写对,而是驱动强制终止了该会话。
正确做法是:每次重试都必须新建 ClientSession,重新执行整个事务体(包括所有读写操作),而不是只重试 commit 步骤。
- 事务体必须是幂等的:同一组输入在多次执行中产生相同结果(例如用
upsert替代insert,用$setOnInsert控制初始值) - 不要在事务外缓存读取结果并复用——重试时数据可能已变,必须在新会话里重新读
- 避免在事务中依赖外部服务响应(如 HTTP 调用),否则重试会放大不确定性
用 with_transaction() 还是手写重试循环?
PyMongo 提供的 with_transaction() 是封装好的重试入口,但它只重试「提交阶段」失败,且默认最多 5 次、无退避策略。真实场景中,网络抖动、短暂主从切换更常见,需要自定义重试控制。
推荐手写带指数退避的循环,把整个事务逻辑包进函数里:
def transfer_money(session, from_id, to_id, amount):
with session.start_transaction():
# 所有操作都在这个 block 内
from_acc = db.accounts.find_one_and_update(
{"_id": from_id, "balance": {"$gte": amount}},
{"$inc": {"balance": -amount}},
session=session
)
if not from_acc:
raise ValueError("insufficient balance")
db.accounts.update_one(
{"_id": to_id},
{"$inc": {"balance": amount}},
session=session
)
<h1>外层重试</h1><p>for attempt in range(3):
try:
with client.start_session() as session:
transfer_money(session, "A", "B", 100)
break
except (ConnectionFailure, OperationFailure) as e:
if "TransientTransactionError" in str(e) and attempt </p>
-
with_transaction()适合简单、确定性高的场景;复杂业务逻辑(含条件判断、多集合联动)必须手写 - 捕获
TransientTransactionError是关键,但注意它只是字符串匹配,部分驱动版本需检查e.error_labels - 重试次数别设太高(3–5 次足够),否则可能掩盖底层集群问题
事务内读写顺序和 readConcern 的实际影响
事务默认使用 readConcern: "local",意味着读到的数据可能被回滚——这在重试时尤其危险:第一次读到 A=100,事务失败;重试时又读到 A=100(但其实是旧快照),导致重复扣款。
必须显式设置 readConcern: "snapshot"(仅支持副本集 + 主节点事务),才能保证事务内读写看到一致快照。
- 启用
"snapshot"后,事务开始时会获取一个全局一致的时间戳,后续所有读都基于该快照 - 但代价是:长事务会阻塞 oplog 截断,增加内存压力;超时时间默认 60 秒,不可调
- 如果集群不满足
snapshot条件(如分片集群未开启featureCompatibilityVersion: 4.4+),降级为"local"不报错,但逻辑可能出错
重试时如何避免“幽灵写入”和并发覆盖
两个事务同时修改同一文档,即使都用 upsert,也可能因竞态导致最终状态错误。例如:两笔转账同时从账户 A 扣 100,A 原有 150,预期剩 -50,但实际可能剩 -100(两次都成功扣了)。
根本解法不是靠重试,而是靠写条件收敛:
- 用
find_one_and_update(..., filter={"_id": x, "balance": {"$gte": 100}})把余额检查和扣减绑成原子操作 - 避免先
find再update——中间窗口期会被其他事务插入 - 如果业务允许,把关键字段(如余额)单独抽成子文档,并用
$isolated(不适用)或应用层锁(不推荐)替代,不如直接强化 filter 条件
重试本身不解决并发问题,它只是让「带正确条件的事务」有机会再跑一次。条件漏了,重试十次也没用。










