Azure Synapse SQLで、TaskID順にタスクを見ながら「その時点で累計タスク数が最小のユーザーへ割り当て、更新後の累計を出す」処理は、見た目以上に難易度が高いテーマです。本記事では難しさの理由を整理し、ビューでも使いやすい“ループなし”の現実解(初期値順のラウンドロビン+ウィンドウ関数)を具体SQLで解説します。
やりたいこと:Synapse SQLで「累計タスク数が最小のユーザーへ順次割り当て」
たとえば、問い合わせ対応・レビュー作業・データチェックなどを複数担当者へ割り振るとき、単純に「順番に回す」だけではなく、各担当者の現在の負荷(累計タスク数)を見て、毎回“いちばん軽い人”に渡したいという要件が出てきます。
今回の要件を一言で表すと、次のような処理です。
- TasksテーブルのTaskID昇順で1件ずつ処理する
- その時点で(直前までの割り当てで更新された値を含む)累計タスク数が最小のユーザーへ割り当てる
- 出力は UpdatedTasks =(そのユーザーの現在値)+ NewTask
- 次のTaskIDでは、更新後累計を前提に最小ユーザーを再判定する
- できればSynapse SQLでループなし(ビュー等でも動く形)にしたい
前提テーブル:UsersとTasks
前提は、ユーザー別の現在の累計を持つUsersテーブルと、新規割り当てしたいタスク量を持つTasksテーブルです。
| テーブル | 列 | 意味 | 重要ポイント |
|---|---|---|---|
| Users | UserID | ユーザーID | 同一値がない想定(主キー) |
| Users | TotalTasks | 現時点の累計タスク数 | ここが「最小」の判定基準になる |
| Tasks | TaskID | タスク番号(処理順) | TaskID昇順で逐次処理したい |
| Tasks | NewTask | 追加タスク数 | 割り当て先ユーザーの累計に加算する |
ここでの「NewTask」は、作業件数でも、ポイント(工数見積)でも構いません。重要なのは割り当てが進むほど、ユーザー別の累計が変化していく点です。
なぜ難しいのか:これは“逐次(反復)アルゴリズム”だから
この要件の核心は、各タスクの割り当て先が「直前までの割り当て結果」に依存することです。SQLは本来「集合(セット)を一括で扱う」言語なので、ループや状態(ステップごとに更新される値)を前提としたアルゴリズムとは相性がよくありません。
要件を疑似コードで書くと、イメージがつきます。
for task in Tasks (TaskID昇順):
user = Usersの中で TotalTasks が最小の人(同点なら何らかのタイブレーク)
user.TotalTasks = user.TotalTasks + task.NewTask
出力:task.TaskID, user.UserID, UpdatedTasks=user.TotalTasks
ポイントは、2行目の「最小ユーザーの選択」が、3行目の更新(加算)によって次のステップで変わることです。つまり、ステップnの結果がステップn+1の条件に食い込むため、完全に一般形として厳密に解くには、どこかで「反復」を表現する必要が出ます。
結論としては次の整理になります。
- この問題は性質的に「逐次処理」
- Synapse SQLのビューのような「単一SELECTで完結」させたい形と相性が悪い
- 厳密に“都度最小”を保証しつつスケールさせるのは、単一SELECTだけでは難しい(場合によっては現実的でない)
よくある落とし穴:LIMITは使えない/TOP(1)に置き換えても本質は解けない
「最小ユーザーを毎回1件取るなら、LIMIT 1(または同等)でいけそう」と考えがちですが、Synapse SQLはT-SQL系です。したがって、一般的なSQL方言で見かけるLIMITは使えません。
| やりたいこと | よくある書き方 | Synapse SQL(T-SQL)での書き方 |
|---|---|---|
| 最小のユーザーを1件取る | ORDER BY TotalTasks LIMIT 1 | SELECT TOP (1) … ORDER BY TotalTasks |
ただし、LIMITをTOP(1)に置き換えれば解決するわけではありません。問題は「毎回1件取る」ことではなく、取った結果でUsers側の累計が更新され、それを次のタスクの判定に反映することにあります。
例えば、次のように「Tasksの各行に対して、UsersからTOP(1)を引っ張る」発想は一見それらしく見えますが、更新が表現できません。
-- ※イメージ例(このままでは要件を満たしません)
SELECT
t.TaskID,
u.UserID,
u.TotalTasks + t.NewTask AS UpdatedTasks
FROM dbo.Tasks t
CROSS APPLY (
SELECT TOP (1) *
FROM dbo.Users
ORDER BY TotalTasks ASC
) u
ORDER BY t.TaskID;
このSQLは、タスクが何件あっても、Usersの「その瞬間の状態」からTOP(1)を選ぶだけで、前行で選ばれたユーザーのTotalTasksが増えるという状態遷移が起きません。結果として、同じユーザーに割り当て続けるなど、期待とズレるケースが出ます。
ここが「SQLで書けそうなのに書き切れない」最大のハマりポイントです。
採用されやすい現実解:ルールを少し緩めて“初期値順のラウンドロビン”にする
そこで現場で採用されやすいのが、要件を少しだけ緩める(実装しやすい形に寄せる)考え方です。
具体的には次の方針です。
- 毎回「真の最小」を再評価するのはやめる
- 代わりに、初期のTotalTasksが小さい順にユーザーへ順番(QueueOrder)を付ける
- タスクはTaskID順で連番(TaskNo)を付ける
- TaskNo % ユーザー数で割り当て先を決め、順繰り(ラウンドロビン)に振る
- ユーザー別の追加分はウィンドウ関数で累積し、TotalTasksへ足してUpdatedTasksを出す
この方法の良いところは、完全にセットベース(ウィンドウ関数中心)で書けて、ビューにも載せやすい点です。さらに、初期値で順番を決めるため、単純なラウンドロビンよりは「最初に軽い人から回す」という直感に寄ります。
一方で、重要な注意点があります。
この方法は「真に毎回“最小”を選ぶ」ことを保証しません。 あくまで「ループなしで実装しやすい、現実的な近似」です。NewTaskの偏りが大きいほど、厳密解との差が出やすくなります。
代表的なSQL:ラウンドロビン割り当て+更新後累計の算出
以下は、Synapse SQL(T-SQL)で書ける代表例です。ビューに組み込みたい場合も、基本的にこの形を土台にできます(末尾のORDER BYは参照側で行います)。
WITH
-- 1) Usersを「初期の累計が小さい順」に並べ、QueueOrderを付ける
UsersOrdered AS (
SELECT
u.UserID,
u.TotalTasks,
ROW_NUMBER() OVER (
ORDER BY u.TotalTasks ASC, u.UserID ASC
) AS QueueOrder
FROM dbo.Users AS u
),
-- 2) ユーザー数(後で % に使う)
UserCount AS (
SELECT COUNT_BIG(*) AS UserCnt
FROM UsersOrdered
),
-- 3) TasksをTaskID昇順に並べ、0始まりの連番(TaskNo)を付ける
TasksOrdered AS (
SELECT
t.TaskID,
t.NewTask,
(ROW_NUMBER() OVER (ORDER BY t.TaskID ASC) - 1) AS TaskNo
FROM dbo.Tasks AS t
),
-- 4) TaskNo % UserCnt で割り当て先のQueueOrderを決定して結合
Assigned AS (
SELECT
t.TaskID,
t.TaskNo,
t.NewTask,
u.UserID,
u.TotalTasks AS BaseTotalTasks,
u.QueueOrder
FROM TasksOrdered AS t
CROSS JOIN UserCount AS c
JOIN UsersOrdered AS u
ON u.QueueOrder = (t.TaskNo % c.UserCnt) + 1
),
-- 5) ユーザー別にNewTaskを累積し、更新後の累計を算出
Calc AS (
SELECT
a.TaskID,
a.TaskNo,
a.UserID,
a.NewTask,
a.BaseTotalTasks,
SUM(a.NewTask) OVER (
PARTITION BY a.UserID
ORDER BY a.TaskNo
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS CumNewTaskForUser
FROM Assigned AS a
)
SELECT
TaskID,
UserID,
NewTask,
BaseTotalTasks + CumNewTaskForUser AS UpdatedTasks
FROM Calc
-- ビューにする場合はORDER BYを外し、参照側で並べ替えるのが基本
ORDER BY TaskID ASC;
このSQLがやっていること(読み解きポイント)
| ブロック | 役割 | 実務で意識したい点 |
|---|---|---|
| UsersOrdered | 初期TotalTasksでユーザーに順番付け | TotalTasks同点時に備え、UserIDをORDER BYに含めて順序を安定化 |
| UserCount | ユーザー数を算出 | COUNT_BIGで大きめのデータでも型トラブルを避ける |
| TasksOrdered | TaskID昇順でタスクに連番を付ける | TaskIDが重複する場合は別列でタイブレークを追加 |
| Assigned | TaskNo % UserCnt で割当先を決めて結合 | “割当先が固定順になる”のがこの方式の肝 |
| Calc | ユーザー別の追加分を累積しUpdatedTasksを出す | ROWS句を明示して、ウィンドウの範囲を意図通りにする |
具体例:サンプルデータで結果を確認する
動きをイメージしやすいように、簡単なサンプルで確認します。
Users(初期値)
| UserID | TotalTasks | QueueOrder(TotalTasks昇順) |
|---|---|---|
| U01 | 3 | 1 |
| U02 | 5 | 2 |
| U03 | 8 | 3 |
Tasks(追加したいタスク量)
| TaskID | NewTask | TaskNo(0始まり) | TaskNo % 3 | 割り当て先(QueueOrder) |
|---|---|---|---|---|
| 101 | 2 | 0 | 0 | 1(U01) |
| 102 | 1 | 1 | 1 | 2(U02) |
| 103 | 4 | 2 | 2 | 3(U03) |
| 104 | 3 | 3 | 0 | 1(U01) |
| 105 | 2 | 4 | 1 | 2(U02) |
| 106 | 1 | 5 | 2 | 3(U03) |
出力(UpdatedTasks:そのタスク割り当て後の累計)
ユーザー別にNewTaskを累積して足し戻すことで、各タスク時点の更新後累計が出ます。
| TaskID | 割り当てUserID | NewTask | そのユーザーの初期TotalTasks | そのユーザーの累積NewTask | UpdatedTasks(初期+累積) |
|---|---|---|---|---|---|
| 101 | U01 | 2 | 3 | 2 | 5 |
| 102 | U02 | 1 | 5 | 1 | 6 |
| 103 | U03 | 4 | 8 | 4 | 12 |
| 104 | U01 | 3 | 3 | 5 | 8 |
| 105 | U02 | 2 | 5 | 3 | 8 |
| 106 | U03 | 1 | 8 | 5 | 13 |
この方式だと、各行のUpdatedTasksは「そのユーザーの現在値(直前までの割り当てを含む)+NewTask」を意味する形になります。SQLとしては、“現在値”を逐次更新しているのではなく、ユーザー別の累積をウィンドウ関数で再現している点がミソです。
ビューで使うときの実務ポイント(Synapse SQLで事故りやすいところ)
ビュー定義ではORDER BYを置かない
ビュー内でのORDER BYは制約が出やすく、また「並び順は参照側で保証する」のが基本です。上のSQLをビューにするなら、末尾のORDER BYを外し、参照時に ORDER BY TaskID を付ける運用が安全です。
ROW_NUMBERのORDER BYは必ず安定化する
UsersOrderedの順序がぶれると、割り当て結果もぶれます。特にTotalTasksが同点のユーザーがいる場合は、必ずタイブレーク列(UserIDなど)をORDER BYに追加してください。
- OK例:
ORDER BY TotalTasks, UserID - 危険例:
ORDER BY TotalTasks(同点時に順序が未定義になり得る)
UserCountが0にならない前提を担保する
ユーザーが0件だと TaskNo % UserCnt が成立しません。データ品質として「Usersは必ず1件以上」を担保するか、運用上あり得るなら0件時の扱い(出力しない等)を別途決める必要があります。
型と桁あふれを意識する
ROW_NUMBERは大きな値になる可能性があります。大量タスクを扱う場合は、TaskNoをBIGINTとして扱い、UserCntもCOUNT_BIGでBIGINTに揃えると安全です。
(専用SQLプールの場合)小さなUsersはレプリケートを検討する
Azure Synapse Analyticsの専用SQLプール(MPP)で、Usersが小さくTasksが大きい構成はよくあります。その場合、Users相当のディメンションをレプリケート(全ノード複製)できる設計だと、結合時のデータ移動が減って安定しやすいことがあります。環境・サイズ次第ですが、パフォーマンスで悩むなら「テーブル分散」「データ移動(シャッフル)の発生」を確認すると原因に当たりやすいです。
この方式の限界:真に「都度最小」を保証しない(ズレる具体例)
繰り返しになりますが、初期値順ラウンドロビンは、“毎回の再評価で本当に最小の人を選ぶ”という厳密要件を保証しません。NewTaskの偏りが大きいとズレが顕著です。
ズレが出る例を、あえて小さなデータで示します。
例:3ユーザー、1件目だけ極端に重いタスクがある
| UserID | 初期TotalTasks |
|---|---|
| U01 | 0 |
| U02 | 0 |
| U03 | 0 |
| TaskID | NewTask |
|---|---|
| 1 | 100 |
| 2 | 1 |
| 3 | 1 |
| 4 | 1 |
このとき、割り当ては次のようにズレやすくなります(同点時のタイブレークは一例としてUserID昇順とします)。
| TaskID | 都度最小(厳密)での割り当て | 初期値順ラウンドロビンでの割り当て | ズレのポイント |
|---|---|---|---|
| 1 | U01(U01=100) | U01(U01=100) | ここまでは同じ |
| 2 | U02(U02=1) | U02(U02=1) | ここも同じ |
| 3 | U03(U03=1) | U03(U03=1) | ここも同じ |
| 4 | U02(最小=1のU02/U03のうちU02) | U01(順番どおりU01に戻る) | 厳密なら軽い人へ、ラウンドロビンは順番固定 |
つまり、途中で極端なタスクが混じったり、ユーザー数が多くて負荷の変動が大きいと、ラウンドロビンは「本当は別ユーザーが最小なのに、順番で決まってしまう」ことがあります。
厳密に「都度最小」が必須なら:実務的な実装パターン
もし業務要件として「毎回、更新後累計を見て最小ユーザーへ割り当てる」が絶対条件なら、ビュー単体で完結させるのではなく、反復を許す実行形態へ寄せた方が安定します。
| パターン | 概要 | 向いているケース | 注意点 |
|---|---|---|---|
| ストアド/スクリプトで反復(WHILE等) | タスクを順に処理し、累計を更新しながら割り当て結果を蓄積 | 厳密性が最優先、件数がそこまで多くない | RBARになりやすいので性能設計が必要 |
| ステージング+段階処理(CTAS等) | バッチ単位で再評価し、途中結果をテーブル化して次段へ渡す | 大量データ、運用でバッチ処理が許される | パイプライン設計・再実行性の担保が必要 |
| Spark / Dataflow 等へ寄せる | 逐次更新が得意な処理系で割り当てロジックを実装 | データレイク中心、処理フローが既にSpark寄り | 出力をSQL側へ戻す連携が必要 |
| 近似+監視(今回のラウンドロビン) | ルールを緩め、SQLだけで実装して運用負荷を下げる | ビューで完結したい、厳密でなくてもよい | 偏りが出たら再調整(再ランキング等)が必要 |
「ビューで完結したい」という要望は強い一方で、厳密な逐次最適化はSQLが不得意な領域です。要件の優先度(厳密性 vs 実装・運用コスト)をはっきりさせると、選ぶべき実装が決めやすくなります。
さらに現場っぽくする工夫:ラウンドロビンを“運用で賢く”する
「厳密な都度最小までは要らないが、偏りはなるべく減らしたい」という場合、ラウンドロビンを少し現場寄りにチューニングできます。
定期的にQueueOrderを作り直す(再ランキング)
例えば日次・週次などのバッチでUsersのTotalTasksを更新し、QueueOrderを付け直す運用にすると、ラウンドロビンの「固定順でズレる」問題が緩和されます。ビューはあくまで“当日の割当”を作り、TotalTasksは別バッチで正規化する、という分業です。
ユーザーごとの上限・下限や休止フラグを持たせる
実務では「この人は今週は担当外」「この人は上限を超えたら止める」などの制約が入ることがあります。Usersテーブルにフラグや上限を持たせ、UsersOrderedのWHEREで除外・優先度調整をする方が、後から要件変更に耐えやすいです。
NewTaskが“重すぎる”なら、粒度を見直す
NewTaskのばらつきが大きいほど、ラウンドロビンはズレやすくなります。もし可能なら、極端に重いタスクを「分割して複数行にする」「別キューに逃がす」など、入力データ側で粒度を整えると、SQL側の近似でも実務上の満足度が上がることがあります。
まとめ:Synapse SQLでの割り当ては“何を保証したいか”で最適解が変わる
Azure Synapse SQLで「TaskID順に、累計タスク数が最小のユーザーへ逐次割り当て」は、厳密にやろうとすると反復処理が避けづらいテーマです。ビュー上の単一SELECTで完結させたい場合は、今回のように初期値順のラウンドロビン+ウィンドウ関数で、更新後累計(UpdatedTasks)を出すのが実装しやすい現実解になります。
一方で、都度最小の厳密性が必要なら、ストアド・段階処理・Sparkなど、反復を表現できる実行形態へ寄せるのが安定です。要件の優先順位を決めた上で、SQLでやる範囲と、処理系を分ける範囲を切り分けてください。

コメント