Azure SQL Managed Instance でデータ仮想化やアーカイブ設計を始めると、必ずと言っていいほどぶつかるのが「CETAS 外部テーブルへの追記をどうするか?」という問題です。本記事では、年別パーティション+UNION ALL ビューという王道パターンを軸に、Parquet ファイルのマージや JSON への切り替え案の是非まで、実運用を想定した具体的な設計例を整理します。
Azure SQL Managed Instance の CETAS とデータ仮想化の前提
まずは前提となる Azure SQL Managed Instance(以下 MI)のデータ仮想化と CETAS の位置づけを整理します。
データ仮想化の2つのアクセスパターン
MI のデータ仮想化では、Azure Data Lake Storage Gen2 や Blob Storage 上の CSV / Parquet ファイルを T-SQL から読み取ることができます。アクセスパターンは大きく次の 2 つです。
- OPENROWSET:アドホックなファイル探索向け(クエリ1本だけで完結)
- CREATE EXTERNAL TABLE:繰り返し使う分析・レポート向け(外部テーブルを作成してから SELECT)
どちらもファイル形式として Parquet と CSV をネイティブにサポートし、JSON は「CSV として 1 行 1 ドキュメントを返し、JSON_VALUE や OPENJSON でパースする」という“間接対応”に留まります。
CETAS(CREATE EXTERNAL TABLE AS SELECT)の役割
CETAS は、MI からストレージへデータをエクスポートしながら、そのファイルを指す外部テーブルを同時に作成する T-SQL 機能です。
- SELECT の結果を Parquet / CSV ファイルとして書き出す
- そのファイル群に紐づく 外部テーブルを自動作成する
- データの保存先は ADLS Gen2 / Blob Storage
セキュリティ上、MI では CETAS は既定で無効化されており、サーバー構成オプション allowPolybaseExport を有効化する必要がある点も押さえておきましょう。
外部テーブルは「読み取り専用」かつ DML 不可
ここが今回のテーマの核心です。MI のデータ仮想化は読み取り専用であり、外部テーブルに対する INSERT / UPDATE / DELETE といった DML はサポートされていません。
| 項目 | ローカル テーブル | 外部テーブル(CET / CETAS) |
|---|---|---|
| データの場所 | MI のデータファイル(データベース内) | ADLS Gen2 / Blob のファイル |
| サポートされる DML | INSERT / UPDATE / DELETE などフルサポート | サポートされない(SELECT のみ) |
| サポートされる DDL | CREATE / ALTER / DROP / INDEX / 制約 etc. | CREATE TABLE / DROP TABLE / CREATE/DROP STATISTICS / CREATE/DROP VIEW のみ |
| バックアップ | データも一緒にバックアップ | メタデータのみバックアップ(ファイル本体は対象外) |
つまり、「CETAS で作った外部テーブルに INSERT で追記する」という発想は設計段階で捨てる必要があります。DML は禁止であり、CETAS はあくまで「書き出し専用の CREATE 文」と考えるのが正解です。
なぜ CETAS 外部テーブルに「追記」できないのか
もう少し踏み込んで、「追記できない理由」と「どう考え方を変えるべきか」を整理します。
DML 非対応という仕様
SQL Server / MI における外部テーブルの仕様として、次のように明記されています。
- 外部テーブルのデータはデータベース外のファイルにある
- そのため バックアップ / リストアはメタデータのみ
- INSERT / UPDATE / DELETE はサポートされない
- 許可される DDL は CREATE/DROP TABLE、CREATE/DROP STATISTICS、CREATE/DROP VIEW のみ
これは MI 固有の制限ではなく、PolyBase ベースの外部テーブル全般に共通する仕様です。「外部テーブルはストレージ上のファイルをテーブルっぽく見せるだけのメタデータ」と割り切る必要があります。
「テーブル中心」ではなく「フォルダ中心」で設計する
通常のテーブル設計では「テーブルに行を追加する」感覚で設計しますが、外部テーブル / CETAS では逆です。
- 主役は ストレージのフォルダ構造
- 外部テーブルは 特定フォルダ(+ワイルドカードパス)をテーブルとして見せるためのビューに近い
- 追記 = 新しいファイル(フォルダ)を増やすこと であり、テーブル自体に行を足すわけではない
したがって「追記したい」ときには、
- 新しいフォルダに CETAS で書き出す
- そのフォルダを指す外部テーブルを追加する
- アプリが参照するビュー側で UNION ALL の定義を更新する
という「フォルダを増やす → テーブルを増やす → ビューを更新する」という 3 段構えで考えるのが自然になります。Microsoft Q&A でも「年ごとに CETAS 外部テーブルを作って UNION ビューでまとめる」という方針が妥当であると示されています。
フォルダ設計のポイント:サブフォルダ再帰読み取りはできない
MI の外部テーブルの LOCATION 指定には重要な制限があります。
- LOCATION にフォルダを指定した場合、そのフォルダ直下のファイルだけが対象
- サブフォルダ配下のファイルは自動では読まれない
- ファイル名が
_や.で始まるものも読み取られない
一方で、データ仮想化のドキュメントでは、Parquet などのファイルについて、ワイルドカードを使って複数フォルダを一度に読む方法も示されています。
SELECT TOP 10 *
FROM OPENROWSET(
BULK 'yellow/puYear=*/puMonth=*/*.parquet',
DATA_SOURCE = 'NYCTaxiExternalDataSource',
FORMAT = 'parquet'
) AS filerows;
つまり MI は
- 暗黙の「再帰走査」はしない(LOCATION = ‘yellow/’ では配下の年・月フォルダは読んでくれない)
- その代わり パス中にワイルドカードを明示すれば、そのレベルに存在する複数フォルダをまとめて読める
という挙動になっています。このため、「年別・月別などのパーティション単位でフォルダを区切る」設計は、性能だけでなく MI の仕様とも相性が良いと言えます。
設計パターンA:年別 CETAS 外部テーブル + UNION ALL ビュー
質問に挙がっていた
CETAS_2023_CustTabCETAS_2024_CustTab
のようなテーブルを作成し、それらを UNION ALL で束ねたビューをアプリ側から参照するパターンは、MI の公式 Q&A でも推奨されている「王道パターン」です。
命名規則とフォルダ構造の例
| 対象年 | フォルダパス例(ADLS 側) | 外部テーブル名 | 備考 |
|---|---|---|---|
| 2023 年分 | /archive/CustTab/y=2023/ | ext_CustTab_y2023 | CETAS で新規出力 |
| 2024 年分 | /archive/CustTab/y=2024/ | ext_CustTab_y2024 | CETAS で新規出力 |
| ビュー | ― | v_CustTab_Archive | 上記を UNION ALL で集約 |
ポイントは次の 2 つです。
- CETAS の LOCATION は毎回ユニークなフォルダ(既存フォルダに上書きしない)
- 外部テーブル名にも年情報を含めておき、あとで動的に列挙しやすくする
年別 CETAS 作成の T-SQL 例
前提として、Parquet を指す外部ファイル形式と、アーカイブ用の外部データソースが作成済みとします。
-- 前提:データ仮想化と CETAS が有効化済み
-- 例:
-- CREATE EXTERNAL DATA SOURCE ArchiveDS ...
-- CREATE EXTERNAL FILE FORMAT FF_Parquet WITH (FORMAT_TYPE = PARQUET);
-- 2024 年分を Parquet に書き出しつつ外部テーブルを作成
CREATE EXTERNAL TABLE archive.ext_CustTab_y2024
WITH (
LOCATION = 'archive/CustTab/y=2024/', -- フォルダごとに一意
DATA_SOURCE = ArchiveDS,
FILE_FORMAT = FF_Parquet
)
AS
SELECT *
FROM dbo.CustTab
WHERE ArchiveYear = 2024;
CETAS 実行時に LOCATION 配下にファイルがすでに存在している場合、「External table location already exists」 系のエラーになるため、必ず空フォルダ(もしくは新しいサブフォルダ)を指定するのが安全です。
UNION ALL ビューを動的に再生成する
年が増えるたびに手作業でビューを ALTER していては運用が回らないので、年別外部テーブルを列挙して自動的に UNION 文を生成するのが定石です。
DECLARE @sql nvarchar(max);
SELECT @sql =
(
SELECT STRING_AGG(
CONVERT(nvarchar(max),
'SELECT * FROM '
+ QUOTENAME(s.name) + '.' + QUOTENAME(t.name)
),
' UNION ALL '
)
FROM sys.external_tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE t.name LIKE 'ext_CustTab_y%' -- 命名規則に合わせて調整
);
SET @sql = N'
CREATE OR ALTER VIEW archive.v_CustTab_Archive
AS
' + @sql + ';';
EXEC sys.sp_executesql @sql;
このスクリプトを SQL Agent ジョブやAzure Data Factory の Stored Procedure アクティビティから呼び出せば、年ごとの CETAS を追加するたびにビューが自動で更新されます。
月別パーティションにするか?年別のままにするか?
アーカイブ対象のテーブルサイズによって、年単位か月単位かを決めます。
| パーティション単位 | メリット | デメリット | 向いているケース |
|---|---|---|---|
| 年別 | 外部テーブル数が少なく、ビュー生成がシンプル | 1 フォルダあたりのファイル数が多くなりがち | 1 年あたりの増分が数千万行以内 |
| 月別 | フォルダ・ファイル数を分散でき、クエリが高速になりやすい | 外部テーブル数が 12 倍になり、ビュー生成ロジックがやや複雑 | 高頻度でデータが増え、期間絞りのクエリが多い |
最初は年別で始め、ファイル数やクエリ時間が許容範囲を超え始めた段階で月別に切り替える、という段階的なアプローチも現実的です。
設計パターンB:OPENROWSET / 外部テーブル+ワイルドカードで束ねる
年別 CETAS + UNION ALL ビューが「テーブルを増やしてビューで束ねる」設計だとすると、もう一つの考え方は「ファイルをワイルドカードで束ねて 1 テーブルとして見せる」パターンです。
OPENROWSET をビューでラップする
MI の OPENROWSET は、前述のとおりワイルドカードを使った複数フォルダ読み取りをサポートしています。
CREATE VIEW archive.v_CustTab_Archive
AS
SELECT *
FROM OPENROWSET(
BULK 'archive/CustTab/y=*/m=*/*.parquet',
DATA_SOURCE = 'ArchiveDS',
FORMAT = 'parquet'
) AS r;
この方式は、
- CETAS 自体は「フォルダ単位でファイルを吐き出す」だけに使い
- アプリからは常に VIEW(v_CustTab_Archive)のみを参照
- 新しい年・月のフォルダが追加されても、VIEW 定義を変えずに自動で参照範囲が広がる
というメリットがあります。一方で次のような注意点もあります。
- 外部テーブルと比べて統計情報の管理方法が変わる(
sp_create_openrowset_statisticsの活用が必要) - 列定義を固定したい場合は
WITH ( ... )句を記述する手間がある
「とにかく DWH やレポート側から T-SQL で読めればよい」「ビューを 1 本に保ちたい」というニーズなら、このパターンも十分現実的です。
ワイルドカード付き LOCATION の外部テーブルを定義する
OPENROWSET ではなく外部テーブルを使いたい場合も、MI では LOCATION にワイルドカード付きのパスを指定することができます。
CREATE EXTERNAL FILE FORMAT FF_Parquet
WITH (FORMAT_TYPE = PARQUET);
CREATE EXTERNAL TABLE archive.ext_CustTab_All (
-- 列定義は Parquet に合わせる
CustId int,
CustName nvarchar(100),
ArchiveYear int,
ArchiveMonth int
)
WITH (
LOCATION = 'archive/CustTab/y=*/m=*/*.parquet',
DATA_SOURCE = ArchiveDS,
FILE_FORMAT = FF_Parquet
);
この方法なら、外部テーブルは 1 つだけで済み、CETAS は単に y=2024/m=10 のようなサブフォルダに向けて書き出すだけでよくなります。ただし MI の仕様上、フォルダを再帰的には辿らないため、パスの構造とワイルドカードの位置は慎重に設計する必要があります。
Parquet か JSON か:フォーマット選定の現実解
質問のもう一つの論点が、「Parquet ファイルのマージが難しそうなので JSON に切り替えて PowerShell で結合したい」というアイデアでした。ここでは Parquet / JSON の長所短所を整理し、どちらを選ぶべきかを考えます。
MI が正式にサポートするファイル形式
MI のデータ仮想化でネイティブにサポートされているファイル形式は次の通りです。
- Parquet
- Delimited Text(CSV)
JSON については、
- ファイル形式として JSON を直接指定することはできない
- CSV 形式として 1 行 1 ドキュメントを返し、
JSON_VALUEやOPENJSONでパースする“間接対応”
また、外部テーブルの列定義で json 型はサポートされておらず、JSON を扱う場合も nvarchar(max) 等に格納する必要があります。
CETAS が書き出せるファイル形式
MI の CETAS では、データ仮想化のドキュメントにある通りParquet または CSV にエクスポートすることが想定されています。JSON に直接書き出すオプションはありません。
つまり「CETAS → JSON ファイル → PowerShell でマージ」というパターンを取る場合、
- いったん CSV / Parquet に吐き出したものを別途 JSON に変換する
- PowerShell 側で JSON <-> Parquet / CSV の変換ロジックを維持し続ける
といった余計な工程と運用コストが増えてしまいます。
Parquet と JSON の比較
| 観点 | Parquet | JSON |
|---|---|---|
| ファイルサイズ | 列指向+圧縮で小さい | テキストベースで大きくなりがち |
| 型情報 | スキーマに基づく厳密な型 | ランタイムでパースが必要 |
| MI からのサポート | ネイティブサポート+スキーマ推測あり(Parquet) | CSV として 1 行 1 ドキュメント扱い(間接対応) |
| CETAS との相性 | ◎(推奨) | △(直接出力できず変換が必要) |
| PowerShell での扱いやすさ | 専用ライブラリが必要 | テキストとして扱えるが大規模データは非現実的 |
結論として、MI+CETAS でのアーカイブ用途では Parquet 継続が圧倒的に現実的です。JSON 側でマージしやすく見えても、スキーマ管理やパフォーマンス、ストレージコストを考えると総合的にデメリットが大きくなります。
Parquet ファイルのマージ/コンパクション戦略
残る課題は「Parquet ファイルをどうマージするか?」です。実際には、CETAS 自体が「CPU コア数に応じて複数ファイルを吐き、1 ファイルは最大 190 GB まで」という仕様のため、極端に細切れなファイルを大量に作ることはあまりありません。
それでも、日次 CTEAS を長期間続けると「中途半端なサイズのファイルが大量に溜まる」ことは避けられません。この“small file 問題”に対しては、Azure Data Factory(ADF)または Spark(Synapse / Databricks)でのコンパクションが定石です。
いつ Parquet をマージすべきか
次のような状態になってきたら、マージ(コンパクション)を検討します。
- 1 年フォルダ内に数万ファイル以上ある
- 1 ファイルあたり数 MB 程度しかないものが多数
- 外部テーブルに対するクエリ時間が目に見えて悪化している
一方で、各ファイルが数百 MB ~ 数 GB 程度であれば、そのままでも十分実用的なケースが多く、過度なマージは不要です。
アプローチ1:Azure Data Factory の Copy Activity「Merge files」
Parquet ファイルのマージだけであれば、ADF の Copy Activity で Copy behavior を「Merge files」に指定するのが最も簡単です。
- Source:ADLS Gen2 上の
/archive/CustTab/y=2024/を指すデータセット(フォルダ単位) - Sink:ADLS Gen2 上の
/archive/CustTab/compact/y=2024/を指すデータセット(出力先フォルダ) - Copy Activity 設定で
- File format:Parquet
- Destination – Copy behavior:Merge files を選択
この設定により、Source フォルダ内の Parquet ファイルが 1 ファイルにマージ(または指定したファイル名でマージ)されます。
| 項目 | 設定例 |
|---|---|
| Source フォルダ | /archive/CustTab/y=2024/ |
| Sink フォルダ | /archive/CustTab/compact/y=2024/ |
| Copy behavior | Merge files |
| トリガー | 日次/週次/CETAS 完了後など任意 |
そのうえで、MI 側の外部テーブルを「compact 側のフォルダ」を指すようにしておけば、アプリケーションからは常に最適化された Parquet を読むことができます。
アプローチ2:Spark(Synapse / Databricks)でコンパクション
より柔軟に「何ファイルにまとめるか」「どのパーティションだけを最適化するか」などを制御したい場合は、Spark を使ったコンパクションが向いています。
通常の Parquet を Spark でコンパクションする例
val df = spark.read.parquet("abfss://archive@<account>.dfs.core.windows.net/CustTab/y=2024/")
// 10 ファイル程度にまとめる例
df.repartition(10)
.write
.mode("overwrite")
.parquet("abfss://archive@<account>.dfs.core.windows.net/CustTab/compact/y=2024/")
このパターンでは、
- 元の
y=2024/は「raw」 compact/y=2024/を「curated」
のようにレイヤー分けしておき、外部テーブルは curated レイヤーのみを指すのが一般的です。
Delta Lake を使っている場合は OPTIMIZE がベスト
もしアーカイブ先を Delta テーブルとして管理しているなら、Spark / Azure Databricks 上で OPTIMIZE コマンドを使うのが最もシンプルです。
OPTIMIZE dbo.CustTab_Delta; -- SQL
-- PySpark 例
from delta.tables import DeltaTable
deltaTable = DeltaTable.forPath(spark, "abfss://.../custtab_delta")
deltaTable.optimize().executeCompaction()
Delta の OPTIMIZE は ACID トランザクションのもとで小さなファイルを安全に集約してくれるため、「読み取り中にファイルを消してしまう」といった事故を避けることができます。
PowerShell で JSON マージ、は現実的か?
PowerShell で JSON の配列を結合するスクリプトを書くのは確かに簡単ですが、MI アーカイブ用途という観点では次の理由からあまり現実的ではありません。
- JSON 化の前後で Parquet ↔ JSON ↔ Parquet の変換ロジックが必要になる
- PowerShell スクリプトはスキーマ変更への耐性が低く、大規模データでは性能も不安
- ADF や Spark が Parquet の読み書きを完全にサポートしており、わざわざ独自実装に寄せる理由が薄い
そのため、「マージのためだけに JSON に寄せる」のではなく、Parquet のまま ADF か Spark でコンパクションする方向で設計するのがおすすめです。
全体アーキテクチャの一例
ここまでの内容を踏まえ、MI でのアーカイブ~参照までを一気通貫で整理してみます。
処理フロー(年別 CETAS+コンパクション+ビュー)
- MI でアーカイブ対象行を抽出して CETAS
- LOCATION:
archive/CustTab/raw/y=2024/ - 外部テーブル:
ext_CustTab_y2024_raw
- LOCATION:
- ADF or Spark で Parquet をコンパクション
- Source:
raw/y=2024/ - Sink:
curated/y=2024/(Merge files / repartition)
- Source:
- MI に curated 用の外部テーブルを作成
ext_CustTab_y2024→ LOCATION:curated/y=2024/
- UNION ALL ビューを再生成
sys.external_tablesからext_CustTab_y%を列挙してv_CustTab_Archiveを CREATE OR ALTER
- アプリケーションは常にビューだけを見る
- SELECT * FROM
archive.v_CustTab_Archive
- SELECT * FROM
このように、「CETAS → コンパクション → curated 外部テーブル → UNION ビュー」という 4 つのレイヤーを組み合わせることで、
- MI の DML 不可という制約を回避しつつ
- Parquet の 性能と圧縮効率を活かし
- アプリケーションからは「1 つの論理テーブル」として見える
というバランスの良いアーキテクチャを構成できます。
スキーマ変更と外部テーブルの制約に注意
最後に、運用上ハマりやすいポイントをいくつか挙げておきます。
外部テーブルの非対応データ型
外部テーブルの列には、一部のデータ型を使用できません。
- geography / geometry / hierarchyid
- image / text / ntext
- xml / json
- ユーザー定義型
これらの列をアーカイブしたい場合は、あらかじめ nvarchar(max) や varbinary(max) に変換しておく必要があります。
スキーマ変更は「後方互換」に揃える
MI の外部テーブルは、ファイル側のスキーマとテーブル定義が合っていないと行が拒否されます。
- 列数のズレ
- データ型の不一致
などがあると SELECT でエラーになったり、行が丸ごと読み飛ばされたりするため、アーカイブ対象テーブルのスキーマ変更は「新しい列を追加する」「型をより広い型に広げる」といった後方互換な変更に抑えるのが安心です。
まとめ:CETAS の「追記」はフォルダ+ビューで設計する
- MI のデータ仮想化と外部テーブルは読み取り専用であり、DML(INSERT/UPDATE/DELETE)は一切サポートされない。
- そのため、「CETAS 外部テーブルに追記する」のではなく、
- 年/月ごとに新しいフォルダに CETAS で書き出し
- そのフォルダを指す外部テーブルを作成
- アプリは UNION ALL ビュー(またはワイルドカード付き外部テーブル/OPENROWSET ビュー)を参照
- フォーマットについては、MI が正式にサポートする Parquet / CSV を使うのがベストで、JSON は間接対応かつ運用コストが高くなるため、アーカイブ用途では非推奨。
- Parquet ファイルのマージ・コンパクションには、
- ADF Copy Activity の Merge files
- Spark(Delta Lake の
OPTIMIZEを含む)
「CETAS 外部テーブルへの追記」という一見シンプルな要件も、MI の仕様を踏まえてフォルダ構造・外部テーブル・ビュー・データパイプラインまで一体で設計することで、長期運用に耐えるアーカイブ基盤として整理できます。本記事のパターンをベースに、自身のシステム規模や既存の Azure リソース(ADF / Synapse / Databricks)に合わせてチューニングしてみてください。

コメント