Article

⏳ DynamoDBのTTLは即時削除ではない:期限切れデータを安全に扱う設計

aws, dynamodb, javascript, backend

DynamoDBで「1時間で無効になるセッション」を作るとき、TTL(Time To Live)を設定するだけで十分でしょうか。

実は、TTLは期限を過ぎた項目をバックグラウンドで削除する機能であり、期限になった瞬間に読み出せなくする機能ではありません。 AWS公式ドキュメントによると、期限切れ項目が実際に削除されるまで数日かかることがあります。削除待ちの項目は読み取り・更新が可能です。

本記事ではAWS SDK for JavaScript v3を使い、不要データの掃除はTTL、アクセス可否の判定はアプリケーションという役割分担を、具体的なコードとともに整理します。

最初に知っておくTTLの仕様

TTLの有効期限は、テーブルで指定した属性(以下は expireAt)に保存します。Unix epoch秒、つまり1970年1月1日00:00:00 UTCからの経過「秒」を、DynamoDBの Number 型として記録します。

{
  "pk": { "S": "USER#123" },
  "sk": { "S": "SESSION#abc" },
  "expireAt": { "N": "1792573200" }
}

重要な性質は次のとおりです。

  • 削除は非同期で、期限と削除時刻は一致しない。
  • 削除前の期限切れ項目も GetItem や Query などで読み出せる。
  • 削除前なら項目の更新も可能。期限切れ項目の更新には条件式を推奨。
  • 属性名は大文字・小文字を区別する。String 型やミリ秒値では期待どおりに動かない。
  • 削除待ちの項目も保存料金と読み取りコストの対象になる。

したがって、招待URL・認証セッション・一時的な利用権限など、期限を厳密に扱いたいデータではTTLの削除完了をアクセス判定に利用しません。

1. TTLを有効にする

以下は、pk(パーティションキー)と sk(ソートキー)を持つ Sessions テーブルがすでに存在する前提です。AWS CLIでTTLを有効にします。

aws dynamodb update-time-to-live \
  --table-name Sessions \
  --time-to-live-specification "Enabled=true,AttributeName=expireAt"

aws dynamodb はCLIからDynamoDBを操作するコマンド、update-time-to-live はTTL設定の変更、--table-name は対象テーブルの指定です。Enabled=true は有効化、AttributeName=expireAt は期限を保存する属性名を意味します。認証情報やリージョンは事前にCLI側で設定してください。

確認には次を使用します。

aws dynamodb describe-time-to-live --table-name Sessions

応答の TimeToLiveStatus が ENABLED か確認します。TTL設定の変更は反映に時間がかかることがあり、短時間で繰り返し変更するとエラーになることがあります。

2. 登録時:現在時刻から1時間後の期限を保存する

依存ライブラリとして @aws-sdk/client-dynamodb を使います。

import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";

const client = new DynamoDBClient({});

export async function createSession(userId, sessionId) {
  const now = Math.floor(Date.now() / 1000);
  const expireAt = now + 60 * 60;

  await client.send(new PutItemCommand({
    TableName: "Sessions",
    Item: {
      pk: { S: "USER#" + userId },
      sk: { S: "SESSION#" + sessionId },
      expireAt: { N: String(expireAt) }
    },
    ConditionExpression: "attribute_not_exists(pk)"
  }));

  return expireAt;
}

Date.now() はミリ秒のため 1000 で割ります。Math.floor は小数点以下を切り捨てる処理です。低レベルSDKではDynamoDBの数値属性を { N: "数値" } という形で渡します。

ConditionExpression は書き込み条件です。attribute_not_exists(pk) により、同じ主キーを持つ項目の意図しない上書きを防ぎます。TTL切れでまだ削除されていない同じキーへの再登録も拒否するため、セッションIDを再利用する要件がある場合は別の設計が必要です。

3. 取得時:期限切れはデータが残っていても無効扱いする

import { GetItemCommand } from "@aws-sdk/client-dynamodb";

export async function findValidSession(userId, sessionId) {
  const response = await client.send(new GetItemCommand({
    TableName: "Sessions",
    Key: {
      pk: { S: "USER#" + userId },
      sk: { S: "SESSION#" + sessionId }
    }
  }));

  const item = response.Item;
  if (!item) return null;

  const expireAt = Number(item.expireAt?.N);
  const now = Math.floor(Date.now() / 1000);

  if (!Number.isFinite(expireAt) || expireAt <= now) {
    return null;
  }
  return item;
}

item.expireAt?.N の ?. は値がなければ処理を止めるオプショナルチェイニングです。Number.isFinite で数値として妥当かを確認し、期限が現在時刻以下なら無効にしています。

これにより、DynamoDBのTTL削除が遅れても、アプリ側では期限切れのセッションを利用できません。ただし実際の認証処理では、ユーザーの停止状態・強制ログアウト・アクセス権などの追加条件も必要です。

4. 一覧取得時:FilterExpression で除外する

複数のセッションを取得する Query では、結果から期限切れを除くフィルター式を使えます。

import { QueryCommand } from "@aws-sdk/client-dynamodb";

export async function listValidSessions(userId) {
  const now = Math.floor(Date.now() / 1000);

  return client.send(new QueryCommand({
    TableName: "Sessions",
    KeyConditionExpression: "#pk = :pk",
    FilterExpression: "#ttl > :now",
    ExpressionAttributeNames: {
      "#pk": "pk",
      "#ttl": "expireAt"
    },
    ExpressionAttributeValues: {
      ":pk": { S: "USER#" + userId },
      ":now": { N: String(now) }
    }
  }));
}

KeyConditionExpression はデータの検索対象を絞るキー条件、FilterExpression は読み取った結果から返却項目をさらに絞る条件です。この例では期限属性がない項目も除外します。無期限の項目を許す仕様なら条件を変更してください。

ハマりどころ:FilterExpression は読み取りコストを減らしません。 AWS公式仕様では、Query のフィルターは読み取り後に適用されるため、読み取りキャパシティー消費はフィルターの有無で変わりません。また1回の結果に含まれるデータ量には上限があり、LastEvaluatedKey が返ったらページ送りが必要です。フィルターで全件落ちたページでも、次ページが存在し得ます。

5. 更新時:有効な項目だけを書き換える

UpdateItem も条件なしだと期限切れの項目を更新してしまう可能性があります。さらに存在しないキーの場合、新規項目が作られることもあります。

import { UpdateItemCommand } from "@aws-sdk/client-dynamodb";

export async function markActive(userId, sessionId) {
  const now = Math.floor(Date.now() / 1000);

  return client.send(new UpdateItemCommand({
    TableName: "Sessions",
    Key: {
      pk: { S: "USER#" + userId },
      sk: { S: "SESSION#" + sessionId }
    },
    UpdateExpression: "SET #seen = :now",
    ConditionExpression: "attribute_exists(pk) AND #ttl > :now",
    ExpressionAttributeNames: {
      "#seen": "lastSeenAt",
      "#ttl": "expireAt"
    },
    ExpressionAttributeValues: {
      ":now": { N: String(now) }
    }
  }));
}

UpdateExpression は変更内容、ConditionExpression は変更の実行条件です。この例は「項目が存在し、有効期限内のときだけ更新する」設計です。条件を満たさない場合は ConditionalCheckFailedException になります。これは単なるサーバー障害とは限らず、セッション無効として扱うべきケースがあります。

TTL・フィルター・条件式をどう使い分ける?

目的 使う仕組み
古いデータを非同期で片付ける DynamoDB TTL
期限切れ直後からアクセスを拒否する アプリ側で expireAt <= now を判定
一覧に期限切れを表示しない Query / Scan のフィルター
期限切れ項目への更新を防ぐ ConditionExpression
TTL削除を契機に後処理する DynamoDB Streams。ただし削除は遅延する

DynamoDB StreamsのTTL削除イベントも期限ぴったりに届くものではありません。グローバルテーブルでは削除イベントの識別情報がリージョンごとに異なる点にも注意してください。

テストでは、期限の1秒前/ちょうど/1秒後、TTL削除待ちの項目、expireAt 欠落、不正な型、Query のページ送り、期限切れ時の条件付き書き込み失敗を最低限確認するとよいでしょう。

まとめ

DynamoDBのTTLは「有効期限を守る機能」ではなく、「期限後に不要な項目を削除する仕組み」として設計します。アクセスを許すかどうかは、アプリケーションの読み取り・更新経路で判定する必要があります。

なお、ここで示したSDKコードは公式仕様に沿った実装例ですが、実際のAWSアカウントへの書き込み試験は行っていません。

参考資料(2026年10月21日確認)