みなさん、エンジニア楽しんでいますか?
現在私は、Goの学習として在庫、受注管理のAPIを開発しています。
商品更新処理に際して、排他制御の楽観ロックを使うことになりました。
ただ、排他制御は私が苦手な分野で、雰囲気でしかわからなかったので深掘りすることにしました。
記事の概要
- 排他制御とは
- 楽観ロックの仕組みと、Goでの書き方
- 悲観ロックの仕組みと、Goでの書き方
- 違いと使い分け
前提
- PostgreSQL 16
- Go 1.22以上(標準ライブラリの
database/sqlを使います)
テーブルは説明用に小さくしています。
-- 商品
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1 -- 楽観ロックで使う。更新するたびに1増やす
);
-- 在庫
CREATE TABLE inventories (
product_id BIGINT PRIMARY KEY REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity >= 0)
);
排他制御とは
排他制御は、同じデータを複数の処理が同時に書き換えようとしたときに、データがおかしくならないように制御する仕組みです。
「排他」は、ほかを締め出すという意味です。
ある処理がデータを書き換えている間はほかの処理を待たせたり、ぶつかったことを見つけて片方を止めたりします。
排他制御がないと何が起きるのか、ネットショップの管理画面で考えてみます。
スタッフのAさんとBさんが、同じ「ノートPC」の編集画面をほぼ同時に開きました。
編集画面には商品名と価格の欄があり、保存ボタンを押すと両方まとめてDBに書き込みます。
時刻 Aさん Bさん
----- ------------------------------ ------------------------------------
10:00 画面を開く(ノートPC/120,000円)
10:01 画面を開く(ノートPC/120,000円)
10:02 価格を110,000円にして保存
10:03 商品名を「ノートPC(新型)」にして保存
価格の欄は120,000円のまま
10:02にAさんが保存すると、価格は110,000円になります。
UPDATE products SET name = 'ノートPC', price = 110000 WHERE id = 1;
10:03にBさんが保存すると、こうなります。
UPDATE products SET name = 'ノートPC(新型)', price = 120000 WHERE id = 1;
Bさんの画面には、10:01に読んだ120,000円が表示されたままでした。
それをそのまま保存したので、Aさんが下げた価格が120,000円に戻ってしまいました。
id | name | price
----+------------------+--------
1 | ノートPC(新型) | 120000
AさんにもBさんにもエラーは出ないので、誰も気づきません。
このように、先に保存した内容があとの保存で上書きされて消えることを、ロストアップデート(lost update)と呼びます。
楽観ロックも悲観ロックも、これを防ぐための排他制御の方法です。
楽観ロック
楽観ロックは、Wikipediaの「編集競合」と同じ考え方です。
Wikipediaでは、誰でもいつでも記事の編集画面を開けます。
そのかわり、自分が編集画面を開いたあとに誰かが先に保存していると、保存しようとしたときに「編集競合」と表示されて保存できません。
楽観ロックも、画面を開くときには何もしません。
保存するときに、自分が読んだあとで誰かが先に保存していないかを確かめます。
同じ商品を2人が同時に編集することはめったにないだろう、と楽観的に考えるので、楽観ロックと呼ばれています。
versionで確かめる
誰かが先に保存したかどうかは、productsテーブルのversion列で確かめます。
versionは最初が1で、保存するたびに1ずつ増やします。
画面を開くときにversionも一緒に読んでおきます。
保存するときは、UPDATEのWHERE句に「画面を開いたときのversion」を入れます。
さっきの例でやってみます。
AさんもBさんも、versionが1のときに画面を開いています。
-- 10:02 Aさんの保存
UPDATE products SET name = 'ノートPC', price = 110000, version = version + 1
WHERE id = 1 AND version = 1
RETURNING version;
version
---------
2
(1 row)
UPDATE 1
Aさんの保存は1件更新されて、versionが1から2になりました。
RETURNING versionは、更新したあとのversionを返してもらうための書き方です。
-- 10:03 Bさんの保存
UPDATE products SET name = 'ノートPC(新型)', price = 120000, version = version + 1
WHERE id = 1 AND version = 1
RETURNING version;
version
---------
(0 rows)
UPDATE 0
DBのversionはもう2なので、WHERE id = 1 AND version = 1に合う行がありません。
Bさんの保存は0件の更新で終わり、Aさんが保存した110,000円はそのまま残りました。
ここで気をつけたいのが、Bさんの保存はエラーにならないことです。
SQLとしては「条件に合う行がなかったので、0件更新した」という普通の結果です。
なので、アプリ側で0件かどうかを見て、Bさんに「先に誰かが保存しました」と伝える必要があります。
Goで書く
私のAPIでは、更新が0件だったときに、追加で確かめていることがあります。
商品が削除されていた場合も、WHERE id = 1に合う行がないので0件になるからです。
versionが変わっていたなら409 Conflict、商品がないなら404 Not Foundを返したいので、0件だったときは商品があるかどうかをもう1回DBに聞いて分けています。
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
)
// 呼び出し側がerrors.Isで見分けるためのエラー
var (
ErrNotFound = errors.New("not found") // 商品がない → 404にする
ErrConflict = errors.New("conflict") // 先に誰かが更新していた → 409にする
)
// productsテーブルの1行を表す構造体
type Product struct {
ID int64
Name string
Price int
Version int // 画面を開いたときに読んだversion
}
// productsテーブルを操作する構造体
type ProductRepository struct {
db *sql.DB // database/sqlの型。DBへの接続を管理している
}
// Updateは楽観ロックで商品を更新する
func (r *ProductRepository) Update(ctx context.Context, p *Product) error {
// WHEREに「画面を開いたときのversion」を入れる。これが楽観ロックの本体。
// 誰かが先に保存していればDBのversionが増えているので、この条件に合う行がなくなり0件になる。
// RETURNINGで、更新したあとのversionを返してもらう
const query = `
UPDATE products
SET name = $1, price = $2, version = version + 1
WHERE id = $3 AND version = $4
RETURNING version`
// QueryRowContext:database/sqlのメソッド。1行を返すSQLを実行する。
// Scan:返ってきた列を変数に入れる。ここでは新しいversionでp.Versionを上書きする。
// 更新が0件で返す行がないときは、sql.ErrNoRows(database/sqlが用意しているエラー)を返す
err := r.db.QueryRowContext(ctx, query, p.Name, p.Price, p.ID, p.Version).Scan(&p.Version)
if err == nil {
return nil // 1件更新できた。p.Versionは1→2のように新しい値になっている
}
if !errors.Is(err, sql.ErrNoRows) {
// 0件以外のエラー(DBにつながらないなど)。
return fmt.Errorf("update product: %w", err)
}
// ここに来るのは0件だったとき。0件になる理由は2つある。
// 1. 商品が削除されていて、id = $3に合う行がない。→ 404にしたい
// 2. 商品はあるが、versionが変わっていた。→ 409にしたい
// どちらなのかは、DBにもう一度聞かないと分からない
var exists bool
// SELECT EXISTS(...):行があればtrue、なければfalseを1つだけ返すSQL
err = r.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM products WHERE id = $1)`, p.ID).Scan(&exists)
if err != nil {
return fmt.Errorf("check product existence: %w", err)
}
if !exists {
return ErrNotFound // 理由1
}
return ErrConflict // 理由2。楽観ロックが「先に誰かが保存した」ことを見つけた
}
呼び出す側(handler)では、返ってきたエラーをHTTPのステータスに置き換えます。
// products:repository.ProductRepository。main.goで作ってhandlerに渡している
// p:repository.Product。画面で入力した値と、画面を開いたときのversionが入っている
// r.Context():*http.Request(net/http)のメソッド。このリクエスト用のcontextを返す
if err := products.Update(r.Context(), &p); err != nil {
// errors.Is:errの中に、指定したエラーが包まれているかを確かめる(標準ライブラリ)
switch {
case errors.Is(err, repository.ErrConflict):
// 409:先に誰かが保存していた。画面に「開き直してください」と出してもらう
// http.Error:net/httpの関数。ステータスコードとメッセージをレスポンスに書く
http.Error(w, "conflict", http.StatusConflict)
case errors.Is(err, repository.ErrNotFound):
// 404:商品が削除されていた
http.Error(w, "not found", http.StatusNotFound)
default:
// それ以外はサーバー側の問題なので500
http.Error(w, "internal server error", http.StatusInternalServerError)
}
return
}
さっきの例をこのコードで動かすと、Aさんの保存は成功し、Bさんの保存には409が返ります。
悲観ロック
悲観ロックは、共有フォルダに置いたExcelファイルと同じ考え方です。
誰かがファイルを開いていると、ほかの人は「使用中」と表示されて、読み取り専用でしか開けません。
編集できるのは、最初の人がファイルを閉じたあとです。
悲観ロックも、データを読むときに行に鍵をかけます。
鍵がかかっている間は、ほかの処理はその行を更新できず、鍵が外れるまで待たされます。
ほかの人とぶつかるかもしれないと悲観的に考えて、先に鍵をかけておくので、悲観ロックと呼ばれています。
PostgreSQLでは、SELECT ... FOR UPDATEで鍵をかけます。
FOR UPDATEで試す
今度は在庫の例です。
ノートPCの在庫が残り1個のときに、2人のお客さんがほぼ同時に購入ボタンを押した場面を、psqlを2つ開いて再現します。
\timing onで、SQLにかかった時間を表示しています。
1つ目のpsqlが1人目の注文です。
FOR UPDATEで在庫を読んだあと、処理に時間がかかっている想定で5秒待ってから在庫を減らします。
-- 1つ目のpsql(1人目の注文)
BEGIN;
SELECT quantity FROM inventories WHERE product_id = 1 FOR UPDATE; -- 1が返る。ここで鍵がかかる
SELECT pg_sleep(5); -- 5秒待つ
UPDATE inventories SET quantity = quantity - 1 WHERE product_id = 1;
COMMIT; -- ここで鍵が外れる
その1秒後に、2つ目のpsqlで2人目の注文として同じ行を読みます。
-- 2つ目のpsql(2人目の注文)
BEGIN;
SELECT quantity FROM inventories WHERE product_id = 1 FOR UPDATE;
quantity
----------
0
(1 row)
Time: 4002.858 ms (00:04.003)
2つ目のSELECTは、すぐには結果が返ってきません。
1つ目がCOMMITして鍵が外れるまで、約4秒待たされます。
待ったあとに読めたのは、1人目が減らしたあとの0です。
在庫が0だと分かるので、2人目には「在庫切れです」と返せます。
もしFOR UPDATEを付けずに読むと、1人目が在庫を減らす前に2人目も読めてしまいます。
2人とも在庫を1と読むので、2人とも「在庫あり」と判断してしまいます。
ちなみに、FOR UPDATEを付けないふつうのSELECTは、鍵がかかっていても待たされません。
1つ目が鍵を持っている間にふつうのSELECTを実行すると、待たずにすぐ1が返ってきます。
Excelでいう、読み取り専用で開いた状態です。
それから、BEGINを書き忘れると鍵がすぐに外れます。
BEGINがないと、SQLを1文実行するたびにトランザクションが終わるからです。
1つ目のpsqlをBEGINなしで実行すると、2つ目のFOR UPDATEは待たずにすぐ返ってきます。
Goで書く
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
)
// 【自分で定義】在庫が足りないときのエラー
var ErrInsufficientStock = errors.New("insufficient stock")
// 【自分で定義】inventoriesテーブルを操作する構造体
type InventoryRepository struct {
db *sql.DB
}
// Decreaseは悲観ロックで在庫を減らす
func (r *InventoryRepository) Decrease(ctx context.Context, productID int64, qty int) error {
// BeginTx:database/sqlのメソッド。トランザクションを始めて、*sql.Txを返す。
// FOR UPDATEの鍵はトランザクションが終わるまで続くので、必ずこの中で使う
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
// 途中でreturnしたときに、トランザクションを取り消して鍵を外す。
// 最後のCommitまで進んだあとに呼ばれても、何も起きない
defer tx.Rollback()
// FOR UPDATEで、この行に鍵をかけてから読む。
// 別の注文がすでに鍵をかけていたら、その注文がCommitするまでここで待たされる。
// 鍵はtxに紐づくので、r.dbではなくtxからSQLを実行する
var quantity int
err = tx.QueryRowContext(ctx,
`SELECT quantity FROM inventories WHERE product_id = $1 FOR UPDATE`, productID).Scan(&quantity)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound // 在庫の行がない
}
if err != nil {
return fmt.Errorf("select inventory: %w", err)
}
// 鍵を持っている間は、ほかの注文がこの行を変えることはできない。
// だから、いま読んだ在庫数を信じて判断してよい
if quantity < qty {
return ErrInsufficientStock // deferのRollbackで鍵が外れる
}
// ExecContext:*sql.Txのメソッド。UPDATEのように行を返さないSQLを実行する
_, err = tx.ExecContext(ctx,
`UPDATE inventories SET quantity = quantity - $1 WHERE product_id = $2`, qty, productID)
if err != nil {
return fmt.Errorf("update inventory: %w", err)
}
// Commit:変更を確定する。ここで鍵が外れて、待っていた注文が動き出す
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
psqlと同じ場面を、Goから再現してみます。
在庫1個のノートPCに、2つの注文を同時に送ります。
// inventories:repository.InventoryRepository。ctx:context.Background()で作ったcontext
// 在庫1個のノートPC(product_id = 1)に、2つの注文をほぼ同時に送る
var wg sync.WaitGroup // sync.WaitGroup:標準ライブラリ。goroutineが全部終わるのを待つための型
for _, name := range []string{"注文1", "注文2"} {
wg.Add(1) // 終わるのを待つgoroutineを1つ増やす
// go:この関数を別のgoroutineで動かす。2つの注文が同時に走る
go func() {
defer wg.Done() // 終わったらWaitGroupに知らせる
err := inventories.Decrease(ctx, 1, 1) // product_id = 1の在庫を1個減らす
fmt.Printf("%s: err=%v\n", name, err)
}()
}
wg.Wait() // 2つの注文が両方終わるまで待つ
注文2: err=<nil>
注文1: err=insufficient stock
先に鍵を取った注文2だけが成功し、注文1は在庫切れになります。
どちらが先に鍵を取るかは実行するたびに変わることがありますが、2つとも成功することはありません。
デッドロック
悲観ロックを使うなら、デッドロックにも注意が必要です。
1つの注文で複数の商品を買うときに起きることがあります。
注文AはノートPC(id = 1)→マウス(id = 2)の順に、注文Bはマウス→ノートPCの順に、カートに入れた順で鍵をかけたとします。
注文A 注文B
------------------------------- -------------------------------
ノートPC(id = 1)に鍵をかける
マウス(id = 2)に鍵をかける
マウスの鍵が外れるのを待つ
ノートPCの鍵が外れるのを待つ
→ お互いが相手の鍵を待ち続けて、どちらも進まない
PostgreSQLはこの状態に気づくと、片方をERROR: deadlock detectedで失敗させます。
防ぐには、鍵をかける順番をそろえます。
たとえば、カートの中身をproduct_idの小さい順に並べ替えてから鍵をかけます。
そうすれば、注文Aも注文BもノートPC(id = 1)から鍵をかけにいくので、注文Bは最初のノートPCで待つだけになり、お互いを待ち合う形になりません。
違いと使い分け
最初の10:00〜10:03の場面で、Bさんから見た違いを比べてみます。
楽観ロックでは、Bさんは10:01にふつうに画面を開けます。
10:03に保存ボタンを押したところで初めて、「先に誰かが保存しました」と知らされます。
Bさんが入力した商品名は保存されないので、画面を開き直して、もう一度入力することになります。
仮に商品の編集に悲観ロックを使ったとすると、Aさんが10:00に画面を開いた時点で行に鍵がかかります。
Bさんは、Aさんが保存して鍵が外れるまで、編集を始められません。
そのかわり、Bさんが編集を始めるときには、Aさんが保存した110,000円が反映された画面になります。
楽観ロックは、ぶつかったあとで片方にやり直してもらう方法です。
悲観ロックは、ぶつからないように片方を待たせる方法です。
では、私のAPIでどちらを使っているかというと、商品の編集には楽観ロックを使っています。
商品の編集は、画面を開いてから保存するまで数分かかることがあります。
悲観ロックだと、その数分間ずっと鍵をかけたままになり、その間はほかの人が保存できません。
そもそも、画面を開くのと保存するのは別々のHTTPリクエストなので、その間ずっとトランザクションを開いたままにしておくのは現実的ではありません。
同じ商品を2人が同時に編集することもめったにないので、ぶつかったときだけ「開き直してください」と返す楽観ロックで足ります。
在庫は逆で、楽観ロックは使っていません。
セールが始まった瞬間などは、同じ商品に注文が集中します。
楽観ロックだと、同時に来た注文のうち1件しか成功せず、残りは全部やり直しになります。
やり直した注文どうしがまたぶつかるので、なかなか処理が進みません。
悲観ロックなら、注文は鍵の順番待ちをして1件ずつ処理されます。
1件の処理は短いので、待つ時間もわずかです。
在庫については、FOR UPDATEを使わずにUPDATE文1つで守る方法もあります。
こちらは実装してから、別の記事に書くつもりです。
まとめ
排他制御は、同じデータを同時に書き換えたときにデータがおかしくならないようにする仕組みで、楽観ロックと悲観ロックは、そのための方法です。
楽観ロックは、保存するときにversionを比べて、先に誰かが保存していないかを確かめる方法でした。
先に保存されていても更新が0件になるだけでエラーにはならないので、0件かどうかはアプリ側で見る必要があります。
悲観ロックは、FOR UPDATEで読むときに行に鍵をかけて、ほかの処理を待たせる方法でした。
鍵はトランザクションが終わるまで続くので、BEGIN(GoならBeginTx)の中で使います。
私のAPIでは、商品の編集には楽観ロックを使い、注文が集中する在庫には楽観ロックを使わないことにしました。