Trung bình Database

Optimistic locking với cột version: chặn lost update trong Laravel (so với lockForUpdate)

Hai người cùng lưu một bản ghi và một thay đổi biến mất không dấu vết. Chặn lost update bằng cột version, trả 409, retry trong job, và biết khi nào lockForUpdate hợp hơn.

05 Th11, 2026 14 phút Cập nhật 23 Th10, 2026 9 Khối code
Sơ đồ
sequenceDiagram participant A as Bạn A participant App as App Laravel participant DB as Database participant B as Bạn B A->>App: load sản phẩm, nhận version 7 B->>App: load sản phẩm, nhận version 7 A->>App: lưu giá kèm version 7 App->>DB: update khi version là 7 DB-->>App: 1 dòng, version thành 8 B->>App: lưu tồn kho kèm version 7 App->>DB: update khi version là 7 DB-->>App: 0 dòng App-->>B: 409 Conflict, load lại

Hai bạn support cùng mở một sản phẩm trong trang admin. Bạn A giảm giá từ 100 xuống 90 rồi bấm lưu. Bạn B vẫn đang nhìn cái form đã load từ một phút trước, sửa lại số tồn kho rồi bấm lưu. Giá quay về 100. Không lỗi, không cảnh báo, không một dòng log. Sáng hôm sau bên tài chính hỏi sao chương trình khuyến mãi không chạy, và không ai giải thích được, vì nhìn từ phía database thì chẳng có gì sai hết.

Đây là bài toán lost update. Mỗi request đều làm một chu trình đọc, sửa, ghi hoàn toàn hợp lệ; bug nằm ở khoảng hở giữa lúc đọc và lúc ghi. Chỉ xài transaction thì không giải quyết được, vì lần đọc nằm ở một HTTP request và lần ghi nằm ở request khác, cách nhau cả phút. Cách sửa là làm cho lệnh ghi có điều kiện: chỉ ghi khi dữ liệu chưa bị đổi kể từ lúc mình đọc.

Lost update xảy ra từng bước

Đây là hai lần lưu nhìn ở mức SQL. Đa số form CRUD gửi lại toàn bộ field chứ không chỉ field đã sửa, và đó chính là lý do lần lưu thứ hai đè lên lần thứ nhất:

-- 10:00:00  Agent A loads product 42: price = 100, stock = 7
-- 10:00:02  Agent B loads product 42: price = 100, stock = 7
-- 10:00:05  Agent A saves a new price
UPDATE products SET price = 90, stock = 7 WHERE id = 42;
-- 10:00:09  Agent B saves a stock correction, using the price it loaded
UPDATE products SET price = 100, stock = 5 WHERE id = 42;
-- Result: price is back to 100. Agent A's change is gone and nobody got an error.

Kể cả khi form chỉ gửi field đã đổi, vấn đề vẫn quay lại ngay khi có giá trị được tính từ dữ liệu đã đọc: "tăng giá 10%", "trừ tồn kho 2". Bất kỳ chu trình đọc, sửa, ghi nào kéo dài theo thời gian đều dính.

Optimistic locking với cột version

Optimistic locking giả định xung đột hiếm khi xảy ra, nên nó kiểm tra lúc ghi chứ không chặn từ đầu. Thêm một cột số nguyên version vô bảng. Ai đọc cũng nhận version kèm dữ liệu. Ai ghi cũng nói "chỉ áp dụng thay đổi này nếu version vẫn là cái mình đã thấy" và tăng version trong cùng một câu lệnh:

-- Each writer sends the version it read. Only one of them can match.
UPDATE products
SET price = 90, version = version + 1
WHERE id = 42 AND version = 7;   -- Agent A: 1 row affected, version is now 8

UPDATE products
SET stock = 5, version = version + 1
WHERE id = 42 AND version = 7;   -- Agent B: 0 rows affected, conflict detected

Điều kiện WHERE ... AND version = 7 biến việc kiểm tra và việc ghi thành một thao tác atomic duy nhất. Database vốn đã tuần tự hóa các lệnh update trên cùng một dòng, nên chỉ đúng một trong hai câu lệnh khớp được. Bên thua thấy 0 dòng bị ảnh hưởng và biết dữ liệu mình đang cầm đã cũ.

sequenceDiagram participant A as Bạn A participant App as App Laravel participant DB as Database participant B as Bạn B A->>App: load sản phẩm, nhận version 7 B->>App: load sản phẩm, nhận version 7 A->>App: lưu giá kèm version 7 App->>DB: update khi version là 7 DB-->>App: 1 dòng, version thành 8 B->>App: lưu tồn kho kèm version 7 App->>DB: update khi version là 7 DB-->>App: 0 dòng App-->>B: 409 Conflict, load lại

Xài updated_at làm version cũng được, nhưng timestamp chỉ chính xác tới giây hoặc mili giây, hai lần lưu trong cùng một tick sẽ cùng khớp. Một số nguyên chỉ tăng lên thì không bao giờ mơ hồ.

Cài đặt trong Laravel

Bắt đầu bằng cột mới. Mặc định bằng 1 để các dòng có sẵn hợp lệ liền:

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->unsignedInteger('version')->default(1)->after('id');
        });
    }

    public function down(): void
    {
        Schema::table('products', function (Blueprint $table) {
            $table->dropColumn('version');
        });
    }
};

save() của Eloquent không cho biết lệnh update ảnh hưởng bao nhiêu dòng, nên mình làm update có điều kiện qua query builder, nơi update() trả về số dòng bị ảnh hưởng:

namespace App\Services;

use App\Exceptions\StaleModelException;
use App\Models\Product;
use Illuminate\Support\Facades\DB;

final class ProductWriter
{
    /**
     * @param array<string, mixed> $changes
     * @throws StaleModelException when someone else saved first
     */
    public function update(int $productId, int $expectedVersion, array $changes): int
    {
        $affected = Product::query()
            ->whereKey($productId)
            ->where('version', $expectedVersion)
            ->update([...$changes, 'version' => DB::raw('version + 1')]);

        if ($affected === 0) {
            throw new StaleModelException(
                Product::class,
                $productId,
                $expectedVersion,
                Product::whereKey($productId)->value('version'),
            );
        }

        return $expectedVersion + 1;
    }
}

Một exception riêng mang đủ thông tin để ghi log và trả lời client. Laravel tự gọi render() trên exception nào có định nghĩa hàm này, nhờ vậy controller gọn gàng:

namespace App\Exceptions;

use RuntimeException;

final class StaleModelException extends RuntimeException
{
    public function __construct(
        public readonly string $model,
        public readonly int $id,
        public readonly int $expectedVersion,
        public readonly ?int $currentVersion,
    ) {
        parent::__construct(
            $currentVersion === null
                ? "{$model} #{$id} no longer exists."
                : "{$model} #{$id} was modified: expected version {$expectedVersion}, found {$currentVersion}."
        );
    }

    public function render(): \Illuminate\Http\JsonResponse
    {
        return response()->json([
            'message' => 'This record was changed by someone else. Reload and try again.',
            'current_version' => $this->currentVersion,
        ], $this->currentVersion === null ? 404 : 409);
    }
}

Version phải đi kèm dữ liệu suốt vòng đời. Với JSON API, trả nó trong body và trong header ETag, đồng thời bắt client gửi lại version mà nó đã sửa. Với form Blade hay Livewire, một field ẩn version làm đúng việc đó:

namespace App\Http\Controllers;

use App\Models\Product;
use App\Services\ProductWriter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class ProductController
{
    public function show(Product $product): JsonResponse
    {
        return response()
            ->json($product)
            ->header('ETag', '"'.$product->version.'"');
    }

    public function update(Request $request, Product $product, ProductWriter $writer): JsonResponse
    {
        $data = $request->validate([
            'price' => ['sometimes', 'integer', 'min:0'],
            'stock' => ['sometimes', 'integer', 'min:0'],
            'version' => ['required', 'integer', 'min:1'],
        ]);

        $version = $writer->update($product->id, $data['version'], collect($data)->except('version')->all());

        return response()->json(['version' => $version])->header('ETag', '"'.$version.'"');
    }
}

Response 409 là tính năng chứ không phải lỗi. Frontend có thể báo "Có người vừa sửa sản phẩm này trong lúc bạn đang chỉnh", load lại dữ liệu mới nhất, và để user áp dụng lại thay đổi một cách có ý thức. Vậy tốt hơn nhiều so với lặng lẽ vứt công sức của người ta.

Retry khi không có người ngồi đó

Background job và scheduled task không có ai để hiện hộp thoại xung đột. Với tụi nó, cách phản ứng đúng khi gặp version cũ thường là: đọc lại, tính lại, thử lại, với số lần có giới hạn. Helper retry() của Laravel hợp với chuyện này:

namespace App\Services;

use App\Exceptions\StaleModelException;
use App\Models\Product;
use Closure;

final class PriceAdjuster
{
    public function __construct(private ProductWriter $writer) {}

    /** Background jobs have no human to ask, so they re-read and retry a few times. */
    public function applyDiscount(int $productId, int $percent, int $maxAttempts = 3): void
    {
        retry($maxAttempts, function () use ($productId, $percent): void {
            $product = Product::findOrFail($productId);
            $newPrice = intdiv($product->price * (100 - $percent), 100);

            $this->writer->update($product->id, $product->version, ['price' => $newPrice]);
        }, sleepMilliseconds: fn (int $attempt): int => random_int(10, 50) * $attempt,
            when: fn (\Throwable $e): bool => $e instanceof StaleModelException);
    }
}

Bước đọc lại là bắt buộc: retry cùng lệnh ghi với cùng version cũ thì fail hoài. Khoảng sleep ngẫu nhiên nhỏ giúp các worker đang tranh nhau giãn ra, không đụng nhau tiếp ở lần thử sau.

Optimistic hay pessimistic: lockForUpdate

Cách còn lại là ngăn xung đột chứ không phát hiện nó. lockForUpdate() sinh ra SELECT ... FOR UPDATE, khóa dòng đó tới khi transaction commit; transaction nào khác muốn lấy cùng lock thì phải chờ:

use App\Models\Product;
use Illuminate\Support\Facades\DB;

// Pessimistic: lock the row, everyone else waits until commit.
DB::transaction(function () use ($productId, $quantity): void {
    $product = Product::whereKey($productId)->lockForUpdate()->firstOrFail();

    if ($product->stock < $quantity) {
        throw new \DomainException('Not enough stock.');
    }

    $product->decrement('stock', $quantity);
}, attempts: 3);

// Often better for a single counter: let the database do the check atomically.
$reserved = Product::whereKey($productId)
    ->where('stock', '>=', $quantity)
    ->decrement('stock', $quantity);

Pessimistic locking chỉ chạy được bên trong một transaction. Mình không thể giữ lock trên một dòng trong lúc admin suy nghĩ hai phút; làm vậy sẽ chặn mọi người ghi khác và cạn connection. Nên hai cách này phục vụ hai kiểu công việc khác nhau:

  • Pessimistic cho các đoạn critical section ngắn, chạy phía server, xung đột thường xuyên: giữ hàng lúc checkout, phân chỗ ngồi, chuyển tiền giữa các ví.
  • Optimistic cho mọi thứ có thời gian suy nghĩ giữa lúc đọc và lúc ghi (form, editor, app mobile đồng bộ thay đổi offline) và cho các dòng được đọc nhiều nhưng ít khi bị sửa cùng lúc.

Đoạn code thứ hai ở trên cho thấy lựa chọn thứ ba, ăn đứt cả hai với các bộ đếm đơn giản: update có điều kiện một cách atomic. stock = stock - 3 WHERE stock >= 3 không cần version, cũng không cần giữ lock qua nhiều round trip.

Kiểm thử nhánh xung đột

Nhánh xung đột là đoạn code mình mong không bao giờ chạy, và chính vì vậy nó cần test. Mô phỏng hai bạn support bằng cách ghi hai lần với cùng version ban đầu:

use App\Exceptions\StaleModelException;
use App\Models\Product;
use App\Services\ProductWriter;

it('rejects the second writer that loaded the same version', function () {
    $product = Product::factory()->create(['price' => 100, 'stock' => 7, 'version' => 1]);
    $writer = app(ProductWriter::class);

    expect($writer->update($product->id, 1, ['price' => 90]))->toBe(2);

    expect(fn () => $writer->update($product->id, 1, ['stock' => 5]))
        ->toThrow(StaleModelException::class);

    expect($product->fresh())
        ->price->toBe(90)
        ->stock->toBe(7)
        ->version->toBe(2);
});

it('returns 409 with the current version over HTTP', function () {
    $product = Product::factory()->create(['version' => 3]);

    $this->actingAs(\App\Models\User::factory()->create())
        ->putJson("/api/products/{$product->id}", ['price' => 50, 'version' => 2])
        ->assertStatus(409)
        ->assertJsonPath('current_version', 3);
});

Test đầu chứng minh lệnh ghi thua cuộc không thay đổi gì (tồn kho vẫn là 7). Test thứ hai chứng minh hợp đồng HTTP mà frontend đang dựa vô. Nếu có writer tự retry, thêm một test cố tình làm lần thử đầu bị cũ, giống như challenge bên dưới.

Những lỗi hay gặp

  • Tăng version trong PHP thay vì trong SQL. ['version' => $model->version + 1] mà không có điều kiện WHERE version = ? thì chỉ là một bộ đếm. Chính cái điều kiện mới là lock.
  • Bỏ qua số dòng bị ảnh hưởng. Trên MySQL, một lệnh UPDATE khớp dòng nhưng không đổi giá trị nào có thể trả về 0 dòng theo mặc định. Vì version luôn tăng, dòng nào khớp cũng chắc chắn thay đổi, nên 0 thật sự có nghĩa là "không khớp".
  • Lấy version từ database ngay lúc lưu. Nếu controller load model rồi xài version hiện tại của nó, phép kiểm tra lúc nào cũng qua. Version phải là cái mà client đã đọc.
  • Mass update bỏ qua version. Một script bảo trì hay Product::query()->update([...]) không tăng version sẽ để client đang cầm dữ liệu cũ ghi đè thay đổi của nó. Mọi writer đều phải tham gia.
  • Retry vô hạn. Retry không giới hạn trên một dòng nóng biến xung đột thành livelock. Giới hạn số lần thử và báo lỗi ra ngoài.

Khi nào nên xài, khi nào không

Xài optimistic locking khi việc sửa diễn ra theo nhịp con người, xung đột không thường xuyên, và mất dữ liệu ghi là không chấp nhận được: trang admin, nội dung CMS, phần cài đặt, đơn hàng do nhân viên chỉnh, đồng bộ offline trên mobile. Chi phí chỉ là một cột số nguyên và một điều kiện WHERE, và nó không bao giờ chặn người đọc hay người ghi khác.

Đừng xài cho các dòng bị tranh chấp dữ dội, nơi nhiều process ghi liên tục, như bộ đếm toàn cục hay tồn kho của món hot trong flash sale; đa số writer sẽ thua và retry. Chỗ đó nên xài update atomic hoặc một pessimistic lock ngắn. Và đừng thêm nó vô bảng chỉ append, hay những dòng chỉ có đúng một process ghi; không có xung đột nào để phát hiện.

Thử thách tương tác

Thử thách

Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.

Đề bài

Viết VersionedStore, một bảng trong bộ nhớ có optimistic locking. insert(id, value) lưu giá trị với version 1. get(id) trả về dòng dữ liệu: ở PHP là array ['value' => ..., 'version' => int] (throw OutOfBoundsException nếu id không tồn tại), ở Python là tuple (value, version) (raise KeyError nếu id không tồn tại). update(id, value, expectedVersion) / update(id, value, expected_version) lưu giá trị mới và trả về version mới (cũ + 1) chỉ khi version đang lưu bằng version mong đợi; ngược lại throw StaleVersionException (Python: raise StaleVersionError) và không đổi gì hết. updateWithRetry(id, mutator, maxAttempts = 3) / update_with_retry(id, mutator, max_attempts=3) đọc dòng hiện tại, gọi mutator(value) để tính giá trị mới, rồi update với version vừa đọc; nếu gặp version cũ thì đọc lại và thử tiếp, tổng cộng tối đa maxAttempts lần (mỗi lần gọi mutator đúng một lần), và throw lại lỗi stale cuối cùng khi mọi lần đều thất bại. Thành công thì trả về version mới.

Code khởi tạo

php
final class StaleVersionException extends RuntimeException {}

final class VersionedStore
{
    /** @var array<string, array{value: mixed, version: int}> */
    private array $rows = [];

    public function insert(string $id, mixed $value): void
    {
        // TODO: store the value with version 1
    }

    /** @return array{value: mixed, version: int} */
    public function get(string $id): array
    {
        // TODO: return ['value' => ..., 'version' => ...] or throw OutOfBoundsException
        throw new OutOfBoundsException('TODO');
    }

    public function update(string $id, mixed $value, int $expectedVersion): int
    {
        // TODO: throw StaleVersionException unless the stored version equals $expectedVersion,
        //       otherwise save the value, bump the version and return the new version
        return 0;
    }

    public function updateWithRetry(string $id, callable $mutator, int $maxAttempts = 3): int
    {
        // TODO: read, compute $mutator($value), update with the version you read;
        //       on StaleVersionException re-read and try again, up to $maxAttempts attempts
        return 0;
    }
}
python
from typing import Any, Callable


class StaleVersionError(Exception):
    pass


class VersionedStore:
    def __init__(self) -> None:
        self._rows: dict[str, tuple[Any, int]] = {}

    def insert(self, id: str, value: Any) -> None:
        # TODO: store (value, 1)
        raise NotImplementedError

    def get(self, id: str) -> tuple[Any, int]:
        # TODO: return (value, version) or raise KeyError
        raise NotImplementedError

    def update(self, id: str, value: Any, expected_version: int) -> int:
        # TODO: raise StaleVersionError unless the stored version matches, else save and bump
        raise NotImplementedError

    def update_with_retry(self, id: str, mutator: Callable[[Any], Any], max_attempts: int = 3) -> int:
        # TODO: read, mutate, update; on StaleVersionError re-read and retry up to max_attempts
        raise NotImplementedError

Lời giải của bạn

Gợi ý

Đã mở hết gợi ý
Gợi ý 1

Toàn bộ cái lock chỉ là một phép so sánh: version đang lưu phải bằng version mong đợi trước khi ghi

Gợi ý 2

Trong vòng retry, đọc lại dòng ở đầu mỗi lần thử, đừng xài lại version cũ

Gợi ý 3

Đếm số lần thử và throw lại exception stale khi đếm tới giới hạn

Lời giải · php
final class StaleVersionException extends RuntimeException {}

final class VersionedStore
{
    /** @var array<string, array{value: mixed, version: int}> */
    private array $rows = [];

    public function insert(string $id, mixed $value): void
    {
        $this->rows[$id] = ['value' => $value, 'version' => 1];
    }

    /** @return array{value: mixed, version: int} */
    public function get(string $id): array
    {
        if (! isset($this->rows[$id])) {
            throw new OutOfBoundsException("Unknown id {$id}");
        }

        return $this->rows[$id];
    }

    public function update(string $id, mixed $value, int $expectedVersion): int
    {
        $current = $this->get($id)['version'];

        if ($current !== $expectedVersion) {
            throw new StaleVersionException("Expected version {$expectedVersion}, found {$current}");
        }

        $this->rows[$id] = ['value' => $value, 'version' => $current + 1];

        return $current + 1;
    }

    public function updateWithRetry(string $id, callable $mutator, int $maxAttempts = 3): int
    {
        for ($attempt = 1; ; $attempt++) {
            $row = $this->get($id);

            try {
                return $this->update($id, $mutator($row['value']), $row['version']);
            } catch (StaleVersionException $e) {
                if ($attempt >= $maxAttempts) {
                    throw $e;
                }
            }
        }
    }
}
Lời giải · python
from typing import Any, Callable


class StaleVersionError(Exception):
    pass


class VersionedStore:
    def __init__(self) -> None:
        self._rows: dict[str, tuple[Any, int]] = {}

    def insert(self, id: str, value: Any) -> None:
        self._rows[id] = (value, 1)

    def get(self, id: str) -> tuple[Any, int]:
        if id not in self._rows:
            raise KeyError(id)
        return self._rows[id]

    def update(self, id: str, value: Any, expected_version: int) -> int:
        _, current = self.get(id)
        if current != expected_version:
            raise StaleVersionError(f"expected version {expected_version}, found {current}")
        self._rows[id] = (value, current + 1)
        return current + 1

    def update_with_retry(self, id: str, mutator: Callable[[Any], Any], max_attempts: int = 3) -> int:
        attempt = 0
        while True:
            attempt += 1
            value, version = self.get(id)
            try:
                return self.update(id, mutator(value), version)
            except StaleVersionError:
                if attempt >= max_attempts:
                    raise

Cùng chủ đề Database