使用条件写入防止重复订单

AWSBeginner
立即练习

介绍

重试提交订单时,不能覆盖已有的金额。过期的付款更新也不能覆盖较新的版本。你将实际发送带条件的 API 请求,观察重复订单和过期版本被拒绝,并证明有效的新订单仍能成功写入。

请先完成“为订单创建 DynamoDB 表”,包括带类型的项目和更新表达式。本实验从全新环境开始,不复用先前的表或凭证。使用 /home/labex/project、官方 AWS CLI 和 AWS View。

认证考点

认证 考点 本实验练习
Developer – Associate (DVA-C02) 任务 1.3, 任务 1.1 DynamoDB 条件写入与乐观并发控制。

实验概览

使用 AWS4 图标并由 draw.io 导出的概念图:条件写原子检查当前订单状态,允许有效创建和更新,拒绝重复或过期版本请求,避免覆盖已接受的数据。

保护首次订单创建

本步骤中,你将仅在主键尚未使用时创建订单。无条件的 PutItem 会替换该键下的整个项目。条件检查与写入以原子方式执行,因此不需要另行先读取再写入;那样的检查还会产生竞态条件。

使用准备好的临时连接,创建一张独立的表,以订单 ID 作为 String 类型的分区键。

cd /home/labex/project
aws sts get-caller-identity
aws dynamodb create-table \
  --table-name labex-d04-orders \
  --attribute-definitions AttributeName=order_id,AttributeType=S \
  --key-schema AttributeName=order_id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST
aws dynamodb wait table-exists --table-name labex-d04-orders

编写一个普通的 API 请求文件。带引号的 heredoc 会按原样写入 JSON;file:// 让 CLI 从文件加载内容,避免为大型请求内容处理 shell 引号。attribute_not_exists(order_id) 仅在没有项目使用该键时允许写入。version: 1 字段是应用程序的值,你将在第 3 步用它保护更新;DynamoDB 不会自动递增它。

cat > create-order.json <<'JSON'
{
  "TableName": "labex-d04-orders",
  "Item": {
    "order_id": {
      "S": "O100"
    },
    "amount": {
      "N": "145"
    },
    "status": {
      "S": "NEW"
    },
    "version": {
      "N": "1"
    }
  },
  "ConditionExpression": "attribute_not_exists(order_id)"
}
JSON
aws dynamodb put-item --cli-input-json file://create-order.json
aws dynamodb get-item \
  --table-name labex-d04-orders \
  --key '{"order_id":{"S":"O100"}}' \
  --consistent-read

O100 的金额为 145,状态为 NEW,版本为 1。对于这个新键,不存在条件检查通过。查看上方的 AWS View 并展开 O100,检查实际项目。

拒绝重复订单,同时允许新订单

本步骤中,你将使用不同金额重新提交同一个订单 ID。不安全的覆盖会将 O100 的金额改为 999;条件必须拒绝该请求并保留 145。重复订单被拒绝时,返回的是错误响应,并不会自动成为应用程序成功的幂等响应。应用程序必须决定如何处理该错误。

重试创建请求不应替换订单已经接受的金额。不存在条件在写入时保护这一决定。读过滤器改变返回哪些项目,而写条件决定修改是否允许发生;每个竞争请求都需要自己的条件。

使用相同的键和不存在条件,编写一个竞争请求。

cat > duplicate-order.json <<'JSON'
{
  "TableName": "labex-d04-orders",
  "Item": {
    "order_id": {
      "S": "O100"
    },
    "amount": {
      "N": "999"
    },
    "status": {
      "S": "NEW"
    },
    "version": {
      "N": "1"
    }
  },
  "ConditionExpression": "attribute_not_exists(order_id)"
}
JSON

使用相同的键再次写入。|| true 让终端在这次预期的失败后继续执行。检查是否出现 ConditionalCheckFailedException;连接或授权错误需要采用其他诊断方式。

aws dynamodb put-item --cli-input-json file://duplicate-order.json || true
aws dynamodb get-item \
  --table-name labex-d04-orders \
  --key '{"order_id":{"S":"O100"}}' \
  --consistent-read

预期出现 ConditionalCheckFailedException,随后读取到未改变的金额 145 和版本 1。证明同样的保护条件允许不同订单 ID 写入,而不是拒绝所有写入。

cat > second-order.json <<'JSON'
{
  "TableName": "labex-d04-orders",
  "Item": {
    "order_id": {
      "S": "O101"
    },
    "amount": {
      "N": "200"
    },
    "status": {
      "S": "NEW"
    },
    "version": {
      "N": "1"
    }
  },
  "ConditionExpression": "attribute_not_exists(order_id)"
}
JSON
aws dynamodb put-item --cli-input-json file://second-order.json
aws dynamodb get-item \
  --table-name labex-d04-orders \
  --key '{"order_id":{"S":"O101"}}' \
  --consistent-read

O101 创建成功,金额为 200,状态为 NEW,版本为 1。AWS View 显示两个订单,O100 的金额仍为 145。这些操作可以防止该键对应的记录被意外覆盖,但没有实现多项目事务或付款去重。

使用预期版本保护更新

本步骤中,你将仅对读取时的版本执行状态转换。乐观版本条件会拒绝过期请求,避免悄悄覆盖较新的决定。该写入会原子地检查版本是否为 1,将状态设为 PAID,并把版本递增为 2。

首次更新匹配版本 1;当项目已处于版本 2 时,重放相同的预期版本会失败。

编写并执行这个 UpdateItem 请求。表达式名称别名可以安全地处理属性名,带类型的值提供预期版本和下一版本。

cat > pay-order.json <<'JSON'
{
  "TableName": "labex-d04-orders",
  "Key": {
    "order_id": {
      "S": "O100"
    }
  },
  "UpdateExpression": "SET #state = :paid, #version = :next",
  "ConditionExpression": "#version = :expected",
  "ExpressionAttributeNames": {
    "#state": "status",
    "#version": "version"
  },
  "ExpressionAttributeValues": {
    ":paid": {
      "S": "PAID"
    },
    ":next": {
      "N": "2"
    },
    ":expected": {
      "N": "1"
    }
  },
  "ReturnValues": "ALL_NEW"
}
JSON
aws dynamodb update-item --cli-input-json file://pay-order.json

ALL_NEW 显示状态 PAID、版本 2 和金额 145。重放未修改的请求,它仍然要求版本为 1。

aws dynamodb update-item --cli-input-json file://pay-order.json || true
aws dynamodb get-item \
  --table-name labex-d04-orders \
  --key '{"order_id":{"S":"O100"}}' \
  --consistent-read

预期出现 ConditionalCheckFailedException。最终读取结果仍为 PAID、版本 2 和金额 145;O101 仍为 NEW/版本 1。在生产环境中,调用方应重新读取当前状态,再决定是否适合进行另一次更新,而不是永远重试过期的预期版本。

实际项目在受条件保护的创建后保留原金额,并记录了基于版本的付款状态转换。

仅删除本实验的订单表

本步骤中,你将确认最终状态并清理本实验创建的表。保存的 JSON 文件描述请求,服务中的项目才能证明请求的实际效果。不要用命令历史或错误日志证明表已删除。

aws dynamodb scan --table-name labex-d04-orders --consistent-read

O100 为 PAID/版本 2/金额 145;O101 为 NEW/版本 1/金额 200。仅删除这张表,并等待它消失。

aws dynamodb delete-table --table-name labex-d04-orders
aws dynamodb wait table-not-exists --table-name labex-d04-orders
aws dynamodb list-tables
aws dynamodb get-item \
  --table-name labex-d04-reference \
  --key '{"id":{"S":"platform"}}' \
  --consistent-read

只剩下参考表,其中的 note 为 keep unchanged。这些成功的查询可以区分删除成功和连接失败。AWS View 中的订单表会消失。临时连接随本 VM 结束。

总结

你使用不存在条件保护了新订单创建,观察到重复订单被拒绝且存储的金额未改变,并允许另一个新键写入。随后,你使用预期版本条件执行了一次状态转换,并拒绝了过期请求的重放。你检查了项目的实际最终值,再仅删除本实验创建的表。

进一步阅读 AWS 官方资料:受保护写入的条件表达式。