こんにちは~ 浮田です。
先日、外部の勉強会でStep FunctionsのJSONataについて登壇したんですが、質疑応答で「JSONataに置き換えると処理速くなるんですか?」と聞かれて、正直に言うと曖昧に濁して終わりました。
「Lambda分の処理は速くなると思いますけど……」みたいな、自分でも歯切れが悪いなと思う回答をした記憶があります。
感覚としては「速くなる」と思ってたんですが、「どれくらい」を聞かれた瞬間、何も答えられないことに気づいたんですよね。
これ、悔しかったので実際に測ってみました。
■ TL;DR
- Lambda 4本のチェーンを JSONata の Pass ステート1つに置き換えたら、ウォームで7.9倍、コールドで31倍速くなった
- 100件バッチ(Map)でも 4.6秒→0.83秒(5.6倍)。誤差では済まない差
- コストの支配項は Lambda料金じゃなくて状態遷移数。4ステート→1ステートで単純に約4倍の課金差
- 出力の等価性はローカル・TestState・実機の3層でテスト済み。さらに5日後に別日再計測して倍率の再現も確認した
- 結論:「その Lambda、入力JSONを出力JSONに写しているだけでは?」に Yes ならJSONataに置き換え可能。外部I/Oがあるなら今でもLambdaの仕事
コーヒー注文処理を「Lambda 4本のチェーン」と「JSONata の Pass ステート1つ」で実装し、両者が同一出力を返すことをテストで保証した上で、東京リージョンで実測しました。
|
指標(p50) |
Lambda版(Task×4) |
JSONata版(Pass×1) |
差 |
|
単発・ウォーム(Standard) |
424 ms |
54 ms |
7.9倍 |
|
単発・コールド(Standard) |
1,425 ms |
46 ms |
31倍 |
|
100件バッチ(Map・Standard) |
4,625 ms |
828 ms |
5.6倍 |
|
単発(Express) |
64 ms |
1 ms |
64倍 |
|
状態遷移数/実行(単発) |
4 |
1 |
4倍 |
|
状態遷移数/実行(100件) |
401 |
101 |
4倍 |
|
Lambda 実行料金 |
発生 |
0 |
— |
|
コールドスタート |
あり |
なし |
— |
しかも Lambda 版は メモリ 1769MB(1vCPU 相当)・外部依存なし・SDK 初期化なしという最速条件で測っています。それでもこの差です。
■ 「データ変換Lambda」問題
Step Functions のワークフローには、外部 I/O を持たない純粋なデータ変換だけの Lambda が紛れ込みがちです。
フィールドを整形する、金額を集計する、表示用文字列を組み立てる——数行の JavaScript のためだけに、関数を1本デプロイし、コールドスタートを抱え、同時実行数を消費し、invoke のたびに数十〜数百 ms を払う。
2024年11月に Step Functions が JSONata と変数をサポートして、この種の Lambda はステートマシン定義の中に畳み込めるようになりました。
「畳み込めるのは知ってる。で、実際どれくらい違うの?」この記事はその実測です。
■ 題材:コーヒー注文処理
題材は AWS 公式サーバーレスデモ Serverlesspresso の世界観を借りました(実装は借りていません。公式アプリは人間の待ち時間が支配的で、変換処理のベンチマークにならないため)。
注文ペイロードはこれ:
%E3%80%82.png?width=664&height=243&name=%E9%A1%8C%E6%9D%90%E3%81%AF%20AWS%20%E5%85%AC%E5%BC%8F%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%83%AC%E3%82%B9%E3%83%87%E3%83%A2%20Serverlesspresso%20%E3%81%AE%E4%B8%96%E7%95%8C%E8%A6%B3%E3%82%92%E5%80%9F%E3%82%8A%E3%81%BE%E3%81%97%E3%81%9F(%E5%AE%9F%E8%A3%85%E3%81%AF%E5%80%9F%E3%82%8A%E3%81%A6%E3%81%84%E3%81%BE%E3%81%9B%E3%82%93%E3%80%82%E5%85%AC%E5%BC%8F%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AF%E4%BA%BA%E9%96%93%E3%81%AE%E5%BE%85%E3%81%A1%E6%99%82%E9%96%93%E3%81%8C%E6%94%AF%E9%85%8D%E7%9A%84%E3%81%A7%E3%80%81%E5%A4%89%E6%8F%9B%E5%87%A6%E7%90%86%E3%81%AE%E3%83%99%E3%83%B3%E3%83%81%E3%83%9E%E3%83%BC%E3%82%AF%E3%81%AB%E3%81%AA%E3%82%89%E3%81%AA%E3%81%84%E3%81%9F%E3%82%81)%E3%80%82.png)
処理は典型的な「データ変換Lambdaの仕事」4段です。
|
段 |
処理 |
|
正規化 |
drink/size を小文字化、shots 未指定を 1、cancelled 未指定を false に補完 |
|
価格計算 |
キャンセル除外 → 単価算出(サイズ・追加ショット・シロップ)→ 小計 |
|
割引・税 |
クーポン 10% 割引 → 消費税 10%(切り捨て) |
|
チケット整形 |
バリスタ画面用文字列、JST 表示、待ち時間見積もり |
■ Before:Lambda 4本
価格計算の関数だけ載せます

外部依存なし・SDK 初期化なし。ステートマシンはこの4本を Task で直列に呼びます。

■ After:JSONata の Pass ステート1つ
同じ処理が Pass ステート 1つになります。

Output に載せている JSONata 式の全文(実際の ASL では1行に潰しています):
.png?width=662&height=805&name=Output%20%E3%81%AB%E8%BC%89%E3%81%9B%E3%81%A6%E3%81%84%E3%82%8B%20JSONata%20%E5%BC%8F%E3%81%AE%E5%85%A8%E6%96%87(%E5%AE%9F%E9%9A%9B%E3%81%AE%20ASL%20%E3%81%A7%E3%81%AF1%E8%A1%8C%E3%81%AB%E6%BD%B0%E3%81%97%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99).png)
$filter / $map / $sum / $lookup / $exists / 三項分岐 / $fromMillis / 文字列連結——グルー Lambda がやっていた仕事は、JSONata の語彙でひと通り書けます。
■ この比較はフェアか
ベンチマークで一番大事なのは「比較が成立していること」なので、先にそこを固めました。
1. 両者は同一の出力を返す。
これは3層で保証しています。
- ローカル: jsonata npm パッケージで、ASL ファイルから抽出した式と Lambda 4本のチェーンを同一入力で実行し deep-equal 比較(エッジケース含め7テスト)。
- TestState API: 実機の JSONata エンジンでも同じ出力になることを確認
- 実機実行: デプロイした A/B に同一ペイロードを投げて出力一致を確認
2. Lambda 側に最速条件を与える。
メモリ 1769MB(1vCPU 相当)、arm64、外部依存ゼロ、SDK 初期化ゼロ、ハンドラは数行の同期処理。
3. 測定条件を揃える。
同一リージョン(ap-northeast-1)・同一時間帯に A/B を交互に実行。ウォームアップ後に計測し、コールドは Lambda の設定を更新して毎回強制的にコールド化。時間はクライアント時計ではなく DescribeExecution のサーバ側 startDate/stopDate から算出。N=50(Map系は20、コールドは5〜10)、p50/p95 で報告。
4. 別日に全計測を再実行して再現を確認。
5日後に同じスタックへ同じ計測を流したところ、すべての p50 が ±7% 以内で一致し、倍率(ウォーム約8倍・コールド約30倍・Map 約5.6倍)と遷移数(4:1、401:101)は完全に再現しました。本文の数字は初回計測のものです。
正直、ここまでやるかどうか少し迷ったんですが、「1回測った数字」と「再現した数字」は説得力が全然違うので、やってよかったです。
なお、いくつか注意点も明記しておきます: Express の「1 ms」は API タイムスタンプの分解能(ミリ秒)の下限値であり実質サブミリ秒であること、Map の倍率は並列度(今回は10)に依存すること(遷移数の 4:1 は並列度に依存しません)。
■ 結果
● 単発実行(Standard)
.png?width=1024&height=427&name=%E5%8D%98%E7%99%BA%E5%AE%9F%E8%A1%8C(Standard).png)
ウォームで 424 ms → 54 ms(7.9倍)。1回の Lambda invoke あたり約90 ms のオーバーヘッド(遷移+invoke+ランタイム往復)が4回積み重なった分が、まるごと消えます。
コールドは 1,425 ms → 46 ms(31倍)。JSONata 版にはコールドスタートという概念自体がありません。ちなみに JSONata 版のコールド列(46 ms)はウォームと同じ値です。
イベント履歴を見ると面白いことが分かります。JSONata 版では PassStateEntered と PassStateExited のタイムスタンプが同一(ミリ秒単位で差が出ない)。つまり JSONata の評価自体は 1 ms 未満で、54 ms のほとんどは Standard ワークフローの実行開始/終了のオーバーヘッドです。後述の Express 版で p50 が 1 ms だった実測とも辻褄が合います。
● 100件バッチ(Map、並列度10)
.png?width=1024&height=341&name=100%E4%BB%B6%E3%83%90%E3%83%83%E3%83%81(Map%E3%80%81%E4%B8%A6%E5%88%97%E5%BA%A610).png)
単発の数十 ms 差は「誤差では?」と言われがちですが、Map で100件処理すると 4.6秒 → 0.83秒 と、誰の目にも明らかな差になります。
● Express ワークフロー

Express だと JSONata 版は p50 で 1 ms。API タイムスタンプの粒度がミリ秒なので、これは計測分解能の下限に張り付いた値、つまり実質サブミリ秒です(Standard 版のイベント履歴で PassStateEntered と PassStateExited が同一タイムスタンプだったことと整合します)。
Lambda 版は 64 ms。ただし Express の課金は 100 ms 単位の切り上げなので、SFN 側の請求はどちらも最低課金の 100 ms でした。差が出るのは Lambda の実行料金と、応答レイテンシそのものです。
● コスト:遷移数が4倍、そして Lambda 料金

Standard の課金単位は状態遷移数です。
単発で 4 対 1、Map 100件で 401 対 101。
実行履歴のイベント数で見ると、C(Lambda版)は 2,206 イベント、D(JSONata版)は 406 イベント。課金対象の遷移数だけでなく、実行履歴そのものも約5倍膨らみます(Standard には実行あたり 25,000 イベントの上限があるので、Lambda チェーン × 大きめの Map はこの上限にも早く近づきます)。
100万注文を単発実行で処理した場合の試算(ap-northeast-1、執筆時点の単価):
|
|
Lambda版 |
JSONata版 |
|
状態遷移 |
400万 × $0.025/1000 = $100 |
100万 × $0.025/1000 = $25 |
|
Lambda リクエスト |
400万 × $0.20/100万 ≈ $0.8 |
$0 |
|
Lambda 実行時間 |
≈ $1 |
$0 |
|
合計 |
≈ $102 |
$25 |
意外なことに、支配項は Lambda 料金ではなく遷移数です。グルー Lambda は「Lambda 代がもったいない」のではなく、「ステートを増やすこと自体が高い」のです。
地味に一番の発見でした。「JSONataにするとLambda代が浮く」くらいの認識で測り始めたんですが、実際はLambda代なんてほぼ誤差で、遷移数という別の軸が支配していた。予想を外したベンチマークほど書く価値があるな、と思いました。
■ じゃあ Lambda は要らないのか
要ります。この結果は「純粋なデータ変換に Lambda を使うのをやめよう」であって、Lambda 不要論ではありません。
- 外部 I/O(DynamoDB、API 呼び出しなど)があるなら Lambda(または SDK 統合)の仕事
- 複雑なビジネスロジック、npm の資産、既存コードの再利用が要るなら Lambda の勝ち
- JSONata 式もテストは書けます。JSONata はただの言語なので、ASL から式を抽出して jsonata パッケージでローカル実行すれば、AWS アカウントなしで単体テストできます
- 判断基準はシンプルで、「その Lambda、入力 JSON を出力 JSON に写しているだけでは?」 に Yes なら JSONata に畳めます。
今回の実測が示しているのは、単なる高速化テクニックではなく Step Functions の進化の方向性だと思います。 Lambda が不要になるのではなく、Lambda を「本当に計算が必要な場所」だけに置ける時代になった。
「とりあえずグルー Lambda」がデフォルトだった時代から、「まず JSONata で書けないか?」を先に考える時代へ。
デフォルトが静かに逆転しつつある、というのが実測を終えての実感です。
■ ハマったところ
実装中に踏んだ罠を4つ置いておきます。
1. Assign した変数は同一ステートの Output で使えない。
Assign と Output は並行に評価されるため、「Assign で $subtotal を作って Output で参照」は動きません。Pass 1つに収めたければ、Output 全体を1つの JSONata 式にして、式の内部で変数束縛($x := ...)します。
2. 空配列で $map/$sum が undefined になる。
全アイテムがキャンセルされたケースで、$sum($map([], ...)) の結果が undefined になり、出力からキーごと消えました(SFN では States.QueryEvaluationError の原因にもなります)。$count($x) = 0 ? 0 : $sum(...) のガードが必要です。
3. jsonata npm パッケージの出力は null-prototype オブジェクト。
ローカルの等価性テストで assert.deepStrictEqual が「値は同じなのに」落ちます。JSON ラウンドトリップ(JSON.parse(JSON.stringify(x)))してから比較します。
4. TestState API にはステート単位の QueryLanguage 指定が要る。
ステートマシン全体では "QueryLanguage": "JSONata" をトップレベルに書きますが、TestState に単一ステートを渡すときはそのステート自身に QueryLanguage を付けないと JSONPath として解釈されてエラーになります。
■ まとめ
- 外部 I/O を持たないグルー Lambda 4本を JSONata の Pass 1つに置き換えると、ウォームで7.9倍、コールドで31倍、100件バッチで5.6倍速くなった
- Standard のコストは遷移数がストを大きく左右する。4ステート→1ステートはそのまま約4倍の料金差になる
- JSONata 版にはコールドスタートも同時実行数の消費も Lambda 料金もない
- 等価性はローカル(jsonata パッケージ)・TestState・実機の3層でテストできる。「置き換えて壊れないか」は勘ではなくテストで保証できる。さらに5日後の再計測でも倍率が再現した
一番の収穫は「コストを大きく左右するのは遷移数」という発見でした。Lambda代がもったいないという当初の予想は外れて、実際はステート数そのものがコストを決めていた。感覚で「速くなるはず」と言うのと、数字で「7.9倍です」と言えるのとでは、説得力がまるで違います。
これで、次に同じ質問をされたら数字で答えられます。「7.9倍、コールドなら31倍です」と。
次は、SDK統合でLambdaを削れるパターンも同じやり方で実測してみようと思っています。DynamoDBへの書き込みをLambda経由でやるのと直接SDK統合でやるのと、どれくらい差が出るのか気になっているので。
※ この記事の数値は執筆時点(2026年7月)の Step Functions / Lambda の料金・挙動に基づきます。今後のアップデートで変わる可能性があります。
■ 参考
- Simplifying developer experience with variables and JSONata in AWS Step Functions
- Transforming data with JSONata in Step Functions
- JSONata 言語ドキュメント
- Serverlesspresso(aws-samples/serverless-coffee) — 題材の出典