Node.js (TypeScript) で DynamoDB のアトミックカウンタを試してみる

2021.04.12

吉川@広島です。

DynamoDB で連番の ID を実現したい場合があるかと思いますが、何も考えず実装してしまうと同時に書き込みが発生した場合にリソース同士の ID が被ってしまう事故になりかねません。
この点、アトミックカウンタを利用することで、同時書き込みが発生した場合も値を被らせずに採番し続けることができます。

項目と属性を操作する アトミックカウンター

[node] DynamoDBでatomic counter[シーケンス]

本当に値が被ることがないのか、実際に Node.js (TypeScript) で試してみました。

環境

  • Node.js 14.13.0
  • TypeScript 4.1.3
  • aws-sdk 2.884.0
  • aws-cli 2.0.54

テーブル作成

検証用に以下のようなテーブルを作成しました。

aws dynamodb create-table \
--table-name AtomicCounter \
--attribute-definitions \
AttributeName=name,AttributeType=S \
--key-schema AttributeName=name,KeyType=HASH \
--billing-mode PAY_PER_REQUEST

get + update で更新する (アトミックカウンタでない)

まずは NG な実装例として、値を取得してからプラスして更新するコードを書いてみます。

import * as aws from 'aws-sdk'

interface AtomicCounterItem {
  name: string
  value: number
}

const nonatomic = async (): Promise<AtomicCounterItem> => {
  const dynamoClient = new aws.DynamoDB.DocumentClient({
    region: 'ap-northeast-1',
    credentials: { accessKeyId: 'MY_ACCESS_KEY_ID', secretAccessKey: 'MY_SECRET_ACCESS_KEY' },
  })
  // 値を取得
  const { value } = (
    await dynamoClient
      .get({
        TableName: 'AtomicCounter',
        Key: {
          name: 'nonatomic',
        },
      })
      .promise()
  ).Item as AtomicCounterItem
  // プラスして更新
  return (
    await dynamoClient
      .update({
        TableName: 'AtomicCounter',
        ReturnValues: 'ALL_NEW',
        Key: {
          name: 'nonatomic',
        },
        UpdateExpression: 'SET #value = :newValue',
        ExpressionAttributeNames: {
          '#value': 'value',
        },
        ExpressionAttributeValues: {
          ':newValue': value + 1,
        },
      })
      .promise()
  ).Attributes as AtomicCounterItem
}

const main = async (): Promise<void> => {
  // ほぼ同時に100件の書き込みを行う
  const promises = Array.from({ length: 100 }).map(() => nonatomic())
  const result = await Promise.all(promises)
  // 結果を番号順にソートして表示
  console.log([...result.map(({ value }) => value)].sort((a, b) => a - b))
}

main()

コード実行前に値が 0 の Item を作成しておきます。

aws dynamodb put-item \
--table-name AtomicCounter \
--item \
'{"name": {"S": "nonatomic"}, "value": {"N": "0"}}'

この状態から // ほぼ同時に100件の書き込みを行う の処理を行った場合の期待挙動は 1,2,3,4,5...100 と採番することです。

実際の出力は以下です。

[
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
  1, 1, 1, 1
]

なんとすべて 1 となってしまいました。

aws dynamodb get-item \                            
--table-name AtomicCounter \
--key '{"name": {"S": "nonatomic"}}'
{
    "Item": {
        "name": {
            "S": "nonatomic"
        },
        "value": {
            "N": "1"
        }
    }
}

上の通り、当然、実際のテーブル上の値も 1 ですね。
これでは連番 ID のために使用するのは無理です。

update のみで更新する (アトミックカウンタ)

続いて、アトミックカウンタとして実装してみます。

import * as aws from 'aws-sdk'

interface AtomicCounterItem {
  name: string
  value: number
}

const atomic = async (): Promise<AtomicCounterItem> => {
  const dynamoClient = new aws.DynamoDB.DocumentClient({
    region: 'ap-northeast-1',
    credentials: { accessKeyId: 'MY_ACCESS_KEY_ID', secretAccessKey: 'MY_SECRET_ACCESS_KEY' },
  })
  // 現在の値にプラスして更新する
  return (
    await dynamoClient
      .update({
        TableName: 'AtomicCounter',
        ReturnValues: 'ALL_NEW',
        Key: {
          name: 'atomic',
        },
        UpdateExpression: 'ADD #value :incr',
        ExpressionAttributeNames: {
          '#value': 'value',
        },
        ExpressionAttributeValues: {
          ':incr': 1,
        },
      })
      .promise()
  ).Attributes as AtomicCounterItem
}

const main = async (): Promise<void> => {
  // ほぼ同時に100件の書き込みを行う
  const promises = Array.from({ length: 100 }).map(() => atomic())
  const result = await Promise.all(promises)
  // 結果を番号順にソートして表示
  console.log([...result.map(({ value }) => value)].sort((a, b) => a - b))
}

main()

こちらも値が 0 の Item を作成します。

aws dynamodb put-item \
--table-name AtomicCounter \
--item \
'{"name": {"S": "atomic"}, "value": {"N": "0"}}'

この状態でコードを実行した出力が以下です。

[
   1,  2,  3,   4,  5,  6,  7,  8,  9, 10, 11, 12,
  13, 14, 15,  16, 17, 18, 19, 20, 21, 22, 23, 24,
  25, 26, 27,  28, 29, 30, 31, 32, 33, 34, 35, 36,
  37, 38, 39,  40, 41, 42, 43, 44, 45, 46, 47, 48,
  49, 50, 51,  52, 53, 54, 55, 56, 57, 58, 59, 60,
  61, 62, 63,  64, 65, 66, 67, 68, 69, 70, 71, 72,
  73, 74, 75,  76, 77, 78, 79, 80, 81, 82, 83, 84,
  85, 86, 87,  88, 89, 90, 91, 92, 93, 94, 95, 96,
  97, 98, 99, 100
]

きちんと意図通りカウントされていますね。

aws dynamodb get-item \
--table-name AtomicCounter \
--key '{"name": {"S": "atomic"}}' 
{
    "Item": {
        "name": {
            "S": "atomic"
        },
        "value": {
            "N": "100"
        }
    }
}

上のように実際のテーブル上の値も 100 でした。

本文紹介以外の参考資料