あいつの日誌β

働きながら旅しています。

DynamoDB の特性を理解しないで開発していたら pagination の実装でつまづいた

前置き

私の勘違いが書かれている可能性がありますが、ご指摘頂けると大変喜びます。特に「いや、それはこうやったらできるよ」という情報をお待ちしております。

あらすじ

Serverless Framework 使って個人サイトを作ろうとしたら DynamoDB で若干手間取ったので後世のエンジニアの為に備忘録を残します。

DynamoDB について

DynamoDB は優れた分散データベースです。バックアップやスケールアップなどに時間を割く必要がないように工夫されたサービスです。なんですが、これまでの RDS や KVS とは注力されている箇所が違うで少し使いづらい部分があります。

パーティションキーの概念

DynamoDB では Pagination がしづらいです。できなくは無いのですが RDS と少し違うところがあります。検索条件にインデックスが含まれていないと正しい件数を取得できませんそしてインデックスを作成するには必ずパーティショニングする必要があります

というわけで実際に動作させて説明したいと思います。

準備

Serveless Framework の migration 機能が便利(再起動するたびにデータがセットされる)なのでこれを利用します。

環境は以下の通り

% node -v
v8.11.1

% sls -v
1.26.1

雛形を作成

sls create --template aws-nodejs --path practice-dynamodb && cd $_
touch example1.js example2.js
mkdir migrations
touch migrations/locations.json

migrations/locations.json を作成

[
  {
    "country": "JP",
    "cityEn": "Shibuya-ku",
    "stateEn": "Tokyo",
    "cityJp": "渋谷区",
    "createdAt": "2018-04-19 20:10:43",
    "stateJp": "東京都",
    "id": "1028337572",
    "name": "Xiang ni cafe(シャンニーカフェ)"
  },
  {
    "country": "JP",
    "cityEn": "Shibuya-ku",
    "stateEn": "Tokyo",
    "cityJp": "渋谷区",
    "createdAt": "2018-04-18 20:08:45",
    "stateJp": "東京都",
    "id": "620336124833357",
    "name": "岡本肉店"
  },
  {
    "country": "JP",
    "cityEn": "Shibuya-ku",
    "stateEn": "Tokyo",
    "cityJp": "渋谷区",
    "createdAt": "2018-04-17 15:31:09",
    "stateJp": "東京都",
    "id": "429313840761469",
    "name": "香港ロジ 原宿店"
  },
  {
    "country": "JP",
    "cityEn": "Sapporo-shi",
    "stateEn": "Hokkaido",
    "cityJp": "札幌市",
    "createdAt": "2018-04-16 14:07:07",
    "stateJp": "北海道",
    "id": "5841572",
    "name": "5坪 gotsubo"
  },
  {
    "country": "JP",
    "cityEn": "Sapporo-shi",
    "stateEn": "Hokkaido",
    "cityJp": "札幌市",
    "createdAt": "2018-04-15 14:07:07",
    "stateJp": "北海道",
    "id": "3518106",
    "name": "SAKANOVA"
  },
  {
    "country": "JP",
    "cityEn": "Toshima-ku",
    "stateEn": "Tokyo",
    "cityJp": "豊島区",
    "createdAt": "2018-04-15 14:07:07",
    "stateJp": "東京都",
    "id": "447856545563610",
    "name": "大衆酒場 かぶら屋池袋7号店"
  },
  {
    "country": "JP",
    "cityEn": "Sapporo-shi",
    "stateEn": "Hokkaido",
    "cityJp": "札幌市",
    "createdAt": "2018-04-15 14:07:07",
    "stateJp": "北海道",
    "id": "52232",
    "name": "菜もっきりや"
  },
  {
    "country": "JP",
    "cityEn": "Sumida-ku",
    "stateEn": "Tokyo",
    "cityJp": "墨田区",
    "createdAt": "2018-04-14 14:07:07",
    "stateJp": "東京都",
    "id": "8588811",
    "name": "立ち呑み 粋"
  },
  {
    "country": "JP",
    "cityEn": "Kawasaki-shi",
    "stateEn": "Kanagawa",
    "cityJp": "川崎市",
    "createdAt": "2018-04-14 14:07:07",
    "stateJp": "神奈川県",
    "id": "1032997013",
    "name": "和食と立喰い寿司 ナチュラ"
  },
  {
    "country": "JP",
    "cityEn": "Shibuya-ku",
    "stateEn": "Tokyo",
    "cityJp": "渋谷区",
    "createdAt": "2018-04-14 14:07:07",
    "stateJp": "東京都",
    "id": "896121",
    "name": "Bistro ひつじや"
  },
  {
    "country": "JP",
    "cityEn": "Takamatsu-shi",
    "stateEn": "Kagawa",
    "cityJp": "高松市",
    "createdAt": "2018-04-13 14:07:07",
    "stateJp": "香川県",
    "id": "1028420342",
    "name": "たべごとや 艸 - そう"
  },
  {
    "country": "JP",
    "cityEn": "Osaka-shi",
    "stateEn": "Osaka",
    "cityJp": "大阪市",
    "createdAt": "2018-04-12 14:06:28",
    "stateJp": "大阪府",
    "id": "242874420",
    "name": "魚屋ひでぞう 立ち呑み"
  },
  {
    "country": "JP",
    "cityEn": "Sapporo-shi",
    "stateEn": "Hokkaido",
    "cityJp": "札幌市",
    "createdAt": "2018-04-11 14:06:28",
    "stateJp": "北海道",
    "id": "1021458277",
    "name": "鮨角打ち 裏・小樽酒商たかの札幌駅前店"
  }
]

serverless.yml を修正

service: practice-dynamodb
plugins:
   - serverless-dynamodb-local

custom:
  dynamodb:
    start:
      port: 8000
      inMemory: true
      migrate: true
      seed: true
    seed:
      development:
        sources:
          - table: locations
            sources: [./migrations/locations.json]

provider:
  name: aws
  runtime: nodejs6.10
  stage: dev
  region: ap-northeast-1

resources:
  Resources:
    ArticlesTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: locations
        AttributeDefinitions:
         - AttributeName: id
           AttributeType: S
         - AttributeName: country
           AttributeType: S
         - AttributeName: stateEn
           AttributeType: S
         - AttributeName: cityEn
           AttributeType: S
         - AttributeName: createdAt
           AttributeType: S
        KeySchema:
         - AttributeName: id
           KeyType: HASH
        GlobalSecondaryIndexes:
          - IndexName: country-index
            KeySchema:
             - AttributeName: country
               KeyType: HASH
             - AttributeName: createdAt
               KeyType: RANGE
            Projection:
              NonKeyAttributes:
               - stateEn
               - cityEn
              ProjectionType: INCLUDE
            ProvisionedThroughput:
              ReadCapacityUnits: '1'
              WriteCapacityUnits: '1'
          - IndexName: stateEn-index
            KeySchema:
             - AttributeName: stateEn
               KeyType: HASH
            Projection:
              NonKeyAttributes:
               - id
              ProjectionType: INCLUDE
            ProvisionedThroughput:
              ReadCapacityUnits: '1'
              WriteCapacityUnits: '1'
          - IndexName: cityEn-index
            KeySchema:
             - AttributeName: cityEn
               KeyType: HASH
             - AttributeName: createdAt
               KeyType: RANGE
            Projection:
              NonKeyAttributes:
               - id
              ProjectionType: INCLUDE
            ProvisionedThroughput:
              ReadCapacityUnits: '1'
              WriteCapacityUnits: '1'
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

dynamodb を local に install して動作確認します。

sls dynamodb install

動作確認します。13件のアイテムを取得できていればOKです。

aws dynamodb scan --table-name locations --endpoint-url http://localhost:8000 | jq .Count
13

というわけで locations テーブルにはいくつかのインデックスが存在します。それぞれのインデックスで出来る事と出来ない事を確認します。

Just Do it

dynamoDB を参照するコマンドは大きく分けて scan, get, query の3種類です。 まずはプライマリーキーに対してコマンドを実行してみます。

example1: scan, get, query

create example1.js

const aws = require('aws-sdk');

function getDocClient() {
  return new aws.DynamoDB.DocumentClient({
    region: 'ap-northeast-1',
    endpoint: "http://localhost:8000"
  });
}

function main() {

  // Good
  getDocClient().scan({
    TableName: 'locations',
  }).promise().then(console.log).catch(console.error);

 // Good
  getDocClient().get({
    TableName: 'locations',
    Key: {
      id: "1032997013"
    }
  }).promise().then(console.log).catch(console.error);

  // Good
  getDocClient().query({
    TableName: 'locations',
    KeyConditionExpression: 'id = :id',
    ExpressionAttributeValues: {
      ':id': "1032997013"
    },
    Limit: 1
  }).promise().then(console.log).catch(console.error);

  // No Good
  getDocClient().query({
    TableName: 'locations',
    KeyConditionExpression: 'begins_with(id, :id)',
    ExpressionAttributeValues: {
      ':id': "103"
    }
  }).promise().then(console.log).catch(console.error);
}

main();

scan は全レコードをシーケンシャルに取得します。get は一意のユニークキーからレコードを一つだけ取得します。

DynamoDB のテーブル設計で考えないといけないのは query です。上記の結果からHash Key に対して部分一致の query が発行できないことが分かります。

DynamoDB では HASH Key を country:state:city のような構造にして先頭部分から一致したものを取得することはできません。その場合は RANGE KEY にする必要があります。

example2: セカンダリインデックス

今度は都道府県別に locations を取得したい場合は以下のようにします。 index を使用するので IndexName に 'stateEn-index' と cityEn-index を指定します。

create example2.js

const aws = require('aws-sdk');

function getDocClient() {
  return new aws.DynamoDB.DocumentClient({
    region: 'ap-northeast-1',
    endpoint: "http://localhost:8000"
  });
}

function main() {

  // Good
  getDocClient().query({
    TableName: 'locations',
    IndexName: 'stateEn-index',
    KeyConditionExpression: 'stateEn = :stateEn',
    ExpressionAttributeValues: {
      ':stateEn': "Hokkaido"
    }
  }).promise().then(console.log).catch(console.error);

  // Good
  getDocClient().query({
    TableName: 'locations',
    IndexName: 'cityEn-index',
    KeyConditionExpression: 'cityEn = :cityEn AND createdAt >= :createdAt',
    ExpressionAttributeValues: {
      ':cityEn': "Sapporo-shi",
      ':createdAt': "2018-04-16"
    }
  }).promise().then(console.log).catch(console.error);

  // Good
  getDocClient().query({
    TableName: 'locations',
    IndexName: 'cityEn-index',
    KeyConditionExpression: 'cityEn = :cityEn AND begins_with(createdAt, :createdAt)',
    ExpressionAttributeValues: {
      ':cityEn': "Sapporo-shi",
      ':createdAt': "2018-04"
    }
  }).promise().then(console.log).catch(console.error);
  
  // Good
  getDocClient().query({
    TableName: 'locations',
    IndexName: 'country-index',
    KeyConditionExpression: 'country = :country',
    ExpressionAttributeValues: {
      ':country': "JP",
    },  
    ScanIndexForward: false
  }).promise().then(console.log).catch(console.error);
  
}

main();

stateEn-indexHASH, cityEn-indexHASH + RANGE で作成されています。 stateEn-index は createdAt を含まないのでソート出来ないのですが、 cityEn-index はcreatedAt でソートされています。

このように Pagination が必要となる場合は HASH + RANGE でインデックスを作成する必要があります。

ただし KeyConditionExpression: 'cityEn = :cityEn AND begins_with(createdAt, :createdAt)' のように必ず HASH Key を指定しなければいけません。

このように DynamoDB ではデータがパーティションで区切られる前提で設計されています。もし全件をソートするなら country-index のように全データを同一のパーティションに含める必要があります。たぶんその辺りの事を理解した上で DynamoDB を利用する、しないを考えた方が良いでしょう。この例だとすでに Index を 3つ追加しているので RDS を検討した方が良いのかもしれません。

まとめ

DynamoDB は我々エンジニアにとっては強力な補助ツールとなるでしょう。ただし利用する場合はその特性を良く理解する必要があります。

近年では Serverless Framework という選択肢が出てきていますが、案件によってはそのような選択をできる様にする為に DynamoDB の Table 設計に一度慣れておいた方が良いと思います。

あとそれから

その DynamoDB で個人サイトつくりました。 飲み歩くのが好きなので飲み歩いたお店を記録するサイトです。

http://drunkard.tokyo/

こちらからは以上です。