DynamoDB Pagination:利用 LastEvaluatedKey 與 ExclusiveStartKey 實作分頁

DynamoDB Pagination:利用 LastEvaluatedKey 與 ExclusiveStartKey 實作分頁

如何為 Dynamodb 做分頁呢?本文主要分三個部份:(1) 為什麼要做分頁?;(2) 利用 LastEvaluatedKey 與 ExclusiveStartKey 實作分頁;(3) 範例。

為什麼要做分頁?

為什麼要做分頁 (pagination)?為什麼不一次把整個資料庫都讀出來就好?

如果資料量很少,一次全部都翻過一次拿出來給大家展示看看,是可行的;但若資料量很大,翻一次要花很多時間,在讀取過程中除了耗時也花錢,而 AWS 設定了每次存取上限是 4KB,讓我們無法無限制的讀取資料。

為了降低成本,將資料庫切成多個 segmenet 來分次讀取,是比較好的解法。注意,在這裡提到的分頁不是使用者介面的分頁,而是指 DynamoDB paginates the results from Scan operations

利用 LastEvaluatedKey 與 ExclusiveStartKey 實作分頁

如果本次查詢 (1) 得到的結果超過預設的上限 4KB,或是 (2) 超過自己給定 Limit 的上限,DynamoDB 就會回傳 LastEvaluatedKey,利用 LastEvaluatedKey 設定 ExclusiveStartKey 來指定下一次存取的起始點,就能實作分頁功能。

白話來說,我們可以把 LastEvaluatedKey 和 ExclusiveStartKey 想像成 cursor,告訴我們現在掃到資料庫的哪一筆資料。LastEvaluatedKey 告訴我們最後看過的是哪一筆資料,而 ExclusiveStartKey 告訴我們要從「哪裡的下一筆開始」。

實際實作的方式是這樣的…

在這裡要注意的是,資料庫是隨時變動的,有可能在切換分頁時得到更多或更少的結果,導致分頁與預期不同,因此分頁到底在哪一頁,建議不要用回傳的資料量來決定,而是要用 LastEvaluatedKey。

範例

架構

如下圖,在此範例會透過 API Gateway 觸發 Lambda function 來讀取 Dynamodb table。

Dynamodb Pagination 架構

利用 DynamoDB 建立資料表

首先,利用 DynamoDB 建立甜點資料表 snack_list。這個資料表有 4 個欄位,分別是 id (primary key,這裡是用建立此筆紀錄時的 timestamp)、name (甜點店名)、phtotURL (圖片網址)、tags (標籤,幫店家做分類)。

資料表 snack_list 如下圖所示。

分頁查詢資料

如果不知道怎麼用 DynamoDB 建立一個甜點資料表,可以參考這個手把手教學

撰寫 Lambda 讀取資料表

再來,寫一支 Lambda function 來讀取資料表,選取資料表中標籤為「蛋糕」的 record。

const AWS = require('aws-sdk');
AWS.config.update({ region: 'us-east-1' });

const ddb = new AWS.DynamoDB({ apiVersion: '2012-08-10' });

exports.handler = async (event) => {
  const {
    queryStringParameters: { startKey },
  } = event;
  const ExclusiveStartInfo = !!startKey ? { ExclusiveStartKey: { id: { S: startKey } } } : {};
  const params = {
    TableName: 'snack_list',
    FilterExpression: 'tags = :tags',
    ExpressionAttributeValues: { ':tags': { S: '蛋糕' } },
    Limit: 5,
    ...ExclusiveStartInfo,
  };
  const body = await getDataFromDB(params);

  return {
    statusCode: 200,
    body: JSON.stringify(body),
  };
};

const getDataFromDB = async (params) => {
  return new Promise((resolve, reject) => {
    ddb.scan(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};

說明

如果不知道怎麼撰寫 Lambda function,可以參考這個手把手教學

第 1 次抓資料

由於是第一次抓資料,不帶入 ExclusiveStartKey,得到結果如下,其中 LastEvaluatedKey 是 1667273809990,掃過我們在 Limit 設定的 5 筆資料,回傳符合條件的 1 筆資料。由於有回傳 LastEvaluatedKey,表示資料表有東西還沒看過,稍後繼續掃剩下的部份。

{
  "statusCode": 200,
  "body": {
    "Items": [
      {
        "id": {
          "S": "1667273756197"
        },
        "name": {
          "S": "艾媽咪"
        },
        "tags": {
          "S": "蛋糕"
        }
      }
    ],
    "Count": 1,
    "ScannedCount": 5,
    "LastEvaluatedKey": {
      "id": {
        "S": "1667273809990"
      }
    }
  }
}

第 2 次抓資料

繼續第二次抓資料,ExclusiveStartKey 帶入 1667273809990,檢視第 6 ~ 10 筆的資料,得到結果如下,其中 LastEvaluatedKey 是 1666341492264,掃過我們在 Limit 設定的 5 筆資料,回傳符合條件的 4 筆資料。由於有回傳 LastEvaluatedKey,表示資料表有東西還沒看過,稍後繼續掃剩下的部份。

{
  "statusCode": 200,
  "body": {
    "Items": [
      {
        "id": {
          "S": "1667273786503"
        },
        "name": {
          "S": "藍門咖啡"
        },
        "tags": {
          "S": "蛋糕"
        }
      },
      {
        "id": {
          "S": "1667273636196"
        },
        "name": {
          "S": "深夜裡的法國手工甜點"
        },
        "tags": {
          "S": "蛋糕"
        }
      },
      {
        "id": {
          "S": "1667273921641"
        },
        "name": {
          "S": "生活在他方"
        },
        "tags": {
          "S": "蛋糕"
        }
      },
      {
        "id": {
          "S": "1666341492264"
        },
        "name": {
          "S": "老法的酒窩"
        },
        "photoURL": {
          "S": ""
        },
        "tags": {
          "S": "蛋糕"
        }
      }
    ],
    "Count": 4,
    "ScannedCount": 5,
    "LastEvaluatedKey": {
      "id": {
        "S": "1666341492264"
      }
    }
  }
}

第 3 次抓資料

繼續第三次抓資料,將 ExclusiveStartKey 帶入 1666341492264 後再次 scan,得到的結果如下,並沒有回傳 LastEvaluatedKey,表示整張 table 都看完了,不需要再發 scan 的 request 了。

{
  "statusCode": 200,
  "body": {
    "Items": [
      {
        "id": {
          "S": "1667273860281"
        },
        "name": {
          "S": "一百種味道"
        },
        "tags": {
          "S": "蛋糕"
        }
      }
    ],
    "Count": 1,
    "ScannedCount": 1
  }
}

結合 API Gateway

最後,要實作從 API Gateway 帶入 query string 來傳入 ExclusiveStartKey 這部份。

在 API Gateway 這裡,先來測試一下,在「Query Strings」欄位依次帶入 startKey 後按下「Test」按鈕…

表示成功透過 API Gateway 觸發 Lambda function 讀取資料表了。

deploy 這個 API 後,拿到 path https://xxx.us-east-1.amazonaws.com/dev/snacks?startKey=<id>,如下圖 Postman 打 API 的方式,接下來就能在 UI 用這樣的解法來取得甜點資料表裡面的資料了。

Postman 打 API

如果不知道怎麼透過 API Gateway 觸發 Lambda function,可以參考這個手把手教學

參考資料


DynamoDB Lambda API Gateway Serverless AWS Amazon Web Services RESTful API Postman