Partition ProjectionでAthenaのコストを80%削減した話

本記事は[AWS Advent Calendar 2021](https://qiita.com/advent-calendar/2021/aws)、カレンダー2の12日目の記事です。

# 知見（この記事で書いてあること）
- Athenaのデータ集計のランニングコストを下げる方法
- 日付パーティションが効果がない集計を行うときの頑張り
- Partition Projectionの適用例

AthenaをMSCK REPAIR TABLEしながら利用されている方、今後Athenaを利用する可能性のある方にお得な内容となっております。


# 背景
およそ1年前くらいからAthenaの活用を進めています。

とてもありがたいことに、利用がとても増えまして、、、、、、金額も増えてきました。
また、利用が増えるものについては速度も速いほうが良いですよね。

Athenaのパフォーマンス改善といえば公式の [Amazon Athena のパフォーマンスチューニング Tips トップ 10](https://aws.amazon.com/jp/blogs/news/top-10-performance-tuning-tips-for-amazon-athena/) が思い浮かびます。何回か熟読しておりSQLレベルで出来ることは可能な範囲で限界まで行っておりますが、あらためて熟読すると以下のトピックが目にとまり、今回のケースに適合する内容だったので試すことにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68734/4d271079-3c10-1570-9719-c6468089c820.png)

（おまけ・・・？）


# Partition Projectionとは
パーティション射影と訳されるのですが、ようは

- MSCK REPAIR TABLEが不要となる

この1点だけでも、魅力的と感じる人は多いのではないでしょうか。
管理面もさることながらパーティションの数が増えたりパーティションが複数層になるとMSCK REPAIR TABLEも時間がかかる可能性もあります。
MSCK REPAIR TABLEが不要ということは、すなわちパーティションを安心して沢山増やせる、ということです。しかもクエリも速くなるのであれば良いところだらけに思えます。


参考：[Amazon Athena でのパーティション射影](https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection.html)


# 扱うデータセット
前提として以下のようなデータを扱っています

- あれこれ合計1000億レコード(※)くらいが集計対象となり得る
- 日付は保持している
- 必ずwhere句で入る条件がある

※レコード数は適当で少々ぼかしておりますが、そこまで少ない量でも、IoTで扱うような甚大な量でもない、というニュアンスです。

日付は保持しているので /yyyy=2021/mm=12/dd=12/ といった形でパーティションを作成しても良いのですが「1年分まとめて集計」ということが頻繁に発生するため、ほぼ意味を成しません。
一方で必ず指定される項目があり、その項目をもとにパーティションを作成すると良い感じにデータが分散されてスキャン量が減る、といった構成となっています。

# 以前のパーティション構成
Parquet形式で、かつパーティション分割を行うためにAthenaのCTAS機能を用いて作成をしていました。しかしここには一度に100個までしかパーティションを作れないという制約がありました。
参考：[CTAS および INSERT INTO を使用した、100 個を超えるパーティションを持つテーブルの作成](https://docs.aws.amazon.com/ja_jp/athena/latest/ug/ctas-insert-into.html)

この制約からデータ作成の運用を考え、以前のパーティション構成は
**「100個ずつ、頑張って10回くらいCTASで1000パーティションに分ける」**
といった方法をとっていました。

スキャン対象が1000個に分割されるので、1年前の時点では性能面でもコスト面でも「なるほどこういう感じか」といった許容範囲になっていました。

なお10回（1000個）である理由は、

- CTAS自体で費用が発生するため
- INSERTに失敗したときのリカバリがセットになりつらい（[参考](https://qiita.com/yuji_saito/items/20a18abc45a38ac48ac2)）
- そこそこ時間がかかる

というもので、10回程度が限界かなーという感覚値です。

# 乗り越えた課題
さて、Partition Projectionを試す、ということは今回の場合「パーティション数をもっと増やす」ということになります。
つまりAthenaのCTAS以外の手段でParquet変換とパーティション作成を行う必要があります。
まずは、この手段を探しました。

- Glue + PySpark
- Redshift のUNLOAD機能

まずは王道かと思うので前者を試しました。
シンプルな例では問題なく動いたのですが、私のスキル不足により断念しました。原因がどこにあるか分からないので詳細は省きますが、途中で「これはかなり深く理解してないとまずいな」と思える事象に遭遇したためです。
並行して調査していたところ、RedshiftのUNLOAD機能を用いるとパーティションの数に制限なく作成できることが分かり、速度も現実的なラインであったためこちらを採用しました。チーム内にRedshiftが基盤として別に存在するため、そこを間借りできるという幸運も大きかったです。

# 新たなパーティション構成（1,000⇒60,000）
パーティション数は無数に作成できることが分かりました。
ここまでやれば大丈夫だろう、という数を考え今回60,000個のパーティションに分けようと考えました。

よしPartitionをProjectionしよう、と思ったところ、以下の制約がありました。

> テーブルには、enum、integer、date,、または injected パーティション列の型の任意の組み合わせを使用できます。

参考：[パーティション射影のサポートされている型](https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection-supported-types.html)

今回where句で指定される特定の文字列をベースにするので[Injection](https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection-dynamic-id-partitioning.html#partition-projection-injection)で解決するかなと思ったのですが、集計の際にパーティションを横断して集計することがあります。そうするとこの機構は適合しないかなと思い別の方法を考えることとしました。

結論、数値型であれば問題なさそうなので、文字列を一定ルールで数値に変換し振り分けます。

そのパーティションが shardprefix という名称とすると、以下のように定義可能です。

```
CREATE EXTERNAL TABLE `xxxxxxxx`(
略
PARTITIONED BY ( 
  `shardprefix` int)
ROW FORMAT SERDE 
  'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe' 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat' 
OUTPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat'
LOCATION
  's3://xxxxxxx/xxxx/'
TBLPROPERTIES (
  'projection.enabled'='true', 
  'projection.shardprefix.interval'='1', 
  'projection.shardprefix.range'='1,60000', 
  'projection.shardprefix.type'='integer', 
  'transient_lastDdlTime'='1635479450')
```

`'projection.enabled'='true',` という定義によりPartition Projectionが有効になります。
シンプルな条件で定義しておりintervalも1ですが、これらのオプションで色々工夫ができるようです。
rangeに存在しないパーティションができた場合は無視されてしまうことがデメリットのように書かれていますが、そんなことはなかなか起きないのではないかと思います...


# 余談 日付パーティションも加える場合

指定方法のうち日付型が非常に便利です。今回実は諸々の都合で 

> /shardprefix=xxxxxxxxx/yyyymm=202112

といった形式にデータを置いており、パーティションを2層でPartition Projectionすることもできます。
例えば以下のような形で、日付型も設定可能です。

```
PARTITIONED BY ( 
  `shardprefix` int, 
  `yyyymm` int)
ROW FORMAT SERDE 
  'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe' 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat' 
OUTPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat'
LOCATION
  's3://xxxxxxx/xxxx/'
TBLPROPERTIES (
  'projection.enabled'='true', 
  'projection.shardprefix.interval'='1', 
  'projection.shardprefix.range'='1,60000', 
  'projection.shardprefix.type'='integer', 
  'projection.yyyymm.format'='yyyyMM', 
  'projection.yyyymm.interval'='1', 
  'projection.yyyymm.interval.unit'='MONTHS', 
  'projection.yyyymm.range'='201801,NOW+1MONTH', 
  'projection.yyyymm.type'='date', 
  'transient_lastDdlTime'='1636975852')
```

パーティションがyyyymm形式で構成されても `.format'='yyyyMM'` によりYYYYMM形式として読み取ってくれて、 `interval.unit'='MONTHS'` で1か月ずつ増えてゆき、 `.range'='201801,NOW+1MONTH'` でレンジの最大が現在月+1か月後、といった非常にかゆいところに手の届く指定が可能となっています。
なおYYYYMMをintegerとしても 201801～210000みたいな指定をしても動きますが、201814といった存在しない数値も対象としてしまい、一方で日付型は存在する日付のみを計算対象とするため上記のような日付型にしたほうが良いようです。

どう考えてもこちらのように2層にパーティションを設定したほうが良いように思えます。
実際、集計したときのスキャン量はこちらの構成のほうが減り、金額面では優しくなります。

しかしながら今回私が使ったデータ構成においてはですが、2つパーティションを指定するとクエリの集計速度が遅くなるといった事象がみられました。ニアリアルタイムで集計をしたいという要件があるため、この構成は泣きながら見送ることとしました。
夜間のバッチ処理だったり、インタラクティブではない処理であればこちらの構成のほうが圧倒的にお勧めです。


# Partition Projectionに変えた結果...

以上で、パーティション構成を組み替え、Partition Projectionを適用することができました。

どきどきしながら適用すると、結果として、コスト（スキャン量）が80％削減という絶大な効果が得られました。大きいデータ量に対して、パーティションの効率を単純計算で60倍にできているので納得の数値です。

集計速度についても、集計方法で差はあるものの全体的に速度が改善され、条件指定が超複雑なものについては数倍レベルでの改善もありました。

またデータセット作成時のCTASがなくなったことを含め、Partition Projectionを利用することを前提とできるのでデータセット作成についてもリファクタリングを行うことができ、ほぼほぼRedshiftのELTで行うことができるので、かなり処理がシンプルになりました。

この手の話はケースバイケースなので、ご自身のインフラ環境やデータセットで全く変わってくるのですが、少なくないケースでPartition Projectionは全力でお勧めできます。


# 感想
Athenaの聖闘士として、そろそろシルバー聖闘士くらいにはなれたかもしれません。