【AWS 奮闘記】AWS Athena について 2
はじめに
前回は AWS Athena について、基本的な概要を説明しました。
今回はもう少し踏み込んだ内容を紹介します。
パーティション
パーティションとはデータを論理的に分割する仕組みです。
思い出してください。前回の記事で athena-sample-data-[あなたのバケット名]/csv-data/
に格納されている CSV ファイルを読むテーブルを作成していました。
このときは2ファイルを格納していたので、2ファイル分のスキャンが発生していました。
`athena-sample-data-[あなたのバケット名]/csv-data/`
└── csv-data/
├── employees.csv
└── sales_2023.csv
これが100、1000ファイルが格納されていると、どうなるでしょうか?
2ファイル分のスキャンが100、1000ファイル分のスキャンになるということです。
ファイルスキャンが多数発生し、クエリが重くなっていくことは想像に難くありません...
つまり、大量のファイルを格納する場合は、パーティションを活用する必要があります。
ファイルスキャン量を減らすためにパーティションで論理分割して、そのパーティションに対してスキャンをかけることでクエリのパフォーマンスを向上させるというのが、パーティションの目的です。
そして、Athena はクエリ時のデータのスキャン量に依存して課金されるため、パーティションを活用することでコストを抑えることにも繋がります。
パーティションを作成する
ここからは、パーティションをどうやって作って、データを論理分割していくかです。
手動でやる場合、下記の手順を踏む必要があります。
- S3 のフォルダ構造をパーティション用に組織化する。
- Athena でパーティションテーブルを作成する。
- Athena でパーティションテーブルにパーティション情報を追加する。
1. S3 のフォルダ構造をパーティション用に組織化する
まず S3 のフォルダでパーティションを組織していきます。
s3://athena-sample-data-[あなたのバケット名]/partitioned-sales/
├── year=2023/
│ ├── month=01/
│ │ ├── day=01/
│ │ │ └── data.csv
│ │ └── day=02/
│ └── month=02/
└── year=2024/
└── month=01/
このようにフォルダでパーティションを区切っていきます。
なんとなく想像がつくと思いますが、2023/01/01 のデータに対してクエリをかける場合は、s3://athena-sample-data-[あなたのバケット名]/partitioned-sales/year=2023/month=01/day=01
のフォルダのみをスキャンするということになります。
パーティションのフォルダ名はキーと値を等号 =
で結合して作成します。
例えば、year=2023
の場合は、year
がパーティションキー、2023
が値となります(month
、day
も同様です)。
今回は下記の構成でパーティションのフォルダ構造を作成し、ファイルを配置しました。
s3://athena-sample-data-[あなたのバケット名]/partitioned-sales/
├── year=2023/
│ ├── month=01/
│ │ └── sales_2023_01.csv
│ └── month=02/
│ └── sales_2023_02.csv
└── year=2024/
└── month=01/
└── sales_2024_01.csv
パーティションのキーは year
、month
の2つです。
フォルダに配置される各 CSV の内容は下記のような構成です。
sales_2023_02.csv
と sales_2024_01.csv
も同じ構成です(データのみ違います)。
データ件数も各ファイルごとに5件入っています。
sale_id,product_id,employee_id,amount,sale_date,region
1,101,4,1200,2023-01-05,Kanto
2,102,7,800,2023-01-06,Kyushu
3,103,10,1500,2023-01-07,Chubu
4,101,4,950,2023-01-10,Kanto
5,104,7,2200,2023-01-12,Kyushu
パーティションキー
ここで、year
、month
は「パーティションキー」と呼ばれます。
私たちがパーティションとして適している値を選出して、フォルダ構造を設定する必要があります。
実際にパーティションを作成する際には下記の基準でキーを選ぶといいとのことです。
- クエリで頻繁に使用されるプロパティ
- 低いカーディナリティを持つプロパティ
また、データに含まれるカラムをパーティションキーにしなければならないといった制約はありません。
お好みのキーを設定できます。
パーティションキーに関する素朴な疑問
ここで、疑問に思ったのですが、DB でインデックスを作成するときは高いカーディナリティが良いです。なぜ AWS Athena では低いカーディナリティがいいのでしょうか?
ちょっと Claude さんに聞いたり、自分で調べてみました。
以下、調査した結果です。
1. カーディナリティが高いとパーティション数が増え、メタデータが大きくなり、メタストアの更新が重くなる。
下記サイトに記載を見つけました。
クエリ時にパーティションメタデータにアクセスする必要があるので、結果としてクエリも遅くなるそうです。
Partition keys should have a relatively low cardinality. As the number of partitions in your table increases, the higher the overhead of retrieving and processing the partition metadata, and the smaller your files. Partition keys with too many values can negate the benefit of partitioning.
2. データの均等性の問題
高いカーディナリティになると、パーティションキーが多くなり、ファイルの偏りが不均一になりやすい。
低カーディナリティだとある程度ファイルがまとまるため、ファイルの偏りが均一になりやすい。
ファイルが偏ると、パーティションの意味が薄れてしまうということでしょう。
これは、そもそものパーティションに対するファイルの分布にも依存しますね...
あくまで、そういう傾向があるということでしょう。
パーティションキーの設計時は、そのパーティションに対するデータの分布も考慮してください。
-- 低いカーディナリティ(日付)→ 均等に分散
date=2025-01-01: 100GB
date=2025-01-02: 98GB
date=2025-01-03: 102GB
→ 各パーティションが適切なサイズ
-- 高いカーディナリティ(user_id)→ 不均等
user_id=1: 10MB
user_id=2: 1KB
user_id=power_user: 50GB -- 極端に偏る
→ パーティションサイズがバラバラ
上記の場合、user_id=power_userのにアクセスする場合、パーティションを切る前とほぼ同じ時間がかかるということですね。
3. クエリとの相性
パーティションを使用してクエリで問い合わせする際に、どのパーティションで検索するか指定する必要が生じます。
複数パーティションにまたがるクエリの場合、高いカーディナリティだと条件として指定するクエリの量が多くなります。
これは、その通りで、場合によっては WHERE 句が非常に長くなることも想定されます。
-- ✅ パーティションと相性良い(範囲指定)
WHERE date BETWEEN '2025-01-01' AND '2025-01-31'
WHERE year = 2025 AND month = 1
-- ❌ パーティションと相性悪い(特定値)
WHERE user_id = 12345 -- 1つしか該当しない
WHERE product_id IN (1,2,3,4,5) -- 少数
一番大きいのは、メタデータの肥大化によるクエリ速度の低下ですね。
また、管理上も管理しにくいと思うので、パーティションキーの選択は慎重に行いたいですね。
2. Athena でパーティションテーブルの作成
S3 のフォルダ構造をパーティション化してデータを配置したら、次はパーティションテーブルを作成します。
パーティションテーブルはパーティションキーを持つテーブルです。
今回は下記のテーブルを作成しました。
CREATE EXTERNAL TABLE sales_partitioned (
sale_id int,
product_id int,
employee_id int,
amount decimal(10,2),
sale_date date,
region string
)
PARTITIONED BY (
year int,
month int
)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'
WITH SERDEPROPERTIES (
'field.delim' = ',',
'skip.header.line.count' = '1'
)
STORED AS TEXTFILE
LOCATION 's3://athena-sample-data-[あなたのバケット名]/partitioned-sales/'
TBLPROPERTIES (
'projection.enabled' = 'false'
);
以前と同じく、CSV ファイルからデータを読むテーブルですが、以前と違う点は PARTITIONED BY
句でパーティションキーを指定しているところです。
year
と month
をパーティションキーとして指定しています。
CREATE EXTERNAL TABLE で指定している他の句については前回の記事を参照してください。
3. Athena でパーティションテーブルにパーティション情報の追加
パーティションテーブルを作成しただけでは、year
とmonth
のパーティションを持つテーブルが作られたというだけで、具体的にどのようなパーティションがあるかという情報は存在しません。
今回は下記の S3 の構造でパーティション構造を作成しているので、
- year=2023、month=1。
- year=2023、month=2。
- year=2024、month=1。
の3つのパーティションを追加する必要があります。
s3://athena-sample-data-[あなたのバケット名]/partitioned-sales/
├── year=2023/
│ ├── month=01/
│ │ └── sales_2023_01.csv
│ └── month=02/
│ └── sales_2023_02.csv
└── year=2024/
└── month=01/
└── sales_2024_01.csv
それでは、作成したパーティションテーブルにパーティション情報を追加していきます。
-- 2023年1月パーティション追加
ALTER TABLE sales_partitioned ADD PARTITION (year=2023, month=1)
LOCATION 's3://athena-sample-data-[あなたのバケット名]/partitioned-sales/year=2023/month=01/';
-- 2023年2月パーティション追加
ALTER TABLE sales_partitioned ADD PARTITION (year=2023, month=2)
LOCATION 's3://athena-sample-data-[あなたのバケット名]/partitioned-sales/year=2023/month=02/';
-- 2024年1月パーティション追加
ALTER TABLE sales_partitioned ADD PARTITION (year=2024, month=1)
LOCATION 's3://athena-sample-data-[あなたのバケット名]/partitioned-sales/year=2024/month=01/';
構文を見ると、パーティション値と実際の S3 上のパスの紐付けが作成されているのが読み取れます。
year=2024
, month=1
のパーティションはs3://athena-sample-data-[あなたのバケット名]/partitioned-sales/year=2024/month=01/
を参照といった具合ですね。
これで、クエリ時にパーティションを指定すると、該当する S3 の参照先が決定します。
下記コマンドで既存パーティションを自動的に発見することもできます。
MSCK REPAIR TABLE sales_partitioned;
これを実行すると sales_partitioned
テーブル作成時に指定した LOCATION
である s3://athena-sample-data-[あなたのバケット名]/partitioned-sales/
の下を自動的に走査し、上記の ALTER で追加したパーティションを勝手に追加してくれます。
追加されたパーティションは下記のクエリを実行すると閲覧できます。
SHOW PARTITIONS sales_partitioned;
しっかり追加されていることを確認しましょう。
パーティションに対してクエリを実行
ようやく、クエリの発行です。
ここまで、かなり大変な手順を踏んできましたが、クエリは非常にシンプルです。
まず、パーティションなしでクエリを発行してみます。
SELECT COUNT(*), SUM(amount)
FROM sales_partitioned;
次にパーティション指定をしてクエリを発行します。
-- 特定月のみスキャン
SELECT COUNT(*), SUM(amount)
FROM sales_partitioned
WHERE year = 2023 AND month = 1;
パーティションの指定ですが、WHERE 句で year
と month
の条件を指定することで実現できます。
year
と month
両方を指定しなければいけないという制約はありません。year
だけでも、month
だけでも指定できます。
まるで、year
カラムと month
カラムがテーブルデータにあるかのように振る舞います。
(データとして参照している CSV ファイルのヘッダには year
と month
は存在しません。)
スキャンしたデータはパーティション指定前と指定後で0.61KB→0.20KBと削減されました。
これがパーティション化した効果になります。
おまけ
今回は手動でやりましたが、CTAS 文(CREATE TABLE AS SELECT)を使用して、下記を一気に行うこともできるようです。
- S3 フォルダのパーティション構造の作成
- パーティションテーブルの作成
- パーティション情報の登録
下記の CTAS クエリを実行しましたが、CREATE 文実行後、下記が確認できました。
- パーティション情報が登録されている
- パーティションを指定して SELECT クエリが発行できる
- S3 に今回手動で作成したものと同様のフォルダ構造が作成される
ただし、ファイルの形式は gz 形式で圧縮されていました。
format
部分の指定には CSV 形式がなく、やむなく TEXTFILE
を指定しました。
形式が変わってもデータの内容は変わっていないので問題ないでしょう。
元の CSV ファイルはそのまま残っていますし。
CREATE TABLE sales_partitioned_ctas
WITH (
format = 'TEXTFILE',
external_location = 's3://athena-sample-data-[あなたのバケット名]/partitioned-sales-ctas/',
partitioned_by = ARRAY['year', 'month']
)
AS
SELECT * FROM sales_partitioned;
partitioned_by
でパーティションキーを指定しています。
では、パーティションの値はどこから取得するのかというと、SELECT 句のカラム指定の最後にパーティションの値となるカラムを指定する必要があります。
今回は、year
と month
をパーティションキーとして指定しましたので、SELECT 句のカラム指定の最後に year
と month
を指定する必要があります。
ここで、パーティションテーブルにおいてパーティションキー year
と month
はテーブルに存在するカラムのように扱えることを思い出してください。
SELECT * FROM sales_partitioned
とすると、パーティションの値も含めて取得できるのです。
year
と month
が最後尾のカラムとして表示されているのがわかりますね。
値もパーティションの値が設定されています。
このため、SELECT *
とすれば、パーティション値も指定したことになるのです。
sales_partitioned
テーブルがパーティション化されているからできることですね。
普通のテーブルであれば、SELECT *, year([日付カラム名]), month([日付カラム名]) FROM [テーブル名]
のように、最後にパーティション値を指定する必要があります。
まとめ
パーティション化することでデータスキャン量を削減でき、コストも削減できることを学びました。
今回で Athena 編は終了の予定だったのですが、想像以上にパーティションのボリュームがあり、長くなりました...
もう少し、Athena 編が続きそうです。
次回は AWS Glue の Crawler について触れていきたいと思います。
「あれ、AWS Athena じゃなくね!?」と思った方は次回の記事を楽しみに待っていていただければと思います😄
では、また次回👋
Discussion