Azure Synapse SQLで累計タスク数が最小のユーザーへ割り当てる方法:ループなしの現実解とウィンドウ関数SQL

Azure Synapse SQLで、TaskID順にタスクを見ながら「その時点で累計タスク数が最小のユーザーへ割り当て、更新後の累計を出す」処理は、見た目以上に難易度が高いテーマです。本記事では難しさの理由を整理し、ビューでも使いやすい“ループなし”の現実解(初期値順のラウンドロビン+ウィンドウ関数)を具体SQLで解説します。

目次

やりたいこと:Synapse SQLで「累計タスク数が最小のユーザーへ順次割り当て」

たとえば、問い合わせ対応・レビュー作業・データチェックなどを複数担当者へ割り振るとき、単純に「順番に回す」だけではなく、各担当者の現在の負荷(累計タスク数)を見て、毎回“いちばん軽い人”に渡したいという要件が出てきます。

今回の要件を一言で表すと、次のような処理です。

  • TasksテーブルのTaskID昇順で1件ずつ処理する
  • その時点で(直前までの割り当てで更新された値を含む)累計タスク数が最小のユーザーへ割り当てる
  • 出力は UpdatedTasks =(そのユーザーの現在値)+ NewTask
  • 次のTaskIDでは、更新後累計を前提に最小ユーザーを再判定する
  • できればSynapse SQLでループなし(ビュー等でも動く形)にしたい

前提テーブル:UsersとTasks

前提は、ユーザー別の現在の累計を持つUsersテーブルと、新規割り当てしたいタスク量を持つTasksテーブルです。

テーブル列意味重要ポイント
UsersUserIDユーザーID同一値がない想定(主キー)
UsersTotalTasks現時点の累計タスク数ここが「最小」の判定基準になる
TasksTaskIDタスク番号(処理順)TaskID昇順で逐次処理したい
TasksNewTask追加タスク数割り当て先ユーザーの累計に加算する

ここでの「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 1SELECT 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で大きめのデータでも型トラブルを避ける
TasksOrderedTaskID昇順でタスクに連番を付けるTaskIDが重複する場合は別列でタイブレークを追加
AssignedTaskNo % UserCnt で割当先を決めて結合“割当先が固定順になる”のがこの方式の肝
Calcユーザー別の追加分を累積しUpdatedTasksを出すROWS句を明示して、ウィンドウの範囲を意図通りにする

具体例:サンプルデータで結果を確認する

動きをイメージしやすいように、簡単なサンプルで確認します。

Users(初期値)

UserIDTotalTasksQueueOrder(TotalTasks昇順)
U0131
U0252
U0383

Tasks(追加したいタスク量)

TaskIDNewTaskTaskNo(0始まり)TaskNo % 3割り当て先(QueueOrder)
1012001(U01)
1021112(U02)
1034223(U03)
1043301(U01)
1052412(U02)
1061523(U03)

出力(UpdatedTasks:そのタスク割り当て後の累計)

ユーザー別にNewTaskを累積して足し戻すことで、各タスク時点の更新後累計が出ます。

TaskID割り当てUserIDNewTaskそのユーザーの初期TotalTasksそのユーザーの累積NewTaskUpdatedTasks(初期+累積)
101U012325
102U021516
103U0348412
104U013358
105U022538
106U0318513

この方式だと、各行の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
U010
U020
U030
TaskIDNewTask
1100
21
31
41

このとき、割り当ては次のようにズレやすくなります(同点時のタイブレークは一例としてUserID昇順とします)。

TaskID都度最小(厳密)での割り当て初期値順ラウンドロビンでの割り当てズレのポイント
1U01(U01=100)U01(U01=100)ここまでは同じ
2U02(U02=1)U02(U02=1)ここも同じ
3U03(U03=1)U03(U03=1)ここも同じ
4U02(最小=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でやる範囲と、処理系を分ける範囲を切り分けてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次