【问题标题】:Concurrent updates on DynamoDB with Conditional Expression sometime both passing带有条件表达式的 DynamoDB 上的并发更新有时都通过
【发布时间】:2022-01-21 07:24:59
【问题描述】:

我遇到了一个问题,即两个并发进程在 5 毫秒内更新 DynamoDB 表,并且当我希望一个进程抛出 ConditionalCheckFailedException 异常时,它们都传递了条件表达式。文档状态:

DynamoDB 支持分布式锁所必需的机制,例如条件写入。

https://aws.amazon.com/blogs/database/building-distributed-locks-with-the-dynamodb-lock-client/

我的表架构有一个名为“Id”的 Key 属性:

AttributeDefinitions:
    -
      AttributeName: "Id"
      AttributeType: "S"
  KeySchema:
    -
      AttributeName: "Id"
      KeyType: "HASH"

我的条件表达式是:

string conditional = "attribute_not_exists(StartedRefreshingAt)";

包含条件表达式的Lock方法:

private bool Lock(Configuration config)
{
    string conditional = "attribute_not_exists(StartedRefreshingAt)";       
    Dictionary<string, AttributeValue> values = new Dictionary<string, AttributeValue>{
        {":new_refresh", new AttributeValue(ToDDBDate(DateTime.Now))}};         

    try
    {           
        _dynamoDB.UpdateItemAsync(new UpdateItemRequest
        {
            TableName = TABLE_NAME,
            Key = new Dictionary<string, AttributeValue>{{"Id", new AttributeValue(config.Id)}},
            UpdateExpression = "set StartedRefreshingAt = :new_refresh",
            ConditionExpression = conditional,
            ExpressionAttributeValues = values

        }).Wait();
        return true;
    }
    catch (Exception)
    {
        return false;
    }
}

如果它返回 true,我认为表已锁定,因为 StartedRefreshingAt 属性现在存在。

在对其他属性的记录完成一些其他更新后,我再次删除了StartedRefreshingAt 属性,有效地释放了锁。这是使用lock方法的方法:

private async Task<Configuration> RefreshAsync(Configuration config)
{
    // concurrent executions may enter here with the same value
    // for config
    if (config.AccessTokenExpired()) // Validates the age of the config.AccessToken
    {
        _logger.LogInformation($"Refreshing expired access token for {config.Id}");

        if (Lock(config))
        {
            // The below code should not be executed concurrently
            
            var authPayload = new List<KeyValuePair<string, string>>();
            authPayload.Add(new KeyValuePair<string, string>("grant_type", "refresh_token"));
            authPayload.Add(new KeyValuePair<string, string>("refresh_token", config.RefreshToken));
            
            // 3rd party REST API call
            // IF execution 1 completed and saved first, the below API
            // call should fail with an "invlaid grant" response
            // since it will be using a stale refresh token.
            JObject authResponse = await GetAuthTokensAsync(authPayload); 

            // Bad practice, but print the auth tokens to logs here 
            // in case we need to recover a RefreshToken.
            _logger.LogInformation($"Got auth token response for {config.Id}:" +
                $" {authResponse.ToString(Formatting.None)}");
            
            config.AccessToken = authResponse["access_token"].ToString();
            config.AccessTokenCreated = DateTime.Now;
            // RefreshToken is updated whenever the API call succeeds.
            config.RefreshToken = authResponse["refresh_token"].ToString();
            config.RefreshTokenCreated = config.AccessTokenCreated;
            config.StartedRefreshingAt = null; // lock attribute
            
            await Save(config); // saves to DynamoDB, releasing the lock.
        }
        else
        {
            _logger.LogInformation($"Someone else is refreshing {config.Id} at same time, so ignoring and returning current value");
            // this will return a potentially stale config object, which client code is expected to handle
        }
    }

    return config;
}

大多数时候,当两个并发执行运行时,此代码在两个执行中的一个上成功返回 false,平均每天大约 20 次。这似乎表明表达式正在正确评估。但大约每 2 周一次,并发执行返回 true。

编辑:在查看答案并提供更多代码上下文后,问题很可能不是 DynamoDB,执行 2 的条件写入在执行 1 释放锁定后成功。这可能是第 3 方 OAuth 服务器的一致性问题。

现在我认为问题在于执行 2 使用与执行 1 相同的刷新令牌成功完成了 OAuth API 调用。这应该是不可能的。

最终结果是我在 DynamoDB 中保存了一个 RefreshToken,它永远返回“无效授权”,这意味着它已经过时了。如果我通过日志查看日志行“Got auth token response for”何时由两个非常接近的执行写入,我可以使用首先记录的 RefreshToken 手动更新 DynamoDB 表,它会恢复。

这个场景对于竞态条件来说非常经典:

  1. 执行1设置StartedRefreshingAt属性并返回true,继续完成额外的工作
  2. 执行 2 设置 StartedRefreshingAt 属性并返回 true,继续完成额外的工作
  3. 执行 2 将附加工作的结果写回表中
  4. 执行 1 将附加工作的结果写回表中,覆盖执行 2 完成的最新工作。

有时第 3 步和第 4 步也可能反向执行,但这对我来说并不存在一致性问题,因为最近完成的工作是期望的最终结果。

【问题讨论】:

    标签: c# amazon-dynamodb


    【解决方案1】:

    您所建议的竞争非常令人惊讶,因为这正是 DynamoDB 声称其条件更新避免的。因此,要么亚马逊在其实施中存在严重错误(这会令人惊讶,但并非不可能),要么比赛实际上与您在问题中描述的不同。

    在您的时间线中,您没有说明您的代码如何将“StartedRefreshingAt”重置为空。将工作结果写回表的相同 UpdateTable 操作是否也会删除 StartedRefreshingAt 属性?因为如果它是单独的写入,理论上可能(即使不常见)两次写入被重新排序。如果首先删除 StartedRefreshingAt,那么此时第二个进程可以开始自己的工作 - 在写入第一个进程的结果之前 - 因此可能会发生您描述的问题。

    您没有说的另一件事是您的处理如何读取项目中的工作。如果您不小心对读取使用了最终一致性,而不是强一致性,则执行 2 可能在执行 1 完成后确实开始了,但是当它读取它需要做的工作时 - 它再次读取旧值而不是什么执行 1 写了 - 所以执行 2 最终重复了 1 的工作,而不是做新的工作。

    我不知道这些猜测是否有意义,因为我不知道您的应用程序的详细信息,但我认为 DynamoDB 一致性根本无法按承诺工作的可能性是我最后的猜测。

    【讨论】:

    • StartedRefreshingAt 在使用完成工作的结果更新其他属性的同一写入中设置为 NULL。有趣的是,其他 DynamoDB 锁库不是这样工作的,它们使用单​​独的锁表。关于读取陈旧数据,这应该已经在代码中处理 - 尝试重复 1 的工作的执行 2 应该失败。我将编辑我的答案并在代码中提供更多上下文来演示这一点。感谢您的想法。
    • 我将把它标记为已回答,因为问题描述现在已经发生了很大变化,而您的洞察力使我摆脱了 DynamoDB 条件表达式是问题的假设。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2019-03-22
    • 2019-07-26
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多