一、为什么多区域写入会产生冲突?
你可能会遇到这样的情况:公司业务增长,用户遍布全球,于是你把数据库部署到多个区域,让用户就近读写,提升体验。Azure Cosmos DB支持多区域写入,听起来很美好,但问题随之而来——当一个用户在东京修改了某条数据,另一个用户同时在纽约修改同一条数据,两个区域各自保存了不同的版本,下一秒数据同步时,到底该听谁的?这就是冲突。
冲突本质上是分布式系统中“写写冲突”的体现。传统单主数据库不存在这个问题,因为所有写入都经过同一个主节点。而多主架构下,每个区域都能独立写入,数据最终要合并,矛盾就不可避免。如果处理不当,业务就会故障。比如电商库存明明只剩一件,两个区域的用户同时下单,各自扣减成功,结果卖出了两件,系统就崩了。
二、冲突解决的基础:从时间戳到自定义策略
Azure Cosmos DB提供了两种内置的冲突解决策略:最后写入者胜出(LWW)和自定义冲突解决。LWW简单粗暴,比较每条记录上的时间戳,谁晚听谁的。但现实业务往往更复杂:你可能需要根据业务逻辑合并数据,比如合并两个用户的购物车,而不是简单覆盖。
2.1 时间戳陷阱
LWW依赖客户端或服务器时间戳。服务器时间戳较可靠,但客户端时间戳可能因时钟偏差导致错误。比如东京区域的服务器时钟慢了1秒,纽约的写入明明本来更合理,却被覆盖掉。另外,LWW会丢失数据——例如两个用户同时修改文档的不同字段,覆盖会让其中一个修改消失。
2.2 自定义冲突解决:把决策交给代码
自定义冲突解决允许你注册一个存储过程(冲突解决器),当冲突发生时,Cosmos DB会调用该存储过程,你可以在里面编写合并逻辑。例如合并两个版本的字段,或者根据优先级选择胜出者。
三、实战:用C#处理冲突
我们用Azure Cosmos DB .NET SDK (v3) 演示如何设置自定义冲突解决策略。假设我们有一个用户资料文档,多个区域可能同时修改用户的“昵称”和“签名”。我们希望保留两者中最新修改的字段,而不是整体覆盖。
// 技术栈: C# (.NET 6+) + Azure Cosmos DB SDK v3
// 需要安装包: Microsoft.Azure.Cosmos
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Fluent;
using System;
using System.Threading.Tasks;
public class UserProfile
{
public string Id { get; set; } // 文档ID
public string UserId { get; set; } // 用户标识
public string Nickname { get; set; } // 昵称
public string Signature { get; set; } // 签名
public DateTime NicknameUpdatedAt { get; set; } // 昵称最后更新时间
public DateTime SignatureUpdatedAt { get; set; } // 签名最后更新时间
}
public class ConflictResolutionDemo
{
// 连接字符串(请替换为你的真实连接字符串)
private static readonly string ConnectionString = "AccountEndpoint=https://你的账号.documents.azure.com:443/;AccountKey=你的主密钥;";
private static readonly string DatabaseName = "MyDB";
private static readonly string ContainerName = "Users";
public static async Task Main(string[] args)
{
// 1. 创建CosmosClient,开启多区域写入
using CosmosClient client = new CosmosClientBuilder(ConnectionString)
.WithApplicationRegion("East Asia") // 设置首选区域
.Build();
// 2. 创建数据库(如果不存在)
Database database = await client.CreateDatabaseIfNotExistsAsync(DatabaseName);
// 3. 定义冲突解决策略:使用存储过程自定义合并
ConflictResolutionPolicy conflictPolicy = new ConflictResolutionPolicy
{
Mode = ConflictResolutionMode.Custom, // 使用自定义模式
ConflictResolutionProcedure = "dbs/MyDB/colls/Users/sprocs/mergeUserProfile" // 存储过程路径
};
// 4. 创建容器时指定冲突解决策略
ContainerProperties containerProperties = new ContainerProperties(ContainerName, "/UserId") // 分区键为UserId
{
ConflictResolutionPolicy = conflictPolicy
};
Container container = await database.CreateContainerIfNotExistsAsync(containerProperties);
Console.WriteLine("容器创建成功,冲突解决策略已设置为自定义合并。");
// 5. 模拟并发写入触发冲突(实际多区域场景会自动发生)
// 此处仅演示如何注册冲突解决器,冲突触发过程由Cosmos DB内部处理。
// 真实场景下,你需要在Azure门户中上传存储过程脚本,或使用SDK创建存储过程。
Console.WriteLine("请部署存储过程 mergeUserProfile 到容器中。示例存储过程如下:");
}
}
3.1 存储过程实现
存储过程需要注册到Cosmos DB中,它接收两个冲突的版本(旧版本和新版本),并返回合并后的文档。我们使用JavaScript编写存储过程,因为Cosmos DB的存储过程仅支持JavaScript。
// 技术栈: JavaScript (Cosmos DB存储过程)
// 冲突解决器: 合并用户资料,保留每个字段的最晚更新时间
function mergeUserProfile(existingDoc, conflictDoc) {
// existingDoc 是当前容器中的文档,conflictDoc 是来自其他区域的冲突版本
// 我们创建一个合并后的文档,继承ID和分区键
var merged = JSON.parse(JSON.stringify(existingDoc)); // 深度拷贝
// 比较昵称更新时间,取较新的
if (conflictDoc.Nickname &&
(!merged.NicknameUpdatedAt || new Date(conflictDoc.NicknameUpdatedAt) > new Date(merged.NicknameUpdatedAt))) {
merged.Nickname = conflictDoc.Nickname;
merged.NicknameUpdatedAt = conflictDoc.NicknameUpdatedAt;
}
// 比较签名更新时间
if (conflictDoc.Signature &&
(!merged.SignatureUpdatedAt || new Date(conflictDoc.SignatureUpdatedAt) > new Date(merged.SignatureUpdatedAt))) {
merged.Signature = conflictDoc.Signature;
merged.SignatureUpdatedAt = conflictDoc.SignatureUpdatedAt;
}
// 返回合并后的文档,Cosmos DB会用这个结果覆盖现有文档
return merged;
}
注意:存储过程中不能访问外部资源,只能操作传入的两个文档。此例中我们按字段粒度合并,而不是全文档覆盖,从而减少数据丢失。
3.2 如何部署存储过程
你可以通过Azure门户或SDK将上述JavaScript脚本上传到容器中。
// 继续前面的C#代码,使用SDK创建存储过程
string sprocBody = @"
function mergeUserProfile(existingDoc, conflictDoc) {
var merged = JSON.parse(JSON.stringify(existingDoc));
if (conflictDoc.Nickname &&
(!merged.NicknameUpdatedAt || new Date(conflictDoc.NicknameUpdatedAt) > new Date(merged.NicknameUpdatedAt))) {
merged.Nickname = conflictDoc.Nickname;
merged.NicknameUpdatedAt = conflictDoc.NicknameUpdatedAt;
}
if (conflictDoc.Signature &&
(!merged.SignatureUpdatedAt || new Date(conflictDoc.SignatureUpdatedAt) > new Date(merged.SignatureUpdatedAt))) {
merged.Signature = conflictDoc.Signature;
merged.SignatureUpdatedAt = conflictDoc.SignatureUpdatedAt;
}
return merged;
}";
StoredProcedureResponse sprocResponse = await container.Scripts.CreateStoredProcedureAsync(
new StoredProcedureProperties("mergeUserProfile", sprocBody)
);
Console.WriteLine("存储过程已创建。");
现在,当多区域同时写入同一文档的不同字段时,冲突解决器会自动执行,合并后的文档保留每个字段的最新更改,业务逻辑正确。
四、应用场景分析
4.1 电商库存扣减
上面举例的库存问题,如果使用LWW,两个区域同时扣减库存,时间戳晚的会覆盖早的,导致库存数量错乱(比如从10扣到9,再扣到8,但实际扣了两次变成7?不,LWW会直接覆盖,所以其中一个扣减会丢失)。更好的做法是使用自定义冲突解决,增加版本号或使用计数器的原子操作(Cosmos DB支持增量更新)。对于库存这种强一致需求,建议使用条件写入(ETag)或利用Cosmos DB提供的冲突解决反馈机制,在应用层做最终合并。
4.2 用户评论系统
多个用户可能同时编辑同一条评论(罕见但可能)。如果采用LWW,较晚保存的版本会完全覆盖较早版本,导致一个人修改的文本被丢弃。自定义冲突解决可以合并文本(比如用diff算法)或者根据编辑时间选择性保留更合理的版本。
4.3 IoT设备数据
多个传感器写入同一个设备ID的数据流。每个传感器更新不同字段(温度、湿度)。自定义冲突解决按字段更新时间合并,保证每个传感器的最新数据都被保留。
五、技术优缺点
5.1 最后写入者胜出(LWW)
优点:
- 配置简单,零代码。
- 性能高,无需执行存储过程。
- 适用于字段整体覆盖的场景,例如配置信息、用户头像URL等。
缺点:
- 丢失所有冲突版本中“较早”的修改,即使这些修改仅涉及不同字段。
- 依赖时间戳,时钟偏差可能导致错误覆盖。
- 无法处理复杂业务合并逻辑。
5.2 自定义冲突解决
优点:
- 完全由业务控制合并逻辑,数据丢失最小化。
- 可以合并字段、版本号、数组等复杂结构。
- 提供冲突解决日志(通过冲突Feed查看未解决的冲突)。
缺点:
- 开发成本高,需要编写并测试存储过程。
- 存储过程执行有时间和资源限制(5秒超时,1MB响应大小)。
- 调试困难,存储过程运行在服务端,日志有限。
六、注意事项
- 选择合适的一致性级别:多区域写入下,会话一致性(Session)可以保证单区域内的因果一致,但跨区域冲突不可避免。建议使用“最终一致性”作为默认,配合冲突解决策略。如果业务要求强一致,则不要开启多区域写入,改用单区域+读取路由。
- 冲突解决存储过程的性能:存储过程按顺序执行,冲突较多时可能成为瓶颈。可以考虑在应用层异步处理冲突(通过冲突Feed API),而不是全靠存储过程。
- 避免自定义冲突解决中的副作用:存储过程不能调用外部API,不能做复杂计算。也不要修改文档ID或分区键,否则会引起其他问题。
- 监控冲突发生频率:通过Azure Monitor查看冲突计数器,如果冲突频繁,说明业务设计可能需要调整,比如优化分片键或减少并行写入相同文档的频率。
- 测试:在非生产环境中模拟多区域写入,验证冲突解决逻辑是否符合预期。可使用Cosmos DB的冲突Feed查看历史冲突记录。
七、总结
多区域写入是一把双刃剑,它带来了低延迟和高可用,却必须面对数据冲突的挑战。Azure Cosmos DB提供了灵活的冲突解决机制,让你可以在简单覆盖和自定义合并之间选择。对于大多数业务,自定义冲突解决更安全,能最大程度保护数据完整性。但也不能盲目使用,必须结合业务场景、性能要求和开发成本来权衡。从开始设计数据库时就把冲突解决考虑进去,提前定义合并规则,开发冲突解决存储过程,并做好监控和测试,这样你的全球业务才能稳定运行,不因数据不一致而出故障。
Comments