一、为什么多区域写入会产生冲突?

你可能会遇到这样的情况:公司业务增长,用户遍布全球,于是你把数据库部署到多个区域,让用户就近读写,提升体验。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提供了灵活的冲突解决机制,让你可以在简单覆盖和自定义合并之间选择。对于大多数业务,自定义冲突解决更安全,能最大程度保护数据完整性。但也不能盲目使用,必须结合业务场景、性能要求和开发成本来权衡。从开始设计数据库时就把冲突解决考虑进去,提前定义合并规则,开发冲突解决存储过程,并做好监控和测试,这样你的全球业务才能稳定运行,不因数据不一致而出故障。