DynamoDBの Sparse Indexes を活用して 「未完了タスク」 だけを取得する方法を試してみた

DynamoDBの Sparse Indexes を活用して 「未完了タスク」 だけを取得する方法を試してみた

2026.01.20

スパースインデックス (Sparse Indexes) とは

Sparse Index を直訳すると、「まばらなインデックス」 となり、つまりは 「スカスカなインデックス」 というイメージです。

具体的にいうと、DynamoDBのインデックス(GSI/LSI)は、「インデックスのキーとして指定した属性(項目)を持っていないデータは、インデックスには反映されない」 という性質があり、これを利用したのがスパースインデックスになります。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-indexes-general-sparse-indexes.html

やりたいこと

DynamoDB の スパースインデックスの性質を活用して、「未完了タスク」 のみを取得してみます。
例えば、以下のようなデータがあるとします:

{
  userId: "001",
  taskId: "002",
  title: "プレゼン資料作成",
  isCompleted: false  // 完了/未完了を boolean で管理したい
}

これをシンプルに、isCompleted をインデックスキーに指定すれば解決!
と思いきや、DynamoDB のインデックスキーには、STRING,NUMBER,BINARY の三つの方しか使用できないという制約があるため、boolean 値で 「完了/未完了」 といった値でインデックスを貼ることができません。
そこで、今回はスパースインデックスの性質を使って、解決してみようという試みです。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Constraints.html#limits-data-types

完成した全体のコードはこちらにあります。

https://github.com/HasutoSasaki/dynamodb-sparse-index-test

環境

AWS リソースの管理には、記事の読者が環境を再現できるように、CDK を使って作っていきます。
動作確認は、アプリケーション開発のイメージが湧きやすいように、Lambda + TypeScript で作ります。

検証

データ構造としては、「未完了タスク」 にのみpendingAt というフィールドを設定します。

// pendingAt が設定されている、「プレゼン資料作成」 「メール返信」 のみを取ってこれれば成功です。
// 例
 {
      "userId": "001",
      "title": "プレゼン資料作成",
      "pendingAt": 1768631797000,
   ....
    },
    {
      "userId": "001",
      "title": "AWS本を読む",
      ....
    },
    {
      "userId": "001",
      "title": "メール返信",
      "pendingAt": 1768631799000
      ...
    }

テーブルの作成

AWS CDKで、検証用の todo テーブルを作成、未完了タスク取得用のインデックスを設定します。

import { Construct } from "constructs";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import { RemovalPolicy } from "aws-cdk-lib";

export class TodosTable extends Construct {
  public readonly todosTable: dynamodb.Table;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    this.todosTable = new dynamodb.Table(this, "TodoTable", {
      tableName: "Todos",

      partitionKey: {
        name: "userId",
        type: dynamodb.AttributeType.STRING,
      },

      sortKey: {
        name: "createdAt",
        type: dynamodb.AttributeType.NUMBER,
      },

      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    this.todosTable.addLocalSecondaryIndex({
      indexName: "user-incomplete-lsi",
      sortKey: {
        name: "pendingAt",
        type: dynamodb.AttributeType.NUMBER,
      },
      projectionType: dynamodb.ProjectionType.ALL,
    });
  }
}

lambda のコード

今回コードはメインではないため、シンプルに未完了のタスクのみ取得する処理を実装します。

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

interface Event {
  userId: string;
}

const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME!;

// 直接未完了をクエリ
export const handler = async ({ userId }: Event) => {
  const result = await docClient.send(
    new QueryCommand({
      TableName: TABLE,
      IndexName: "user-incomplete-lsi",
      KeyConditionExpression: "userId = :userId",
      ExpressionAttributeValues: { ":userId": userId },
      ConsistentRead: true,
    }),
  );
  return {
    count: result.Count,
    items: result.Items,
  };
};

検証データを投入

投入用のshell script
#!/usr/bin/env bash
TABLE_NAME="${TABLE_NAME:-Todos}"
REGION="${AWS_REGION:-ap-northeast-1}"

# タイムスタンプ(ミリ秒)を作成
NOW=$(($(date +%s) * 1000))
TS_001_1=$((NOW - 10000))
TS_001_2=$((NOW - 9000))
TS_001_3=$((NOW - 8000))
TS_002_1=$((NOW - 7000))
TS_002_2=$((NOW - 6000))
TS_003_1=$((NOW - 5000))
TS_003_2=$((NOW - 4000))
TS_003_3=$((NOW - 3000))
TS_COMPLETED_1=$((NOW - 5000))
TS_COMPLETED_2=$((NOW - 3000))
TS_COMPLETED_3=$((NOW - 1000))

# BatchWrite で一括投入(未完了は pendingAt を設定、完了は completedAt を設定)
REQUEST_JSON=$(cat <<JSON
{
  "$TABLE_NAME": [
    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "001" },
          "createdAt": { "N": "$TS_001_1" },
          "id": { "S": "001" },
          "title": { "S": "プレゼン資料作成" },
          "description": { "S": "来週の営業会議用" },
          "category": { "S": "work" },
          "pendingAt": { "N": "$TS_001_1" }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "001" },
          "createdAt": { "N": "$TS_001_2" },
          "id": { "S": "002" },
          "title": { "S": "メール返信" },
          "description": { "S": "クライアントへの返信完了" },
          "category": { "S": "work" },
          "completedAt": { "N": "$TS_COMPLETED_1" }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "001" },
          "createdAt": { "N": "$TS_001_3" },
          "id": { "S": "003" },
          "title": { "S": "AWS本を読む" },
          "description": { "S": "認定試験の勉強" },
          "category": { "S": "personal" },
          "pendingAt": { "N": "$TS_001_3" }
        }
      }
    },

    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "002" },
          "createdAt": { "N": "$TS_002_1" },
          "id": { "S": "004" },
          "title": { "S": "コードレビュー" },
          "description": { "S": "新機能のPRレビュー" },
          "category": { "S": "work" },
          "pendingAt": { "N": "$TS_002_1" }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "002" },
          "createdAt": { "N": "$TS_002_2" },
          "id": { "S": "005" },
          "title": { "S": "食材購入" },
          "description": { "S": "週末の買い物完了" },
          "category": { "S": "shopping" },
          "completedAt": { "N": "$TS_COMPLETED_2" }
        }
      }
    },

    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "003" },
          "createdAt": { "N": "$TS_003_1" },
          "id": { "S": "006" },
          "title": { "S": "ジムに行く" },
          "description": { "S": "筋トレ" },
          "category": { "S": "personal" },
          "pendingAt": { "N": "$TS_003_1" }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "003" },
          "createdAt": { "N": "$TS_003_2" },
          "id": { "S": "007" },
          "title": { "S": "技術書を買う" },
          "description": { "S": "AWS認定試験の本" },
          "category": { "S": "shopping" },
          "pendingAt": { "N": "$TS_003_2" }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "userId": { "S": "003" },
          "createdAt": { "N": "$TS_003_3" },
          "id": { "S": "008" },
          "title": { "S": "会議資料準備" },
          "description": { "S": "明日の会議用資料完成" },
          "category": { "S": "work" },
          "completedAt": { "N": "$TS_COMPLETED_3" }
        }
      }
    }
  ]
}
JSON
)

echo "Writing batch items to table '$TABLE_NAME' in region '$REGION'..."
aws dynamodb batch-write-item --region "$REGION" --request-items "$REQUEST_JSON"
echo "Done."

無事データが挿入できました

dynamodb-table

lambdaを実行して、確かめてみる

userIdが 001 の未完了タスクを取得してみます。

aws lambda invoke \
    --function-name todo-function \
    --payload '{"userId": "001"}' \
    --cli-binary-format raw-in-base64-out \
    response.json

レスポンスは、生成される、response.json に格納されるので中身を確認します。

無事に未完了タスクのみ取って来れています!

{
  "count": 2,
  "items": [
    {
      "userId": "001",
      "category": "work",
      "createdAt": 1768631797000,
      "description": "来週の営業会議用",
      "id": "001",
      "title": "プレゼン資料作成",
      "pendingAt": 1768631797000
    },
    {
      "userId": "001",
      "category": "personal",
      "createdAt": 1768631799000,
      "description": "認定試験の勉強",
      "id": "003",
      "title": "AWS本を読む",
      "pendingAt": 1768631799000
    }
  ]
}

コンソール上で確認しても、完了時間(completedAt) が存在しない、id001,003 の二つが取ってこれたことがわかります。

スクリーンショット 2026-01-17 15.43.28

おわり

今回は、データの状態が、ON/OFF みたいな2パターンの時、シンプルに boolen で定義したいけど、indexが貼れない。というときに使える小技が学べました。

以前、無理にやろうとして、true/false を文字列で保存したことがありました。
その結果、アプリケーション内での通常の boolean フラグと、インデックス用の文字列型の真偽値が混在し、コードの可読性と保守性が低下してしまうこととなってしまいました。

const isComplete = true;           // 通常のboolean
const isActive = 'true';           // インデックス用の文字列

ですが、今回学んだ方法を活用することで、未完了(false) 状態のデータを取得できるインデックスが貼れるようになります!
DynamoDB の特性を活かした小技として、どなたかの参考になれば幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事