TiDB CloudのデータをTiDB Cloud Lakeへ移してみた
こんにちは、ゲームソリューション部のsoraです。
今回は、TiDB CloudのクラスタにあるデータをTiDB Cloud Lakeへ移してみたことについて書いていきます。
TiDB Cloudにダミーデータを入れる
TiDB Cloud Starterのクラスタを1つ作り、同じデータベースにappdb.ordersとappdb.app_logsの2つのテーブルを置きました。
app_logsの500,000行だけをLakeへ移して、ordersの50,000行はTiDBに残します。
app_logsの定義は以下です。
CREATE TABLE appdb.app_logs (
log_id BIGINT PRIMARY KEY,
level VARCHAR(16) NOT NULL,
service VARCHAR(64) NOT NULL,
message TEXT,
attributes JSON,
logged_at DATETIME NOT NULL,
KEY idx_logged_at (logged_at)
);
データはLOAD DATA LOCAL INFILEで入れました。
app_logsの500,000行で約54秒、投入後のRow-based Storageはorders込みのクラスタ全体で199.24 MiBです。
TiDB CloudからS3へエクスポートする
TiDB Cloud Lakeへの取り込みはS3を経由します。
TiDB CloudからS3へファイルを出して、それをLake側に読ませる、という流れです。
エクスポートにはDumplingを使いました。
TiDB Cloudのコンソールにもエクスポート機能があるのですが、そちらで出したファイルは取り込めませんでした。
その話は後半に書きます。
Dumplingそのものの使い方は、こちらの記事にて紹介されています。
実行したコマンドがこちらです。
tiup dumpling \
-h gateway01.ap-northeast-1.prod.aws.tidbcloud.com -P 4000 \
-u '<prefix>.root' -p '<パスワード>' \
--ca /etc/ssl/cert.pem \
--filter 'appdb.app_logs' \
--filetype csv \
--escape-backslash=false \
--csv-output-dialect snowflake \
--csv-line-terminator $'\n' \
-c no-compression \
-o 's3://<バケット名>/tidb-cloud-lake/dumpling/' \
--s3.region ap-northeast-1
--filterでテーブルを絞れるので、app_logsだけが出てordersは出ません。
-oにS3のURIをそのまま書けるため、ローカルに落とす必要もありませんでした。
そして、後ろの4つがLakeに取り込ませるための条件です。
| フラグ | 無いとどうなるか |
|---|---|
--filetype csv |
デフォルトはsql。Parquetで出したファイルは取り込まれなかった |
--escape-backslash=false |
デフォルトのtrueで\"になり、列数が合わずFailedする |
--csv-line-terminator $'\n' |
デフォルトの\r\nで出て、取り込み側の\nと合わない |
-c no-compression |
デフォルトは無圧縮だが、圧縮するとファイル自体を認識しない |
--csv-output-dialect snowflakeも付けていますが、これはクォートの話ではありませんでした。
公式の説明では、バイナリ型を16進表記にする(0xプレフィックスを除去する)オプションです。
今回のテーブルにバイナリ列は無いので、実質効果があったのは--escape-backslash=falseだけです。
500,000行・140.1MBのダンプに1分57秒かかり、4ファイルが出ました。
$ aws s3 ls s3://<バケット名>/tidb-cloud-lake/dumpling/ --recursive --human-readable
133 Bytes tidb-cloud-lake/dumpling/appdb-schema-create.sql
442 Bytes tidb-cloud-lake/dumpling/appdb.app_logs-schema.sql
133.6 MiB tidb-cloud-lake/dumpling/appdb.app_logs.000000000.csv
146 Bytes tidb-cloud-lake/dumpling/metadata
データ本体のほかにCREATE DATABASE文とCREATE TABLE文が出ています。
これがあとでLake側のテーブル自動作成に使われます。
CSVの中身はこうなりました。
"log_id","level","service","message","attributes","logged_at"
1,"DEBUG","search-api","span exported (req=204b5f50)","{""az"": ""ap-northeast-1a"", ""customer_id"": 3890, ""duration_ms"": 46.5, ""http"": {""method"": ""GET"", ""path"": ""/v1/inventory"", ""status"": 200}, ""trace_id"": ""11398b037a11a01e""}","2026-07-25 10:34:50"
--escape-backslash=falseにすると"が""の2個重ねになります。
この挙動は公式ドキュメントに明記が見つけられなかったのですが、実測で確認できました。
ちなみに、TiDB Cloud Starterに対してDumplingを流すと、information_schema.cluster_info・mysql.tidb・information_schema.placement_policiesへの権限エラーが3つ出ます。
Starterがシステムテーブルへのアクセスを制限しているためで、3つとも警告止まりでダンプ自体は成功します。
TiDB Cloud Lakeにデータソースを登録する
ここからはTiDB Cloud Lake側のコンソールでの作業です。
Data > Data Sources > CreateのServiceにTiDBという選択肢があるので、これを選びます。

開いてみると、入力欄にホスト名もユーザー名もパスワードもありません。
並んでいたのはS3バケットとRole ARNでした。
Storage Providerの下には、この一文があります。
Where TiCDC / Dumpling stages the data that Databend loads.
つまりTiDBデータソースは、TiDBクラスタへ接続するためのものではなく、TiCDCやDumplingがS3に吐いたファイルの置き場を指すものでした。
LakeはTiDBを見ません。
TiDBが出力したファイルが配置されているS3を見ます。
Authentication MethodのデフォルトはRole ARNで、アクセスキーを持たせずにIAMロールで繋げました。
なお、公式ドキュメントのData Source TypesにTiDBのページはありません。
掲載されているのはAmazon S3・Amazon SQS(S3)・MySQL・PostgreSQL・FeiShuBot・Kafkaの6種類だけで、TiDBはコンソールにしか存在しない選択肢でした。

TiDB Cloud LakeのIntegration Taskで取り込む
TiDB Cloud LakeのコンソールのData > Data Integrationsでタスクを作ります。

Table Rulesにappdb.app_logsとだけ書けば、そのテーブルだけが対象になります。

Sync Modeは3択でした。

Snapshot / CDC / Snapshot + CDCで、Snapshotに置かれるのがDumplingの出力、CDCに置かれるのがTiCDCの変更ログ、という対応です。
今回はSnapshotモードで進めます。
Preview Matched Tablesを押すと、作る前にマッチ結果を確認できます。

appdb.app_logsだけがマッチし、ordersは含まれていません。
このボタンはS3を実際にスキャンして-schema.sqlを探しているので、タスクを作らずWarehouseも起動させずにプレフィックスと出力形式の当たりを確認できます。
詳細画面のSync Configurationには、作成フォームに無かった項目が3つ出ていました。

Allow Delete: Yes / Poll Interval: 10 s / Merge Interval: 3 sの3つで、デフォルトで10秒ごとにS3を見に行くようになっています。
作成しただけでは動かないので、Startを押します。

Rows Syncedが500,000になりました。
Auto Create TableがデフォルトでYesなので、Lake側にテーブルも自動で作られています。

500,000行で18.5MB、EngineはFUSEです。
TiDB Cloud Lakeで取り込んだデータを確認する
TiDB Cloud LakeのWorksheetで検証クエリを流して、TiDB Cloud側で取っておいた期待値と突き合わせます。

SELECT COUNT(*) AS cnt, SUM(log_id) AS sum_id FROM appdb.app_logs;
COUNT(*)が500,000、SUM(log_id)が125,000,250,000で、どちらもTiDB Cloud側と一致しました。
level別の内訳もHTTPステータス別の集計も同じ値です。
チェックサムが合っているので、欠けも重複もなく入っています。
jsonはVARIANTになる
TiDB Cloud Lake側に作られたテーブル定義です。

CREATE TABLE app_logs (
log_id BIGINT NULL,
level VARCHAR NOT NULL,
service VARCHAR NOT NULL,
message VARCHAR NULL,
attributes VARIANT NOT NULL,
logged_at TIMESTAMP NOT NULL
) ENGINE=FUSE
| 列 | TiDB Cloud側 | Lake側 | |
|---|---|---|---|
level |
varchar(16) |
VARCHAR |
長さ指定が落ちる |
message |
text |
VARCHAR |
TEXTとVARCHARの区別が無い |
attributes |
json |
VARIANT |
狙いどおり |
logged_at |
datetime |
TIMESTAMP |
jsonがVARIANTになったので、ネストしたJSONをそのまま辿れます。

SELECT attributes['http']['status'] AS status, COUNT(*) AS cnt
FROM appdb.app_logs GROUP BY status ORDER BY status;
200が356,519、429が71,939、500が71,542で、これもTiDB Cloud側と一致しました。
ただし書き方は変わります。
TiDB Cloud側がattributes->>'$.http.status'なのに対して、Lake側はattributes['http']['status']です。
同じ結果が出ますが、JSON列を触っているSQLはそのままでは移植できません。
主キーとセカンダリインデックスが消えるのは分析用ストアとして妥当だと思いますが、log_idはNOT NULLだったのにNULLになり、attributesは逆にNULL許容だったのにNOT NULLになっています。
-schema.sqlのDDLをそのまま反映しているわけではなく実データから推定しているのではないかと思われますが、そこは確認できていません。
ストレージは約1/11になった
| サイズ | |
|---|---|
| TiDB Cloud側のRow-based Storage | 199.24 MiB |
| Dumplingが出したCSV | 133.6 MiB |
| Lake側のテーブル(FUSE) | 18.5MB |
TiDB Cloud側の199.24 MiBはordersを含むクラスタ全体の値ですが、ほとんどがapp_logsの分です。
Lake側には主キーもセカンダリインデックスも引き継がれないので、その分が大きそうです。
試行錯誤: TiDB Cloudのエクスポート機能では取り込めなかった
ここからは本筋から外れますが、Dumplingを持ち出すまでの記録です。
TiDB Cloudのコンソールにもエクスポート機能があり、最初はそちらで出したファイルを取り込もうとしていました。

画面としてはこちらの方が楽です。
Exported Dataのツリーからテーブル単位でチェックできるので、app_logsだけを選んでordersを外せます。
認証もRole ARNで、CloudFormationのリンクからロールを作れます。
形式はSQL / CSV / Parquet、圧縮はZstd / Gzip / Snappy / Noneから選べます。
ParquetとCSVをそれぞれ圧縮あり・なしで試しましたが、どれも取り込めませんでした。
Parquetでは取り込まれなかった
まずParquetで出してタスクを実行しました。

StatusはSuccess、Last Messageは空です。
ところがRows Syncedが0、Data Syncedが0 B、Chunksが0/0でした。
S3には26.4 MBのParquetが置いてあるのに、1行も取り込まれていません。
エラーではなく、「対象ファイルが0件だった」という結果として成功扱いになっています。
ここで大事なのは、取り込まれたかどうかはStatusではなくRows Syncedを見ないと分からないということです。
定期実行させてSuccessだけを監視していると、実は何も入っていないことに気づけません。
切り分けのためTiDB Cloud Lake側のData > Databasesを見ると、テーブルだけは作られていました。

Rowsが0、Bytesが0Bです。
-schema.sqlは読めていて、データファイルだけが対象から外れています。
圧縮を外しても結果は同じでした。
ファイル名に.zstが入るのが原因ではなさそうです。
チャンクとして認識されたのはCSVのときだけだったので、SnapshotタスクがParquetを対象にしていないように見えます。
実際、作成フォームにはファイル形式を選ぶ欄が無く、CSV Separator / Skip Header Rows / Export Escaped BackslashesとCSV前提の設定ばかりが並んでいます。
なお、同じLakeでもAmazon S3 Integration TaskはCSV / Parquet / NDJSONに対応しています。
TiDBタスクで通ったのはCSVだけでした。
圧縮するとスキーマファイルまで.zstになる
CSVで出し直します。
CompressionをZstdのままにしたところ、今度はPreview Matched Tablesの段階で弾かれました。
These rules matched no table under the S3 prefix.
No database was found under the prefix at all. Check the S3 prefix.
S3に出たファイルを見ると理由が分かります。
appdb-schema-create.sql.zst 131 B
appdb.app_logs-schema.sql.zst 303 B
appdb.app_logs.0000000010000.csv.zst 22.5 MB
スキーマSQLまで.zstで圧縮されています。
Parquetのときは圧縮がファイル内部のコーデックだったので.sqlは素のまま残っていました。
CSVにすると圧縮が外側のラッパーになり、.sqlも巻き込まれます。
データベースの定義そのものが見つからないので、「DBが1つも無い」というメッセージになるわけです。
非圧縮CSVはエスケープと改行がズレている
Compression: Noneで出し直すとPreviewは通り、実行するとこうなりました。

Chunksが0/0から0/1になっています。
1チャンクを認識して、読みにいって、失敗しました。
Last Messageは1 table(s) failed during full sync: ap...で切れていて全文が読めません。
ここで役に立ったのが、TiDB Cloud Lake側のMonitoring > SQL Historyでした。

BadBytes. Code: 1046, Text = Number of columns in file (12) does not match that of the corresponding table (6)
at file 'tidb-cloud-lake/appdb.app_logs.0000000010000.csv', line 1.
Integration Taskの失敗はRun Historyでは途中で切れますが、実体はWarehouse上で走るSQLなのでSQL Historyに全部残ります。
さて、app_logsは6列です。
ファイルが12列に見えているので、差は6です。
実際のCSVを見てみます。
1,"DEBUG","search-api","span exported (req=204b5f50)","{\"az\": \"ap-northeast-1a\", \"customer_id\": 3890, \"duration_ms\": 46.5, \"http\": {\"method\": \"GET\", \"path\": \"/v1/inventory\", \"status\": 200}, \"trace_id\": \"11398b037a11a01e\"}","2026-07-25 10:34:50"
attributesのダブルクォートがバックスラッシュでエスケープされています。
Dumplingのデフォルト--escape-backslash=true(MySQL方言)そのものです。
そして差の6は、attributesのJSON内部にあるカンマの数と一致します。
\"のエスケープが解釈されないため値が1つのフィールドとして閉じず、JSON内部のカンマが列区切りとして扱われていました。
タスクが投げているSQLもSQL Historyに出ていました。
COPY INTO `appdb`.`app_logs` (`log_id`, `level`, `service`, `message`, `attributes`, `logged_at`)
FROM 's3://<バケット名>/tidb-cloud-lake/appdb.app_logs.0000000010000.csv'
CONNECTION=(external_id='la***xl', role_arn='ar***le')
FILE_FORMAT=(field_delimiter=',', null_display='\\N', record_delimiter='\n', skip_header=1, type=CSV)
PURGE=false FORCE=false DISABLE_VARIANT_CHECK=false ON_ERROR=abort RETURN_FAILED_ONLY=false
FILE_FORMATにescapeもquoteもないので、Databendのデフォルト(RFC 4180の"")で読まれます。
さらにrecord_delimiter='\n'ですが、エクスポートはDumplingのデフォルトどおり\r\nで出ます。
エスケープ方式と改行コードの2つがズレていました。
出し直すためのオプションがコンソールに無い
タスクのExport Escaped BackslashesをYesにすれば読めるだろうと思ったのですが、赤字が出ました。

Re-export with
--escape-backslash=false --csv-output-dialect=snowflake, then select No.
Existing export settings must not be changed without re-exporting.
「エスケープ済みも読める」という設定ではなく、「そのファイルは読めないので出し直してからNoを選べ」という指示でした。
しかもYesにするとUpdateボタンが押せなくなるので、そもそも保存できません。
では出し直せばいいのかというと、TiDB Cloudのエクスポート機能にそのオプションはありません。
コンソールのEdit CSV Configurationで変えられるのはSeparator・Delimiter・Null value・Skip headerの4つだけで、エスケープ方式も改行コードも触れません。
CLIも同じでした。
指定できるのは--csv.delimiter・--csv.separator・--csv.null-value・--csv.skip-header・--parquet.compressionの5つだけで、--escape-backslashも--csv-output-dialectもありません。
ここまでで、前半のDumplingに--escape-backslash=falseと--csv-line-terminator $'\n'を付けた理由がつながります。
画面に書かれているとおりに出し直すには、Dumplingを自分で回すしかありませんでした。
ちなみにTiDB Cloudのエクスポートと自前のDumplingで-schema.sqlをdiffすると差分なしで、バイト単位で一致します。
エクスポート機能の実体もDumplingで、違うのはフラグだけでした。
変換すれば通る
Dumplingを持ち出さずに済ませたい場合は、出したCSVを直す手もあります。
直すのは2か所だけです。
$ LC_ALL=C sed 's/\\"/""/g' original.csv | tr -d '\r' > converted.csv
\"を""に、\r\nを\nにしているだけです。
134MBの変換に4.9秒でした。
これをS3へ戻して同じタスクを実行すると、通りました。
1回前の実行はRows Synced 0だったので、設定を一切変えずファイルの中身だけを変えて成否が分かれています。
ただし、値そのものにバックスラッシュを含むデータでは、この単純置換は壊れます。
今回は事前にgrepで確認して、データにバックスラッシュもNULL表現も含まれていないことを確かめたうえでやっています。
最後に
今回は、TiDB CloudのクラスタのデータをS3経由でTiDB Cloud Lakeへ移し、取り込んだデータが一致するところまで確認してみました。
TiDBデータソースは自前でDumplingを回す前提で作られていて、TiDB Cloudのコンソールのエクスポート機能だけでは完結しないというのが正直なところです。
この記事がどなたかの参考になれば幸いです。



