AWS DynamoDBで楽観的排他制御(楽観的ロック)をやってみた

2020.04.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

楽観的排他制御(楽観的ロック)とは、同時アクセスによるデータの不整合を防ぐ排他制御(ロック)の手段です。バージョン番号や更新日時を利用して、データを取得してから更新するまでの間、データが変更されていないことを確認することで整合性を保ちます。対照に悲観的排他制御(悲観的ロック)では、データを取得する際にロックをかけることで整合性を保ちます。

楽観的排他制御(楽観的ロック)

DynamoDBでは楽観的排他制御を実装できます。また、DynamoDBのトランザクションでは楽観的排他制御が採用されています。

DynamoDBで楽観的排他制御

AWS LambdaでDynamoDBのデータを更新する際に、条件付き書き込みを使用してバージョン番号で楽観的排他制御を実装してみます。

DynamoDBに商品テーブルProductを作成して次のデータを格納しておきます。

productName(PK) stock version
りんご 100 1

Lambdaで商品テーブルから商品情報を取得して、在庫数を更新する処理を書いてみました。

index.js

const DynamoDB = require('aws-sdk/clients/dynamodb');

const PRODUCT_TABLE_NAME = 'Product';

const docClient = new DynamoDB.DocumentClient({
  region: 'ap-northeast-1',
});

/**
 * 商品を取得
 * @param productName 商品名
 */
const getProductItem = async productName => {
  const params = {
    TableName: PRODUCT_TABLE_NAME,
    Key: {
      productName: productName,
    },
  };

  const response = await docClient.get(params).promise();

  return response.Item;
};

/**
 * 商品を更新
 * @param productName 商品名
 * @param beforeVersion 購入前のバージョン
 * @param afterStock 購入後の在庫数
 */
const updateProductStock = async (productName, beforeVersion, afterStock) => {
  const afterVersion = beforeVersion + 1;

  const params = {
    TableName: PRODUCT_TABLE_NAME,
    Key: {
      productName: productName,
    },
    ExpressionAttributeNames: {
      '#version': 'version',
      '#stock': 'stock',
    },
    ExpressionAttributeValues: {
      ':beforeVersion': beforeVersion,
      ':afterVersion': afterVersion,
      ':afterStock': afterStock,
    },
    UpdateExpression: 'SET #version=:afterVersion, #stock=:afterStock',
    ConditionExpression: '#version=:beforeVersion',
  };

  try {
    await docClient.update(params).promise();
  } catch (e) {
    if (e.code === 'ConditionalCheckFailedException') {
      throw new Error('楽観的排他制御によるエラー');
    }

    throw e;
  }
};

exports.handler = async () => {
  const purchaseProductName = 'りんご'; // 購入商品
  const purchaseQuantity = 1; // 購入個数

  const productItem = await getProductItem(purchaseProductName);

  const beforeVersion = productItem.version; // 購入前のバージョン
  const afterStock = productItem.stock - purchaseQuantity; // 購入後の在庫数

  await new Promise(resolve => setTimeout(resolve, 5000)); // 何かの処理(5秒)

  await updateProductStock(purchaseProductName, beforeVersion, afterStock);
};

このLambda関数を同時に2つ実行すると、後から実行された処理は条件付き書き込み(ConditionExpression)によりエラーが返ります。また、Lambdaを順番に2つ実行した場合は、両方の処理が成功して在庫が2つ減り、バージョン番号が2つ上がることが分かります。

排他制御を実装しない場合は、Lambdaを同時に2つ実行してもエラーは返りませんが、在庫数が1つしか減らず、実際のデータとの不整合が生じてしまいます。

注意事項

楽観的排他制御ではバージョン番号の他にも更新日時を使用することがあります。更新日時をUnixtimeで記録している場合には、秒単位でしか検証することができませんので、データの変更を検出できない可能性があります。更新日時を使用するのであれば、Milliseconds(ミリ秒)まで記録するのがよいと思います。

DynamoDBのGSI(グローバルセカンダリインデックス)から取得したデータを用いて楽観的排他制御を実装した場合、データの不整合が発生する可能性があります。GSIはDynamoDBのベーステーブルからデータを同期しますが、同期には若干のタイムラグがあります。そのためGSIから取得したデータが最新とは限りません。GSIから取得したデータを楽観的排他制御に用いるのは避けてください。

まとめ

データの整合性を維持するため排他制御を用いるケースは多いと思います。ただ、排他制御の時間が長ければ長いほど、競合が発生する確率が高くなり、システムの利便性が悪くなりますので、データ不整合が起こらない範囲でなるべく処理を短時間で終わらせる必要があると思います。

参考資料