Chống double-click và duplicate request

Người dùng bấm nút "Đặt hàng" hai lần trong 300ms. Hệ thống tạo 2 đơn, trừ tiền 2 lần. Đây là bug kinh điển, và điều đáng nói là disable cái nút không giải quyết được nó — chỉ giấu nó đi.

Tài liệu này đi qua 4 tầng phòng thủ, nói rõ tầng nào chặn được cái gì, tầng nào chỉ là trang trí, và hoàn cảnh nào thì dùng tầng nào. Mọi hành vi PostgreSQL trong bài đều chạy thật trên PostgreSQL 18.4 và dán output vào; mọi hành vi Livewire đều đọc từ source thật của livewire/livewire 4.3 trong project.

Câu một dòng nếu chỉ nhớ được một câu: chặn ở client là để UX, chặn ở database là để đúng. Thiếu tầng client thì xấu; thiếu tầng database thì sai.

Từ điển nhanh

Thuật ngữNghĩa ngắn gọn
idempotentGọi 1 lần hay 10 lần cùng một request đều ra đúng 1 kết quả, không cộng dồn
idempotency keyMã do client sinh, gắn vào request; server dùng nó để nhận ra "cái này tao làm rồi"
race conditionHai luồng chạy song song, kết quả phụ thuộc vào cái nào tới trước
check-then-actKiểm tra rồi mới làm. Giữa hai bước đó có khe hở cho thằng khác chen vào
unique indexChỉ mục database, cấm 2 dòng có cùng giá trị. Do database ép, không phải app
partial unique indexUnique nhưng chỉ áp cho các dòng thoả một điều kiện, ví dụ WHERE status = 'pending'
advisory lockKhoá "tự đặt tên" trong PostgreSQL, không gắn với dòng nào cả, chỉ là một con số
atomic lockKhoá phân tán trên Redis/Memcached, ai SET NX được trước thì thắng
at-least-onceQueue/webhook đảm bảo tin nhắn đến ít nhất 1 lần — nghĩa là có thể 2 lần
PRGPost/Redirect/Get — sau khi POST xong thì redirect, để F5 không POST lại
optimistic lockingKhông khoá gì cả, chỉ kiểm tra "phiên bản dòng còn nguyên không" lúc ghi

Phần 0. Duplicate đến từ đâu — không chỉ mỗi double-click

Đây là chỗ hầu hết người trả lời phỏng vấn hụt điểm: họ chỉ nói về cái nút. Double-click chỉ là 1 trong 6 nguồn, và là nguồn dễ chặn nhất.

#NguồnChuyện gì xảy raDisable nút có cứu không?
1Double-clickNgười dùng bấm 2 lần vì thấy trang không phản hồi
2F5 / back rồi submit lạiTrình duyệt gửi lại POST cũKhông
3Client tự retryRequest timeout ở giây 30, client gửi lại — nhưng server vẫn đang chạy cái cũKhông
4Load balancer / proxy retryUpstream chậm, nginx hoặc SDK tự thử node khácKhông
5Queue at-least-onceJob chạy xong nhưng chết trước khi ack → worker khác chạy lạiKhông
6Webhook bên thứ baCổng thanh toán gửi lại IPN vì lần trước không nhận được 200Không

5 trên 6 nguồn không đi qua trình duyệt của người dùng. Đó là lý do tầng client không bao giờ đủ.

0.1. Vì sao 2 click cách nhau 300ms lại tạo 2 đơn

sequenceDiagram
  autonumber
  participant U as Người dùng
  participant S1 as PHP-FPM worker 1
  participant S2 as PHP-FPM worker 2
  participant DB as PostgreSQL

  U->>S1: Click 1 - POST /orders
  S1->>DB: SELECT * FROM orders WHERE cart_id=9
  DB-->>S1: 0 dong
  Note over U,S2: t = 300ms, worker 1 van dang chay
  U->>S2: Click 2 - POST /orders
  S2->>DB: SELECT * FROM orders WHERE cart_id=9
  DB-->>S2: 0 dong (worker 1 chua COMMIT)
  S1->>DB: INSERT don hang
  S2->>DB: INSERT don hang
  DB-->>U: 2 don hang

Điểm mấu chốt: worker 2 không thấy việc worker 1 đang làm dở, vì worker 1 chưa COMMIT. Đây là check-then-act kinh điển. Kiểm chứng thật:

-- Hai session chạy song song, KHÔNG có unique index
-- session A: BEGIN; SELECT count(*) WHERE idem_key='k-race'; -- thấy 0
-- session B: BEGIN; SELECT count(*) WHERE idem_key='k-race'; -- cũng thấy 0
-- cả hai cùng INSERT rồi COMMIT

SELECT count(*) AS so_don_tao_ra FROM tmp_orders_noidx WHERE idem_key='k-race';
 so_don_tao_ra
---------------
             2
(1 row)

Kết luận đã kiểm chứng: if (đã có đơn) return; trong PHP không chống được duplicate. Nó chỉ giảm xác suất. Cùng kịch bản đó, khi có unique index, kết quả là 1 đơn (xem phần 1.4).


Phần 1. Bốn tầng phòng thủ — cơ chế thật của từng tầng

flowchart TB
  U["Người dùng bấm Lưu"]
  subgraph L1["Tầng 1 — Client (trình duyệt)"]
    A1["Disable nút, readonly input
wire:loading, PRG"] end subgraph L2["Tầng 2 — Biên request (nhận diện)"] A2["Idempotency-Key header
hoặc one-time form token"] end subgraph L3["Tầng 3 — Ứng dụng (loại trừ lẫn nhau)"] A3["Cache::lock (Redis)
advisory lock, SELECT FOR UPDATE"] end subgraph L4["Tầng 4 — Database (sự thật cuối cùng)"] A4["UNIQUE index
partial unique index"] end N1["Bỏ được nếu chấp nhận UI xấu"] N2["KHÔNG BAO GIỜ bỏ"] OK["Đúng 1 bản ghi"] U --> A1 A1 --> A2 A2 --> A3 A3 --> A4 A4 --> OK A1 -.->|"chặn 1/6 nguồn — vì UX"| N1 A4 -.->|"chặn 6/6 nguồn — vì đúng đắn"| N2

1.0. Đây là 4 tầng chồng lên nhau, KHÔNG phải 4 phương án thay thế nhau

Đọc sơ đồ trên dễ hiểu nhầm thành "chọn một trong bốn". Không phải. Request đi xuyên qua cả bốn tầng, và mỗi tầng bắt một loại lỗi khác nhau:

TầngBắt loại lỗi nàoCó bỏ được không?
1. ClientNgười dùng bấm 2 lầnBỏ được — chỉ mất UX, không sai dữ liệu
2. Idempotency keyCùng một ý định gửi lại sau một khoảng thời gian (client retry, proxy retry, webhook retry, resubmit form)Bỏ được nếu client không bao giờ retry
3. LockHai luồng chạy cùng lúc đụng nhauBỏ được nếu tầng 4 đã đủ (xem dưới)
4. Unique indexMọi thứ ba tầng trên để lọt, kể cả bug của chính bạnKhông bao giờ

Tầng 2 và tầng 3 chống hai trục khác nhau. Tầng 2 chống trùng theo thời gian — hai request cách nhau 30 giây. Tầng 3 chống trùng theo đồng thời — hai request chạy song song ngay lúc này. Nhiều người tưởng chúng thay thế nhau, thật ra chúng bắt hai chuyện khác nhau.

Có tầng 2 rồi thì còn cần tầng 3 không?

Thường là không. Lý do: bảng idempotency_keys với trạng thái in_progress chính nó đã là một cái lock — request thứ hai thấy dòng in_progress và bị trả 409, đúng bằng việc lock từ chối. Cài tầng 2 đúng cách thì tầng 3 thành thừa cho việc tạo bản ghi.

Ba trường hợp vẫn cần tầng 3:

Trường hợpVì sao tầng 2 không lo được
Không có client để mà gửi key: queue job, cron, artisan commandKhông ai sinh idempotency key cho bạn cả
Chống race giữa các thao tác khác nhau, không phải trùng lặp
(vd "trừ tiền" và "hoàn tiền" cùng chạm vào một ví)
Hai thao tác đó có key khác nhau nên tầng 2 cho qua cả hai — vẫn hỏng số dư
Cần đảm bảo toàn hệ thống chỉ 1 tiến trình chạy (vd đồng bộ tồn kho)Đây là loại trừ lẫn nhau, không phải chống trùng

Thực tế mỗi hoàn cảnh dùng mấy tầng?

Hoàn cảnhTầng 1
Client
Tầng 2
Idem key
Tầng 3
Lock
Tầng 4
Unique index
Form admin nội bộ (CRUD)ThừaThừa
Checkout trên webNên có (token server sinh)Nên có (FOR UPDATE trên cart)
API công khai / mobileKhông áp dụngBắt buộcThường thừa
Webhook thanh toánKhông áp dụngCó — event_id đóng vai keyThừa
Queue job gửi emailKhông áp dụngKhông có client để sinh keyCó, hoặc bảng sent_notifications (trên bảng đó)
Cron đồng bộ tồn khoKhông áp dụngThừaBắt buộcKhông áp dụng

Quy tắc 3 dòng nếu phải nhớ:

  1. Tầng 4 luôn luôn có. Không có nó thì mọi tầng trên chỉ là giảm xác suất.
  2. Tầng 1 có nếu có giao diện. Rẻ, và nó chặn ~95% ca thực tế.
  3. Giữa tầng 2 và tầng 3, chọn theo câu hỏi: "Việc này có sinh ra một dòng DB để mà ép unique không?" — Có, và client có thể retry → tầng 2. Không sinh dòng nào, hoặc side effect nằm ngoài DB → tầng 3.

Bảng chọn đầy đủ hơn (có cả rate limit, optimistic locking, content hash) ở Phần 4.2; ba combo hay dùng nhất ở Phần 4.3.

1.1. Tầng 1 — Client: Livewire tự làm gì, và chỗ nó KHÔNG làm

Livewire 4 tự động khoá form khi submit. Đây không phải lời đồn — đọc thẳng từ vendor/livewire/livewire/dist/livewire.esm.js, feature js/features/supportDisablingFormsDuringRequest.js:

on("directive.init", ({ el, directive, cleanup, component }) => setTimeout(() => {
    if (directive.value !== "submit")   // <-- CHỈ wire:submit
        return;
    el.addEventListener("submit", () => {
        let componentId = ...;
        let cleanup2 = disableForm(el);  // disable + readonly toàn bộ form
        cleanups.add(componentId, cleanup2);
    });
}));

on("commit", ({ component, respond }) => {
    respond(() => {                      // mở khoá khi response về
        cleanups.each(component.id, (i) => i());
        cleanups.remove(component.id);
    });
});

disableForm() đi qua từng phần tử trong form: <select>, <button type="submit"> và các input không hỗ trợ readonly (checkbox, radio, file, range…) thì set disabled; <input><textarea> thì set readonly. Phần tử có wire:ignore bị bỏ qua.

Ba cái bẫy đọc được từ chính đoạn source trên

BẫyVì saoCách xử lý
wire:click không được bảo vệĐiều kiện directive.value !== "submit" loại hết mọi directive khácTự thêm wire:loading.attr="disabled" + wire:target
Nút submit đặt ngoài <form> (dùng thuộc tính form="id")disableForm chỉ duyệt cây con của form: if (!formEl.contains(el)) return;Đưa nút vào trong form, hoặc tự khoá
Phần tử có wire:ignoreBị skip() — Alpine không đi vào cây con đóĐặt wire:ignore ở nhánh không chứa nút submit

Cách viết đúng cho từng loại nút

{{-- ĐÚNG: wire:submit — Livewire tự khoá, không cần thêm gì --}}
<form wire:submit="placeOrder">
    <input wire:model="address">
    <button type="submit">Đặt hàng</button>
</form>

{{-- SAI: wire:click trần — bấm 2 lần là 2 request --}}
<button wire:click="placeOrder">Đặt hàng</button>

{{-- ĐÚNG: wire:click phải tự khoá. wire:target để nút không bị khoá oan
     khi một action khác trong cùng component đang chạy --}}
<button wire:click="placeOrder"
        wire:loading.attr="disabled"
        wire:target="placeOrder">
    Đặt hàng
</button>

Trong project laravel-livewire, nút "Load more" ở resources/views/livewire/product-reviews.blade.php:56 làm đúng mẫu này:

<button type="button"
        wire:click="loadMore"
        wire:loading.attr="disabled"
        wire:target="loadMore"
        class="... disabled:cursor-not-allowed disabled:opacity-50">
    Load more
</button>

Livewire xử lý click thứ hai thế nào nếu bạn quên khoá?

Không phải "bỏ qua". Đọc js/request/messageBus.jsjs/request/interactions.js:

// messageBus.js — gom action trong cùng 5ms vào 1 message
messageBuffer(message, callback) {
    if (this.bufferingMessages.has(message)) return;
    this.bufferingMessages.add(message);
    setTimeout(() => { callback(); this.bufferingMessages.delete(message); }, 5);
}

// interactions.js — action mới tới khi đã có message đang bay
let message = bus.activeMessageMatchingScope(action);
if (message) {
    if (action.metadata.type === "poll") return action.cancel();  // poll thì huỷ
    action.defer();                                              // còn lại thì HOÃN
    message.addInterceptor(({ onFinish }) => {
        onFinish(() => action.fire());                           // bắn sau khi cái cũ xong
    });
}
flowchart LR
  C1["Click 1"] --> B{"Click 2 cách bao xa?"}
  B -- "dưới 5ms" --> M["Gộp vào 1 message
→ chạy 1 lần"] B -- "trên 5ms" --> D["action.defer()
chờ request 1 xong
rồi BẮN TIẾP"] D --> R["→ chạy 2 lần, tuần tự"]

Đây là điểm gây hiểu nhầm nhất. Livewire không vứt click thứ hai. Nó xếp hàng và chạy sau. Với người dùng thật (click cách nhau 150–400ms), kết quả là 2 lần placeOrder() chạy nối đuôi nhau — không có race, nhưng vẫn 2 đơn hàng. Kiểu duplicate này còn khó phát hiện hơn race, vì log trông hoàn toàn bình thường.

Nguồn: đọc trực tiếp từ dist/livewire.esm.js của livewire/livewire 4.3. Chưa chạy test bằng trình duyệt thật để đo con số 5ms trong thực tế.

Form HTML thuần (không Livewire): PRG + one-time token

sequenceDiagram
  participant B as Trình duyệt
  participant S as Server
  B->>S: POST /orders (token=abc)
  S->>S: Token abc con hieu luc? Xoa no di
  S-->>B: 302 Redirect /orders/1042
  B->>S: GET /orders/1042
  S-->>B: 200 Trang don hang
  Note over B: F5 luc nay chi lap lai GET, khong POST lai
// Sinh token lúc render form
<input type="hidden" name="form_token" value="{{ $token = Str::uuid() }}">
@php session()->push('open_form_tokens', $token) @endphp

// Trong controller: dùng một lần rồi vứt
$tokens = session()->pull('open_form_tokens', []);
abort_unless(in_array($request->form_token, $tokens, true), 419, 'Form đã gửi rồi');
session()->put('open_form_tokens', array_diff($tokens, [$request->form_token]));

return redirect()->route('orders.show', $order);   // <-- P-R-G

Đừng nhầm với CSRF token. CSRF token của Laravel không chống double-submit: mặc định nó sống theo session, dùng lại được bao nhiêu lần cũng được. Nó chống "trang web khác gửi hộ", không chống "gửi hai lần".

1.2. Tầng 2 — Idempotency key: cách duy nhất chống retry

Ý tưởng: client sinh một mã ngẫu nhiên cho mỗi ý định (không phải mỗi request), gắn vào header. Server retry bao nhiêu lần cũng gửi lại đúng mã đó. Server thấy mã đã xử lý thì trả lại kết quả cũ, không làm lại.

sequenceDiagram
  autonumber
  participant C as Client
  participant S as Server
  participant DB as PostgreSQL

  C->>S: POST /orders
Idempotency-Key: 8f3a-... S->>DB: INSERT idempotency_keys (key, status=in_progress) DB-->>S: OK S->>DB: Tao don hang S->>DB: UPDATE idempotency_keys SET status=done, response=... S-->>C: 201 Created (mat goi tin tren duong ve) Note over C: Timeout - client retry C->>S: POST /orders
Idempotency-Key: 8f3a-... (CUNG MA) S->>DB: INSERT ... ON CONFLICT DO NOTHING DB-->>S: INSERT 0 0 - da co S->>DB: SELECT response FROM idempotency_keys S-->>C: 201 Created (tra lai y nguyen ket qua cu)

Cụ thể: client sinh key lúc nào, và đổi lúc nào

Quy tắc gốc, mọi thứ dưới đây suy ra từ đây:

Một ý định = một key. Key sinh ra lúc ý định ra đời (khi người dùng bắt đầu một việc), không phải lúc bấm nút. Key chết khi người dùng biết chắc kết quả của việc đó.

Sinh lúc nào — theo từng loại client
Loại clientSinh key lúc nàoCất ở đâuSinh key mới khi nào
Form Blade thuần Lúc server render form <input type="hidden"> Mỗi lần render lại form
Livewire Trong mount() public property của component Sau khi submit thành công, gán lại trong chính action đó
SPA (React/Vue) Lúc mở màn hình / mount component, hoặc lúc người dùng gõ ký tự đầu tiên useRef / state của component Sau khi nhận response cuối cùng (2xx)
Mobile app Lúc tạo bản nháp local (draft order trong SQLite) Một cột của bản nháp Khi bản nháp hoàn tất hoặc bị huỷ
Server-to-server (job, tích hợp) Lúc ghi dòng outbox, trước khi gửi đi Một cột của bảng outbox Không bao giờ — mọi lần retry dùng lại đúng key đó
Bạn là bên NHẬN webhook Không sinh gì cả Dùng event_id của bên gửi làm key (xem Case 2 ở Phần 6)
Vòng đời một key
flowchart LR
  A["Ý định ra đời
(render form / mount / tạo draft)"] --> B["Sinh key
UUID v4"] B --> C["Gửi request
kèm key"] C --> D{"Nhận được gì?"} D -- "2xx" --> E["Ý định XONG
vứt key"] D -- "5xx / timeout / mất mạng" --> F["GIỮ NGUYÊN key
retry đúng key đó"] F --> C D -- "409 đang xử lý" --> G["GIỮ NGUYÊN key
chờ rồi thử lại"] G --> C D -- "4xx validation" --> H["Sửa dữ liệu
server đã xoá dòng key"] H --> C E --> A
Đổi hay giữ — theo mã trả về
Server trảClient làm gì với keyServer làm gì với dòng keyVì sao
2xx Vứt, lần sau sinh key mới Giữ 24h kèm response Ý định đã xong. Giữ key lại thì lần sau khách làm việc tương tự sẽ bị chặn oan
5xx / timeout / mất mạng Giữ nguyên, retry đúng key đó Giữ nguyên dòng Đây chính là lý do idempotency key tồn tại. Đổi key lúc này = tạo bản ghi thứ hai
409 (request đầu đang chạy) Giữ nguyên, chờ theo Retry-After Giữ nguyên dòng in_progress Request đầu sắp xong, kết quả sẽ nằm sẵn ở đó
4xx (validation, phân quyền) Giữ nguyên cũng được — vì server đã dọn XOÁ dòng key Chưa có gì thay đổi trong hệ thống. Không xoá thì khách sửa form gửi lại sẽ đụng request_hash khác và ăn 422
422 "key đã dùng cho request khác" Sinh key mới Giữ nguyên Client đang dùng sai. Đây là lỗi lập trình, phải báo lên log

Dòng dễ bỏ sót nhất là dòng 4xx. Nếu server lưu key cả khi validation trượt, thì khách sửa lại địa chỉ rồi bấm gửi → cùng key, payload khác → request_hash không khớp → server trả 422 và khách không đặt được đơn. Cách chữa gọn nhất là để server xoá dòng key khi response là 4xx (trừ 409), như vậy client không phải biết gì cả.

// Trong middleware Idempotent, sau khi có $response:
$code = $response->getStatusCode();

if ($code >= 400 && $code < 500 && $code !== 409) {
    // Chưa đổi gì trong hệ thống -> coi như chưa từng có key này
    DB::table('idempotency_keys')->where('key', $key)->delete();
    return $response;
}

DB::table('idempotency_keys')->where('key', $key)->update([
    'status' => 'done',
    'response_code' => $code,
    'response_body' => $response->getContent(),
]);
Code cụ thể cho từng loại client

1. Form Blade thuần — server sinh, client không viết dòng JS nào:

{{-- Sinh mỗi lần render form. F5 trang form = key mới = ý định mới, đúng ý. --}}
<form method="POST" action="{{ route('orders.store') }}">
    @csrf
    <input type="hidden" name="idem_key" value="{{ (string) Str::uuid() }}">
    ...
</form>

2. Livewire — sinh trong mount(), làm mới sau khi thành công:

class Checkout extends Component
{
    public string $idemKey = '';

    public function mount(): void
    {
        $this->idemKey = (string) Str::uuid();
    }

    public function placeOrder(): void
    {
        $this->validate();      // trượt validate -> giữ nguyên key, chưa gửi đi đâu cả

        $order = app(PlaceOrder::class)->run($this->cart, $this->idemKey);

        // Ý định đã xong. Không làm mới thì khách đặt đơn thứ hai
        // trên cùng màn hình sẽ bị chặn oan.
        $this->idemKey = (string) Str::uuid();

        $this->redirect(route('orders.show', $order));
    }
}

Bẫy riêng của Livewire: $idemKey là public property nên nó nằm trong snapshot gửi về client mỗi request. Đừng đặt tên kiểu $secretKey và đừng nhét dữ liệu nhạy cảm vào — UUID thì vô hại. Chi tiết cơ chế snapshot xem Livewire public property.

3. SPA / mobile — sinh lúc mở màn hình, không phải lúc bấm nút:

// ĐÚNG: useRef giữ nguyên key qua mọi lần render lại và mọi lần retry
const idemKey = useRef(crypto.randomUUID());

async function submit(payload) {
    const res = await fetch('/api/orders', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Idempotency-Key': idemKey.current,   // retry vẫn đúng key này
        },
        body: JSON.stringify(payload),
    });

    if (res.ok) {
        idemKey.current = crypto.randomUUID();    // ý định xong -> key mới
    }
    return res;
}

// SAI: sinh trong hàm submit -> mỗi lần bấm/retry một key -> cơ chế vô dụng
async function submitSai(payload) {
    const key = crypto.randomUUID();              // <-- sai chỗ
    ...
}

4. Server-to-server — key sinh cùng lúc với dòng outbox, không đổi qua mọi lần retry:

// Lúc tạo việc cần gửi đi
DB::table('outbox')->insert([
    'idem_key' => (string) Str::uuid(),   // sinh MỘT lần, ở đây
    'endpoint' => 'partner.create_shipment',
    'payload'  => json_encode($payload),
    'status'   => 'pending',
]);

// Trong job gửi đi — chạy lại bao nhiêu lần cũng dùng đúng key đó
Http::withHeaders(['Idempotency-Key' => $row->idem_key])
    ->post($url, json_decode($row->payload, true));
Bốn cách sinh key SAI hay gặp
Cách làm saiHỏng thế nào
Sinh trong hàm onClick / lúc gọi APIMỗi lần bấm và mỗi lần retry một key khác → cơ chế vô dụng hoàn toàn
Dùng timestamp (Date.now())Hai request cùng mili-giây trùng key; hai lần bấm cách 1 giây thì khác key → sai cả hai chiều
Dùng hash của payload làm keyChính là content hash — chặn oan lần mua lại cố ý. Xem phần 1.5
Một key dùng chung cho cả luồng nhiều bước (checkout 3 bước)Bước 2 và bước 3 có payload khác nhau → đụng request_hash → 422. Mỗi bước ghi state là một ý định, một key riêng

Hệ quả: vì sao "F5" có hai nghĩa khác nhau

Đây là chi tiết quyết định cả cơ chế, và cũng là chỗ hay bị hiểu nhầm nhất: key gắn với vòng đời của cái form, không gắn với vòng đời của cú click.

Hệ quả là "F5" không phải một chuyện, mà là hai chuyện trái ngược nhau:

F5 ở đâuTrình duyệt làm gìKey ra saoTầng 2 bắt?nên bắt không?
F5 trên trang kết quả sau POST — trình duyệt hiện "Confirm Form Resubmission" Gửi lại nguyên xi POST cũ, kể cả hidden input Giống key cũ Nên — đây là duplicate thật
Back/Forward quay lại trang form rồi bấm gửi lần nữa Lấy DOM cũ từ bfcache, hidden input còn nguyên key cũ Giống key cũ Nên
F5 trên chính trang form (chưa submit), rồi điền lại và gửi Tải lại trang, server render form mới Key mới Không Không cần — đây là ý định mới, đơn mới là đúng
Livewire: F5 → component chạy lại mount() Component mount mới hoàn toàn Key mới Không Không cần

Nói cách khác: key mới sau khi F5 trang form không phải lỗ hổng, đó là thiết kế đúng. Nếu key sống dai hơn cái form thì khách F5 rồi đặt đơn thứ hai một cách cố ý sẽ bị chặn oan — đúng cái bệnh của content hash ở phần 1.5.

Nhưng có một ca F5 mà tầng 2 KHÔNG cứu được

t=0    Khách điền form, bấm "Đặt hàng"  (key = A)
t=2s   Mạng chậm, trang đang quay quay, khách sốt ruột
t=3s   Khách F5  ->  request cũ bị huỷ ở phía trình duyệt,
                     NHƯNG server vẫn đang chạy nó và sẽ tạo đơn
t=5s   Form mới render ra  (key = B)
t=20s  Khách điền lại, bấm "Đặt hàng"  ->  key B, khác key A
       -> Tầng 2 cho qua  ->  ĐƠN THỨ HAI

Huỷ request ở trình duyệt không huỷ được tiến trình PHP đang chạy trên server. Hai request mang hai key khác nhau nên tầng 2 coi đó là hai ý định — theo đúng luật của nó. Chỉ tầng 4 chặn được:

CREATE UNIQUE INDEX CONCURRENTLY one_pending_order_per_cart
    ON orders (cart_id) WHERE status = 'pending';

Đây là ví dụ rõ nhất cho câu "tầng 4 không bao giờ được bỏ": nó bắt đúng cái mà tầng 2 không thể bắt, vì cái đó nằm ngoài định nghĩa của tầng 2.

Bảng lưu key

CREATE TABLE idempotency_keys (
    key           text PRIMARY KEY,
    user_id       bigint NOT NULL,
    endpoint      text   NOT NULL,
    request_hash  text   NOT NULL,   -- chống dùng lại key cho payload khác
    status        text   NOT NULL,   -- in_progress | done
    response_code smallint,
    response_body jsonb,
    created_at    timestamptz NOT NULL DEFAULT now()
);

-- Dọn rác: key sống 24h là đủ cho mọi vòng retry hợp lý
CREATE INDEX ON idempotency_keys (created_at);
// app/Http/Middleware/Idempotent.php — bản tối thiểu chạy được
public function handle(Request $request, Closure $next)
{
    $key = $request->header('Idempotency-Key');
    if (! $key) {
        return $next($request);      // hoặc abort(400) nếu API bắt buộc
    }

    $hash = hash('xxh128', $request->getContent());

    // insertOrIgnore = INSERT ... ON CONFLICT DO NOTHING, trả về số dòng đã ghi
    $isFirst = DB::table('idempotency_keys')->insertOrIgnore([
        'key' => $key, 'user_id' => $request->user()->id,
        'endpoint' => $request->path(), 'request_hash' => $hash,
        'status' => 'in_progress', 'created_at' => now(),
    ]) === 1;

    if (! $isFirst) {
        $row = DB::table('idempotency_keys')->where('key', $key)->first();

        // Cùng key nhưng payload khác = client dùng sai. Phải báo lỗi, không được im lặng.
        abort_if($row->request_hash !== $hash, 422, 'Idempotency-Key đã dùng cho request khác');

        // Request đầu còn đang chạy. 409 để client thử lại sau, không phải 500.
        abort_if($row->status === 'in_progress', 409, 'Đang xử lý, thử lại sau');

        return response($row->response_body, $row->response_code);
    }

    $response = $next($request);

    DB::table('idempotency_keys')->where('key', $key)->update([
        'status' => 'done',
        'response_code' => $response->getStatusCode(),
        'response_body' => $response->getContent(),
    ]);

    return $response;
}

Chi tiết dễ làm sai: phải ghi in_progress trước khi chạy business logic và ngoài transaction chính. Nếu ghi trong cùng transaction, request thứ hai sẽ không thấy dòng đó (chưa commit) và cả hai cùng chạy — quay lại đúng bài toán cũ.

1.3. Tầng 3 — Lock: khi công việc không sinh ra một dòng nào để mà unique

Unique index chỉ dùng được khi kết quả là "một dòng mới". Còn những việc như "gửi email xác nhận", "gọi API cổng thanh toán", "cộng điểm thưởng" thì không có dòng nào để ép unique. Lúc đó dùng lock.

a) Cache::lock() — atomic lock trên Redis

use Illuminate\Support\Facades\Cache;

// block(0) = không chờ. Ai tới sau thì trượt luôn, đúng ý cho chống double-click.
$lock = Cache::lock("place-order:user:{$user->id}", 10);

if (! $lock->get()) {
    return back()->with('warning', 'Đơn của bạn đang được xử lý.');
}

try {
    $this->placeOrder($user);
} finally {
    $lock->release();      // finally, luôn luôn — quên là khoá tới khi hết TTL
}

Laravel 12 có sẵn bản gọn hơn, tự release, tự chờ tối đa 10 giây (Illuminate/Cache/Repository.php:678):

Cache::withoutOverlapping("place-order:user:{$user->id}", function () use ($user) {
    $this->placeOrder($user);
});

Chạy lock ở process này, release ở process khác (ví dụ đưa vào queue job) thì truyền owner token:

$lock = Cache::lock('processing', 120);

if ($lock->get()) {
    ProcessPodcast::dispatch($podcast, $lock->owner());
}

// Trong job:
Cache::restoreLock('processing', $this->owner)->release();

b) PostgreSQL advisory lock — lock nằm cùng chỗ với dữ liệu

pg_advisory_xact_lock nhận một số nguyên và giữ khoá tới khi transaction kết thúc. Không cần nhớ release. Kiểm chứng thật, 2 session song song:

-- session A
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('order:user:9'));
SELECT clock_timestamp();   -- lấy được lock
SELECT pg_sleep(2);
COMMIT;

-- session B (bắt đầu sau 0.3s)
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('order:user:9'));
SELECT clock_timestamp();   -- phải đợi
COMMIT;
-- session A
       a_lay_duoc_lock
------------------------------
 2026-09-08 08:37:29.33455+00

-- session B
        b_lay_duoc_lock
-------------------------------
 2026-09-08 08:37:31.443289+00

B lấy được lock 2.109 giây sau A — đúng bằng thời gian A giữ transaction. Trong Laravel:

DB::transaction(function () use ($user) {
    // crc32 để ép về int4, vì pg_advisory_xact_lock nhận bigint hoặc 2 x int4
    DB::selectOne('SELECT pg_advisory_xact_lock(?)', [crc32("order:user:{$user->id}")]);

    // Từ đây tới hết transaction, không worker nào khác vào được với cùng user
    $this->placeOrder($user);
});   // COMMIT tự nhả lock

c) SELECT ... FOR UPDATE — khoá đúng dòng đang sửa

DB::transaction(function () use ($cartId) {
    $cart = Cart::whereKey($cartId)->lockForUpdate()->first();

    if ($cart->status !== 'open') {
        return;                     // request thứ hai rơi vào đây
    }

    $order = Order::createFromCart($cart);
    $cart->update(['status' => 'converted']);
});

Cách này gọn nhất khi đã có sẵn một dòng để khoá (giỏ hàng, ví, tài khoản). Nó vừa chống duplicate vừa chống race về số dư trong một lần.

1.4. Tầng 4 — Unique index: chốt chặn cuối, không được bỏ

Chạy lại đúng kịch bản race ở Phần 0, lần này có unique index:

CREATE UNIQUE INDEX ON tmp_orders_idx (idem_key);

-- session A: BEGIN; INSERT ... VALUES (9,'k-race',500); pg_sleep(2); COMMIT;
-- session B (sau 0.3s): INSERT ... ON CONFLICT (idem_key) DO NOTHING;
-- session B
INSERT 0 0
Time: 1727.969 ms (00:01.728)

 so_don_tao_ra
---------------
             1
(1 row)

Hai điều đọc được từ output này:

  1. Chỉ 1 đơn được tạo. Database ép, không cần app hợp tác.
  2. Session B bị chặn 1.728 giây. Nó phải đợi A commit mới biết có xung đột hay không. Đây là chi phí ẩn ít ai nói tới: unique index biến "2 request song song" thành "2 request nối đuôi". Transaction giữ lâu bao nhiêu thì request kia đợi lâu bấy nhiêu.

Ba cách viết, ba ngữ nghĩa khác nhau

Câu lệnhTrùng thì saoDùng khi
INSERT trầnNém lỗi 23505Trùng là bất thường, muốn thấy trong log
ON CONFLICT DO NOTHINGIm lặng, INSERT 0 0Trùng là bình thường (webhook, retry)
ON CONFLICT DO UPDATE ... RETURNINGTrả về dòng cũCần biết id của bản ghi đã tồn tại

Partial unique index — ép luật nghiệp vụ, không chỉ ép trùng key

"Mỗi user chỉ được có 1 đơn đang chờ thanh toán" — không cần idempotency key, chỉ cần một index:

CREATE UNIQUE INDEX one_pending_order
    ON orders (user_id) WHERE status = 'pending';

Kiểm chứng:

INSERT INTO tmp_orders2 (user_id, status, idem_key) VALUES (7,'pending','a');  -- OK
INSERT INTO tmp_orders2 (user_id, status, idem_key) VALUES (7,'pending','b');  -- ?
INSERT 0 1
ERROR:  duplicate key value violates unique constraint "tmp_one_pending"
DETAIL:  Key (user_id)=(7) already exists.

Đơn cũ chuyển sang paid thì index nhả ra, tạo đơn pending mới được ngay:

UPDATE 1
INSERT 0 1
 id | status  | idem_key
----+---------+----------
  1 | paid    | a
  3 | pending | b
(2 rows)

Để ý id nhảy từ 1 sang 3. Lần INSERT thất bại vẫn đã lấy một số từ sequence, và sequence không quay lui. Nếu mã đơn hàng của bạn hiển thị cho khách và lấy thẳng từ bigserial, khách sẽ thấy số nhảy cóc — và đối thủ đếm được lượng đơn thất bại của bạn. Mã hiển thị nên sinh riêng.


1.5. Chen ngang — vì sao không dùng content hash thay idempotency key?

Câu hỏi rất tự nhiên: thay vì bắt client sinh key, sao server không tự băm nội dung request (path + payload)? Thấy hash trùng mà đang xử lý dở thì reject. Client đỡ phải làm gì cả.

Cách này có thật, tên là request fingerprinting (băm nội dung request để nhận diện trùng), và nó chống double-click rất tốt. Nhưng nó không thay được idempotency key, vì hai thứ trả lời hai câu hỏi khác nhau:

Cơ chếTrả lời câu hỏi
Content hash"Hai request này có nội dung giống nhau không?"
Idempotency key"Hai request này có phải cùng một ý định không?"

Chỉ client mới biết ý định. Server nhìn vào bytes thì phải đoán — và đoán sai theo cả hai chiều.

Chiều sai 1: chặn nhầm request thật (false positive)

Hash giống nhau không có nghĩa là trùng lặp:

Tình huốngPayloadÝ định thật
Khách nạp ví 100k, xong nạp thêm 100k nữagiống hệt2 lần nạp
Mua 1 sản phẩm cho mình, rồi mua đúng cái đó tặng bạn (cùng địa chỉ)giống hệt2 đơn
Bấm "+1" vào giỏ 3 lần để mua 3 cáigiống hệt3 lần
Admin gửi email nhắc nợ, khách không thấy, gửi lạigiống hệt2 email, cố ý
Chấm công vào/ragiống hệt2 bản ghi

Với idempotency key, client nói rõ "đây là 2 ý định khác nhau" bằng 2 key khác nhau. Với content hash, server không có thông tin nào để phân biệt. Và với e-commerce, "mua lại đúng thứ vừa mua" không phải trường hợp hiếm.

Chiều sai 2: bỏ lọt trùng thật (false negative)

Hash là hash của bytes. Retry rất hay đổi bytes trong khi nội dung không đổi:

Thứ tự key JSON:      {"a":1,"b":2}  vs  {"b":2,"a":1}      -> hash khác
Field client tự chèn: "requested_at", "trace_id", "_t"
SDK thêm số lần thử:  "attempt": 1  ->  "attempt": 2
Định dạng số:         100  vs  100.0  vs  "100"
multipart/form-data:  boundary string sinh ngẫu nhiên mỗi request
Header đổi:           client vừa refresh access token

Đây là kiểu hỏng im lặng: không log, không lỗi, chỉ có 2 đơn hàng. Muốn tránh thì phải viết canonicalization (chuẩn hoá payload trước khi băm: sort key, ép kiểu số, bỏ field bay hơi). Đoạn code đó phải khớp tuyệt đối giữa mọi client, và mỗi lần thêm field mới là một lần có thể làm hỏng nó — đắt hơn nhiều so với một dòng crypto.randomUUID() ở client.

Chỗ gãy nặng nhất: "in-process" chỉ bắt được ca song song

sequenceDiagram
  autonumber
  participant C as Client
  participant S as Server
  C->>S: Request 1, hash=H
  S->>S: Danh dau H = in-process
  C->>S: Request 2, hash=H (t=0.3s)
  S-->>C: 409 REJECT - dang in-process (chan duoc double-click)
  S->>S: Request 1 xong, XOA dau in-process
  S--xC: 201 Created (mat goi tin / client da timeout)
  Note over C: t=30s, client retry
  C->>S: Request 3, hash=H
  S->>S: Khong con in-process nua
  S->>S: CHAY LAI - tao don thu hai

Nguồn duplicate khó nhất — client/proxy retry sau timeout (nguồn #3 và #4 ở Phần 0) — lọt hoàn toàn, vì lúc retry tới thì request đầu đã xong rồi, không còn "in-process" nào để mà thấy.

Muốn bịt thì phải giữ hash lại sau khi xong — và ngay lập tức rơi vào chiều sai 1.

Cửa sổ thời gian: không có giá trị nào đúng cả

TTL giữ hashChặn được retry sau timeout?Chặn nhầm request thật?
5 giâyKhông — retry thường ở 30s/60sHiếm
60 giâyMột phầnKhách mua lại trong 1 phút bị chặn
24 giờChặn sạch mọi lần mua lại trong ngày

Đây là điểm cốt lõi: content hash ép bạn cân false-positive với false-negative bằng một cái núm duy nhất (độ dài cửa sổ), và không có nấc nào chấp nhận được cả hai. Idempotency key không có đánh đổi này — giữ 24 giờ vẫn an toàn, vì mỗi ý định có key riêng.

Và reject xong thì client biết kết quả bằng cách nào?

Idempotency key trả lại đúng response cũ: client retry và nhận 201 + order_id, coi như chưa có gì xảy ra. Content hash + reject chỉ trả được 409 — client không biết đơn đã tạo chưa, id là gì. Muốn biết thì cần endpoint tra cứu và một mã để tra cứu, mà mã đó chính là… idempotency key.

Bảng so sánh

Content hash (reject khi in-process)Idempotency key
Chống double-click
Chống F5 / back
Chống client retry sau timeoutKhông — đã hết in-process
Chống proxy / load balancer retryKhông
Chống webhook retry sau 60sKhông
Cho phép 2 request giống hệt cố ýKhông — chặn oan
Trả lại kết quả cũKhông — chỉ 409 mù
Chịu được payload đổi byte khi retryKhông — hỏng im lặngCó — key không đổi
Client phải làm gìKhông gì1 dòng randomUUID()
Server phải làm gìCanonicalize payload, chọn TTL, buffer toàn bộ body để bămLưu key

Đáng chú ý: mọi API thanh toán lớn đều chọn key do client sinh. Theo tài liệu Stripe, key do client sinh (khuyến nghị UUID v4, tối đa 255 ký tự) và tự bị xoá sau ít nhất 24 giờ. Họ dùng content hash — nhưng để làm việc khác:

"the idempotency layer compares incoming request parameters with those from the original request and returns an error if they do not match" — Stripe API docs

Tức là hash là hàng rào phụ để bắt client dùng lại key sai, không phải khoá chính. Đúng vai trò của cột request_hash trong bảng ở phần 1.2.

Vậy khi nào content hash lại là lựa chọn đúng?

Khi giả định "hai request giống hệt luôn là nhầm" thật sự đúng trong nghiệp vụ đó:

Không dùng khi: API công khai, mobile app, webhook, hoặc bất kỳ chỗ nào tạo bản ghi mà người dùng có quyền tạo trùng một cách hợp lệ.

Nhưng ý đằng sau câu hỏi thì đúng: có cách để client khỏi phải làm gì

Với web form, đạt được điều đó mà vẫn giữ nguyên ngữ nghĩa idempotency key: server sinh key lúc render form.

// Lúc render form — server sinh, client không viết dòng JS nào
<input type="hidden" name="idem_key" value="{{ (string) Str::uuid() }}">

// Lúc submit — insertOrIgnore là chốt, không phải if
$isFirst = DB::table('idempotency_keys')->insertOrIgnore([
    'key'     => $request->idem_key,
    'user_id' => $request->user()->id,
    'status'  => 'in_progress',
    'created_at' => now(),
]) === 1;

Mỗi lần mở form là một ý định mới, một key mới — nên khách mua lại đúng sản phẩm vừa mua vẫn tạo được đơn thứ hai. Đây chính là "one-time form token" ở phần 1.1, chỉ khác là lưu ở DB thay vì session để nhiều pod cùng nhìn thấy.

Chỉ khi là API công khai / mobile mới buộc client tự sinh, vì không có bước "render form" nào để server chen vào.

Một dòng tóm tắt: content hash là cái chặn double-click tốt nhưng là cơ chế idempotency dở. Dùng nó thay idempotency key là đổi một class bug ồn ào (409 hiếm gặp) lấy một class bug im lặng — đơn trùng, hoặc khách bị chặn không mua được.

Phần 2. Lợi ích — mỗi tầng mua được gì

TầngChặn được nguồn nào (theo Phần 0)Lợi ích thậtChi phí thêm
1. Client#1 double-clickGiảm ~95% duplicate thực tế. Không tốn request nào. Người dùng thấy nút đang chạy nên không bấm nữaGần như 0
2. Idempotency key#1 #2 #3 #4 #6Duy nhất chống được retry của client/proxy. Trả lại đúng response cũ nên client không cần xử lý đặc biệt1 bảng, 1–2 query mỗi request, cron dọn rác
3. Lock#1–#6, kể cả việc không sinh ra dòng nàoBao được cả side effect ngoài DB: gọi API, gửi mail, ghi fileThêm Redis vào đường ghi; phải nghĩ về TTL và deadlock
4. Unique index#1–#6, kể cả bug trong chính code bạnKhông phụ thuộc app hợp tác. Đúng cả khi có 20 pod, khi ai đó chạy tay trên psql, khi có service khác ghi vào cùng bảng1 index (tốn ghi ~5–10%), phải bắt lỗi 23505

Phần 3. Tác hại và hạn chế — chỗ từng cơ chế gãy

3.1. Tầng client gãy ở đâu

Cách làmGãy khi
Disable nút bằng JSJS chưa load xong; người dùng tắt JS; mạng chập chờn làm response không về nên nút kẹt disabled vĩnh viễn
Livewire tự khoá formChỉ với wire:submit. Mọi wire:click đều trần trụi
wire:loading.attr="disabled" không có wire:targetNút bị khoá oan mỗi khi bất kỳ action nào trong component chạy — kể cả wire:model.live đang gõ phím
PRGKhông cứu được click thứ hai trước khi redirect kịp về
Debounce phía clientChỉ dời vấn đề đi vài trăm ms. Retry của SDK không đi qua debounce của bạn

Nút kẹt disabled là bug hay bị bỏ sót. Livewire mở khoá trong callback respond() của sự kiện commit. Nếu request không bao giờ về (mất mạng giữa chừng, worker PHP-FPM bị OOM kill), người dùng ngồi nhìn cái form đứng hình và phải F5. Nên có timeout phía client tự mở khoá sau 30 giây kèm thông báo. Chưa verify hành vi chính xác của Livewire khi request bị abort — cần test bằng trình duyệt.

3.2. Idempotency key gãy ở đâu

3.3. Lock gãy ở đâu

Vấn đềChuyện gì xảy raCách giảm
TTL ngắn hơn công việcLock hết hạn giữa chừng, request thứ hai vào, hai luồng chạy song song — tệ hơn không có lock, vì bạn tưởng đã an toànTTL >> p99 thời gian chạy; hoặc dùng lock ở tầng DB (advisory) vì nó không có TTL
Quên release()Exception ném ra, khoá giữ tới hết TTL. Người dùng bị chặn 10 phútLuôn dùng finally, hoặc dùng dạng closure withoutOverlapping()
Redis failoverMaster chết, replica lên thay chưa kịp nhận key lock → hai client cùng cầm lockChấp nhận (đa số trường hợp ổn), hoặc dùng advisory lock của PostgreSQL nếu tiền bạc phụ thuộc vào nó
Lock quá rộngKhoá theo "place-order" thay vì "place-order:user:9" → cả hệ thống đặt hàng nối đuôi nhauKey lock phải chứa định danh nhỏ nhất còn đúng
Advisory lock quên COMMITDùng pg_advisory_lock (bản session) thay vì pg_advisory_xact_lock, connection về pool mà vẫn cầm khoáLuôn dùng bản _xact_ — nó tự nhả khi transaction kết thúc
FOR UPDATE gây deadlockHai transaction khoá 2 dòng theo thứ tự ngược nhauLuôn khoá theo thứ tự cố định (ví dụ ORDER BY id)

3.4. Unique index gãy ở đâu — 5 bẫy đã kiểm chứng

Bẫy 1: unique violation làm hỏng CẢ transaction

BEGIN;
  INSERT INTO tmp_orders (user_id, idem_key, amount) VALUES (2,'k-002',50);  -- OK
  INSERT INTO tmp_orders (user_id, idem_key, amount) VALUES (2,'k-002',50);  -- trùng
  SELECT 'con chay duoc khong?' AS probe;
COMMIT;
SELECT count(*) AS rows_k002 FROM tmp_orders WHERE idem_key = 'k-002';
BEGIN
INSERT 0 1
ERROR:  duplicate key value violates unique constraint "tmp_orders_idem"
DETAIL:  Key (idem_key)=(k-002) already exists.
ERROR:  current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
 rows_k002
-----------
         0

Dòng INSERT đầu tiên thành công cũng bị mất. COMMIT âm thầm biến thành ROLLBACK. Bắt exception trong PHP rồi chạy tiếp cũng vô ích — mọi câu lệnh sau đó đều bị PostgreSQL từ chối.

Cách chữa: SAVEPOINT. Kiểm chứng:

BEGIN;
  INSERT INTO tmp_sp (k, v) VALUES ('b', 2);
  SAVEPOINT sp1;
  INSERT INTO tmp_sp (k, v) VALUES ('a', 99);   -- trùng, lỗi
  ROLLBACK TO SAVEPOINT sp1;                    -- chỉ lùi tới đây
  INSERT INTO tmp_sp (k, v) VALUES ('c', 3);    -- chạy tiếp bình thường
COMMIT;
ROLLBACK
INSERT 0 1
COMMIT
 id | k | v
----+---+---
  1 | a | 1
  2 | b | 2
  4 | c | 3
(3 rows)

Laravel làm đúng chuyện này giúp bạn. Eloquent\Builder::createOrFirst() (vendor/laravel/framework/.../Database/Eloquent/Builder.php:722):

public function createOrFirst(array $attributes = [], Closure|array $values = [])
{
    try {
        return $this->withSavepointIfNeeded(fn () => $this->create(...));
    } catch (UniqueConstraintViolationException $e) {
        return $this->useWritePdo()->where($attributes)->first() ?? throw $e;
    }
}

// withSavepointIfNeeded (dòng 1957): đang trong transaction thì bọc thêm 1 lớp
return $this->getQuery()->getConnection()->transactionLevel() > 0
    ? $this->getQuery()->getConnection()->transaction($scope)   // -> SAVEPOINT
    : $scope();

Vì sao useWritePdo(): nếu đọc lại từ read replica, dòng vừa được request kia INSERT có thể chưa replicate tới → first() trả null → ném lại exception. Laravel ép đọc từ primary để tránh đúng chuyện đó. Xem thêm migration PostgreSQL zero-downtime.

Bẫy 2: firstOrCreate KHÔNG an toàn nếu thiếu unique index

public function firstOrCreate(array $attributes = [], Closure|array $values = [])
{
    if (! is_null($instance = (clone $this)->where($attributes)->first())) {
        return $instance;          // <-- CHECK
    }
    return $this->createOrFirst($attributes, $values);   // <-- THEN ACT
}

Đây đúng là check-then-act. Nó chỉ an toàn vì createOrFirst bắt UniqueConstraintViolationException. Không có unique index thì không có exception, và bạn có 2 dòng — chính là kết quả TEST ở Phần 0.

Bẫy 3: ON CONFLICT với partial index phải lặp lại mệnh đề WHERE

CREATE UNIQUE INDEX tmp_one_pending ON tmp_orders2 (user_id) WHERE status = 'pending';

-- Thiếu WHERE:
INSERT INTO tmp_orders2 (...) VALUES (7,'pending','d',10)
ON CONFLICT (user_id) DO NOTHING;
ERROR:  there is no unique or exclusion constraint matching the ON CONFLICT specification
-- Đúng: lặp lại y hệt mệnh đề WHERE của index
INSERT INTO tmp_orders2 (...) VALUES (7,'pending','e',10)
ON CONFLICT (user_id) WHERE status = 'pending' DO NOTHING;
INSERT 0 0

Laravel insertOrIgnore() sinh ra ON CONFLICT DO NOTHING không có conflict target — dạng đó bắt mọi unique constraint nên chạy được với partial index, đã kiểm chứng (INSERT 0 0). Nhưng nó cũng nuốt luôn xung đột ở constraint khác mà bạn không định nuốt. Cần chính xác thì viết SQL thô.

Bẫy 4: DO NOTHING + RETURNING trả về 0 dòng

INSERT INTO tmp_orders2 (...) VALUES (8,'pending','a',10)
ON CONFLICT (idem_key) DO NOTHING
RETURNING id, idem_key;
 id | idem_key
----+----------
(0 rows)

INSERT 0 0

Muốn lấy id của dòng đã tồn tại thì phải dùng DO UPDATE giả:

INSERT INTO tmp_orders (user_id, idem_key, amount) VALUES (1,'k-001',100)
ON CONFLICT (idem_key) DO UPDATE SET idem_key = EXCLUDED.idem_key
RETURNING id, amount, created_at;
 id | amount |          created_at
----+--------+------------------------------
  1 | 100.00 | 2026-09-08 08:36:56.92278+00
(1 row)

DO UPDATE ghi lại dòng cũ (tạo tuple mới, tốn WAL và làm bảng bloat), và nó khoá dòng đó. Chỉ dùng khi thật sự cần id trả về.

Bẫy 5: unique index biến song song thành nối đuôi

Đã đo ở phần 1.4: session B đợi 1.728 giây. Với đường ghi nóng, unique index trên một cột mà nhiều request cùng chen vào sẽ tạo hàng đợi. Giữ transaction ngắn là cách duy nhất giảm.


Phần 4. Trade-off — chọn cái nào cho hoàn cảnh nào

4.1. Cây quyết định

flowchart TB
  S["Cần chống duplicate"] --> Q1{"Hành động có sinh ra
bản ghi mới không?"} Q1 -- "Không
(gửi mail, gọi API, cộng số dư)" --> Q2{"Đã có sẵn 1 dòng
để khoá chưa?"} Q2 -- "Rồi (cart, ví, đơn)" --> R1["SELECT ... FOR UPDATE
trong transaction"] Q2 -- "Chưa" --> Q3{"Side effect có ra
ngoài DB không?"} Q3 -- "Có" --> R2["Cache::lock (Redis)
+ bảng ghi nhận đã gửi"] Q3 -- "Không" --> R3["pg_advisory_xact_lock"] Q1 -- "Có" --> Q4{"Client là ai?"} Q4 -- "Trình duyệt, form nội bộ" --> R4["Unique index
+ khoá nút ở UI"] Q4 -- "API công khai / mobile app" --> R5["Idempotency-Key
+ unique index"] Q4 -- "Webhook bên thứ ba" --> R6["Unique index trên event_id
+ ON CONFLICT DO NOTHING"] Q4 -- "Queue job" --> R7["ShouldBeUnique
+ unique index"]

4.2. Bảng so sánh đầy đủ

Cơ chếChặn đượcChi phíĐiểm gãy chínhChọn khi
Disable nút / wire:loading Double-click 0 JS chết, nút kẹt disabled Luôn luôn — nhưng chỉ tính là UX, không tính là chống duplicate
PRG + one-time token Double-click, F5, back 1 khoá session Session mất là token mất; nhiều tab đá nhau Form HTML thuần, không có Livewire/SPA
Debounce / throttle Click liên tiếp 0 Không chặn được retry, không chặn multi-tab Ô tìm kiếm, filter — không dùng cho hành động ghi tiền
Rate limit (throttle middleware) Spam, bot 1 lượt Redis Không phân biệt "gửi 2 lần cùng 1 đơn" với "đặt 2 đơn khác nhau" Chống lạm dụng, không phải chống duplicate
Content hash (request fingerprint) Double-click, F5 Băm payload mỗi request Chặn oan request giống hệt cố ý; bỏ lọt retry sau khi request đầu đã xong Form nội bộ, cửa sổ 3–5s, chỉ như lớp phụ — xem phần 1.5
Idempotency key Mọi nguồn có retry 1 bảng + 2 query + cron dọn Client sinh key sai; in_progress kẹt API công khai, mobile app, tích hợp thanh toán — bắt buộc
Cache::lock (Redis) Mọi nguồn, cả side effect ngoài DB 1–2 lượt Redis TTL hết giữa chừng; Redis failover Việc không sinh dòng DB: gửi mail, gọi API, xuất file
Advisory lock (PG) Mọi nguồn đi qua DB ~0 (không I/O) Nhầm bản session vs _xact_; giữ connection lâu Cần lock mà không muốn thêm Redis vào đường ghi
SELECT FOR UPDATE Race trên đúng dòng đó Khoá dòng tới hết transaction Deadlock nếu thứ tự khoá loạn Đã có dòng cha để khoá (cart, ví, tồn kho)
Unique index Mọi nguồn, kể cả bug của chính bạn ~5–10% chậm hơn khi ghi Abort transaction; ép request nối đuôi Luôn luôn. Đây là tầng không được bỏ
Partial unique index Vi phạm luật nghiệp vụ ("1 đơn pending") Nhỏ hơn index đầy đủ ON CONFLICT phải lặp WHERE Luật nghiệp vụ diễn đạt được bằng một điều kiện
ShouldBeUnique (queue) Job trùng trong hàng đợi 1 atomic lock Chỉ chặn lúc đẩy vào, không chặn lúc job retry Job nặng, tốn kém, không nên chạy 2 lần
Optimistic locking (version) Ghi đè mất dữ liệu 1 cột Không chống tạo mới trùng Sửa dữ liệu đồng thời, không phải tạo mới

4.3. Ba combo nên dùng

Hoàn cảnhComboVì sao đủ
Form nội bộ (admin CRUD) wire:submit + unique index Người dùng ít, tin được, không có retry tự động. Thêm idempotency key là thừa
Checkout của e-commerce wire:submit + FOR UPDATE trên cart + partial unique index (cart_id) WHERE status='pending' Cart là dòng có sẵn để khoá; index chốt lại nếu lock hỏng
API thanh toán công khai Idempotency-Key + unique index + ghi lại response Client và proxy đều tự retry; phải trả lại đúng response cũ, không chỉ chặn

Điều dở nên nói thẳng ở phỏng vấn: nhiều team dùng throttle:1,1 để chống double-submit. Đó là dùng sai công cụ. Rate limit không phân biệt được "gửi 2 lần cùng một đơn" với "khách đặt 2 đơn khác nhau trong 1 giây" — nó chặn cả hai, và người dùng thật bị chặn oan trong khi retry của SDK vẫn lọt qua sau 1 giây.


Phần 5. Khó khăn khi vận hành thực tế

5.1. Chi phí

Hạng mụcƯớc tính (ví dụ minh hoạ, hệ 1 triệu request/ngày)
Bảng idempotency_keys~1 triệu dòng/ngày; giữ 24h ≈ 300–800 MB tuỳ kích thước response lưu kèm
Query thêm mỗi request1 INSERT ... ON CONFLICT + 1 UPDATE ≈ +1–3ms
Redis cho lock2 lệnh/request, không đáng kể — nhưng thêm 1 điểm chết vào đường ghi
Unique indexGhi chậm hơn ~5–10%; đọc nhanh hơn nếu index dùng được cho query

5.2. Cần monitor gì

5.3. Debug lúc 3 giờ sáng

-- Ai đang bị chặn, và bị chặn bởi ai
SELECT pid, wait_event_type, wait_event, state,
       now() - xact_start AS xact_age,
       left(query, 80) AS query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY xact_start;

-- Advisory lock nào đang bị giữ
SELECT pid, locktype, objid, granted
FROM pg_locks WHERE locktype = 'advisory';
# Ai đang cầm lock trên Redis
redis-cli --scan --pattern 'laravel_cache:*place-order*'
redis-cli ttl 'laravel_cache:place-order:user:9'

Vấn đề con người, không phải kỹ thuật: tầng 4 là tầng dễ bị xoá nhất. Ai đó gặp lỗi 23505 trên staging, không hiểu vì sao, và "sửa" bằng cách drop index. Hãy để lại comment ngay trên migration nói rõ index này chống cái gì — nếu không, sáu tháng sau nó biến mất và không ai biết.

// database/migrations/..._add_dedupe_index_to_orders.php
Schema::table('orders', function (Blueprint $table) {
    // Chống double-submit: mỗi giỏ hàng chỉ được có 1 đơn đang chờ thanh toán.
    // Đây là chốt chặn CUỐI CÙNG, không phải tối ưu. Xoá index này = tạo đơn trùng.
    // Xem docs/laravel/chong-double-submit.html
});
DB::statement("CREATE UNIQUE INDEX CONCURRENTLY one_pending_order_per_cart
               ON orders (cart_id) WHERE status = 'pending'");

Lưu ý CONCURRENTLY: tạo index trên bảng đang chạy mà không khoá ghi. Chi tiết ở CREATE INDEX CONCURRENTLY.


Phần 6. Case thực tế và cách xử lý

Case 1 — "Khách bấm 2 lần, tạo 2 đơn, trừ tiền 2 lần"

Triệu chứng: mỗi ngày 5–10 khiếu nại. Đơn trùng cách nhau 200–500ms, cùng user_id, cùng giỏ hàng.

Chẩn đoán từng bước:

flowchart TB
  A["Tìm đơn trùng:
GROUP BY cart_id, giữ nhóm có từ 2 đơn"] --> B["Xem khoảng cách created_at"] B --> C{"Cách nhau bao lâu?"} C -- "dưới 1s" --> D["Double-click hoặc retry client"] C -- "vài giây tới vài phút" --> E["F5 hoặc client retry sau timeout"] C -- "chính xác 30s / 60s" --> F["Timeout của proxy hoặc SDK"] D --> G["Xem nút dùng wire:click hay wire:submit"]
SELECT cart_id, count(*), array_agg(created_at ORDER BY created_at),
       max(created_at) - min(created_at) AS khoang_cach
FROM orders
WHERE created_at > now() - interval '7 days'
GROUP BY cart_id HAVING count(*) > 1
ORDER BY khoang_cach;

Cách xử lý, theo thứ tự ưu tiên:

  1. Cầm máu ngay (5 phút): đổi wire:click="placeOrder" thành <form wire:submit="placeOrder">, hoặc thêm wire:loading.attr="disabled" wire:target="placeOrder". Deploy được ngay, giảm ~95% ca.
  2. Chốt chặn thật (cùng ngày):
    CREATE UNIQUE INDEX CONCURRENTLY one_pending_order_per_cart
        ON orders (cart_id) WHERE status = 'pending';
    Từ giờ database không cho tạo trùng, dù code có sai thế nào.
  3. Xử lý lỗi cho tử tế (cùng ngày): bắt UniqueConstraintViolationException rồi redirect sang đơn đã có, không hiện màn hình 500:
    try {
        $order = $this->createOrder($cart);
    } catch (UniqueConstraintViolationException) {
        $order = Order::where('cart_id', $cart->id)->where('status', 'pending')->firstOrFail();
    }
    return redirect()->route('checkout.payment', $order);   // trùng hay không, kết quả như nhau
    
  4. Dọn dữ liệu cũ: gộp đơn trùng, hoàn tiền, rồi mới bật constraint. Nếu bật constraint trước khi dọn thì CREATE UNIQUE INDEX sẽ thất bại vì dữ liệu hiện tại đã vi phạm.

Case 2 — Webhook thanh toán gửi lại IPN, cộng tiền 2 lần

Triệu chứng: ví của khách được cộng 2 lần cho cùng một giao dịch. Log cho thấy 2 request POST giống hệt nhau, cách nhau 60 giây.

Nguyên nhân: lần đầu server trả 200 nhưng mất trên đường về (hoặc trả sau timeout của cổng). Cổng thanh toán là at-least-once — nó gửi lại cho tới khi nhận được 200. Không có cách nào để nó "gửi đúng 1 lần"; trách nhiệm dedupe nằm ở bạn.

sequenceDiagram
  participant G as Cong thanh toan
  participant S as Server
  participant DB as PostgreSQL
  G->>S: POST /webhook (event_id=evt_123)
  S->>DB: Cong 500k vao vi
  S--xG: 200 OK (mat goi tin)
  Note over G: Khong nhan duoc 200 -> retry sau 60s
  G->>S: POST /webhook (event_id=evt_123) LAN 2
  S->>DB: Cong 500k LAN NUA

Cách sửa — dùng event_id của cổng làm khoá tự nhiên:

public function handle(Request $request)
{
    // 1. Xác thực chữ ký TRƯỚC. Không có bước này thì ai cũng cộng tiền được.
    abort_unless($this->signatureValid($request), 401);

    $eventId = $request->input('event_id');

    // 2. Chốt chỗ. Unique index trên payment_events.event_id là thứ ép luật.
    $isFirst = DB::table('payment_events')->insertOrIgnore([
        'event_id' => $eventId,
        'payload'  => json_encode($request->all()),
        'created_at' => now(),
    ]) === 1;

    // 3. Đã xử lý rồi thì vẫn trả 200 — nếu trả lỗi, cổng sẽ retry mãi.
    if (! $isFirst) {
        Log::info('webhook trùng, bỏ qua', ['event_id' => $eventId]);
        return response()->noContent();
    }

    ProcessPaymentEvent::dispatch($eventId);   // việc nặng đẩy sang queue

    return response()->noContent();
}
CREATE TABLE payment_events (
    event_id   text PRIMARY KEY,          -- chính là chốt chặn
    payload    jsonb NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

Ba lỗi hay gặp ở webhook: (1) trả 500 khi thấy event trùng → cổng retry vô hạn, đúng ra phải trả 200; (2) xử lý nặng ngay trong request → timeout → cổng retry → càng nặng; (3) kiểm chữ ký sau khi ghi vào bảng → ai cũng làm bảng phình được.

Case 3 — Job trong queue chạy 2 lần, gửi 2 email

Nguyên nhân: queue là at-least-once. Job chạy xong, gửi email xong, rồi worker bị OOM kill trước khi ack. Job quay lại hàng đợi và chạy lại.

ShouldBeUnique không cứu được ca này — nó chỉ chặn lúc đẩy vào hàng đợi:

use Illuminate\Contracts\Queue\ShouldBeUnique;

class SendOrderConfirmation implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string { return $this->order->id; }
    public int $uniqueFor = 3600;          // hết hạn khoá sau 1h, phòng job chết cứng
}

Cách chống thật: ghi lại việc đã làm, trong cùng transaction với việc đó nếu có thể:

public function handle(): void
{
    // Chốt chỗ ở DB. Trùng -> 0 dòng -> đã gửi rồi.
    $isFirst = DB::table('sent_notifications')->insertOrIgnore([
        'kind' => 'order_confirmation',
        'subject_id' => $this->order->id,
        'created_at' => now(),
    ]) === 1;

    if (! $isFirst) {
        return;
    }

    Mail::to($this->order->email)->send(new OrderConfirmation($this->order));
}
CREATE UNIQUE INDEX ON sent_notifications (kind, subject_id);

Vẫn còn một khe hở, phải nói ra: giữa insertOrIgnore (đã commit) và Mail::send(), nếu process chết thì email không bao giờ được gửi — bảng đã nói "đã gửi". Đây là đánh đổi at-most-once đổi lấy at-least-once. Với email xác nhận, gửi thiếu tốt hơn gửi 2 lần. Với việc không được phép thiếu (ví dụ ghi sổ kế toán), phải làm ngược lại: ghi pending → làm → đánh dấu done, và có job quét lại các dòng pending quá lâu.

Case 4 — Nút "Load more" bấm nhanh 2 lần, danh sách lặp item

Triệu chứng: người dùng bấm nhanh, danh sách hiện trùng review, hoặc nhảy mất một trang.

Vì sao: theo phân tích ở phần 1.1, click thứ hai bị defer() chứ không bị huỷ. Nó chạy sau click thứ nhất, đọc $this->cursor đã cập nhật, nên với keyset pagination thì không lặp. Nhưng nếu phân trang bằng OFFSET và có dữ liệu mới chèn vào giữa chừng thì sẽ lặp.

Cách xử lý: đúng như code đang có trong project — khoá nút bằng wire:loading.attr, và dùng cursor thay vì offset:

<button type="button"
        wire:click="loadMore"
        wire:loading.attr="disabled"
        wire:target="loadMore">
    Load more
</button>
public function loadMore(): void
{
    if ($this->cursor === null) {   // đã hết trang -> click thừa không làm gì
        return;
    }
    $before = $this->reviews->count();
    $this->fetchPage();
    $this->justLoadedCount = $this->reviews->count() - $before;
}

Chi tiết vì sao keyset pagination miễn nhiễm với chuyện này: xem phân trang danh sách sản phẩm.

Case 5 — Khi client là kẻ đối địch: idempotency key do client sinh trở thành lỗ hổng

Bốn case trên đều giả định client muốn chống trùng cho chính nó: SDK thanh toán, cổng webhook, trình duyệt của khách mua hàng. Case này phá bỏ giả định đó.

Bài toán: sinh viên làm bài quiz online. Mỗi quiz cho làm tối đa 2 lần, mỗi lần 60 phút.

Thiết kế "trông rất đúng sách"

  1. Sinh viên bấm "Bắt đầu làm bài"client sinh attempt_id (UUID v4).
  2. Làm bài trong trình duyệt.
  3. Bấm "Nộp bài" → gửi kèm attempt_id, nút submit bị disable.
  4. Server INSERT vào bảng quiz_attempts, cột attempt_idUNIQUE.

Chấm theo tài liệu này thì thiết kế trên làm đúng ba việc:

Nếu đây là form đặt hàng nội bộ, thiết kế này ổn. Với quiz thì hỏng — và hỏng ở chỗ không nằm trong bất kỳ tầng nào ở Phần 1.

Vì sao hỏng: unique index đang ép trên giá trị mà KẺ TẤN CÔNG tự chọn

sequenceDiagram
  autonumber
  participant SV as Sinh vien mo DevTools
  participant S as Server
  participant DB as PostgreSQL
  SV->>SV: Lam bai 10 lan, luu 10 bo dap an
  SV->>S: POST /submit attempt_id=uuid-1
  S->>DB: INSERT - UNIQUE(attempt_id) OK
  SV->>S: POST /submit attempt_id=uuid-2
  S->>DB: INSERT - UNIQUE(attempt_id) OK
  SV->>S: POST /submit attempt_id=uuid-3
  S->>DB: INSERT - UNIQUE(attempt_id) OK
  Note over DB: 10 attempt hop le, khong co dong nao trung

UNIQUE (attempt_id) chỉ chặn được "nộp hai lần cùng một id". Nó không chặn "làm bài 10 lần", vì mỗi lần một id khác nên không có gì trùng.

Luật nghiệp vụ thật là "mỗi sinh viên tối đa 2 lần cho mỗi quiz". Luật đó không được ép ở đâu cả: không ở client (sửa được bằng DevTools), không ở DB (unique đặt sai cột).

Nguyên tắc rút ra: giá trị dùng làm khoá chống trùng phải do bên bị thiệt hại kiểm soát. Idempotency key do client sinh chỉ an toàn khi client muốn chống trùng cho chính nó (SDK thanh toán retry). Sinh viên thì muốn điều ngược lại.

Hai vấn đề kéo theo, còn rộng hơn chuyện trùng lặp

Vì không có bản ghi nào tồn tại trong lúc sinh viên làm bài, server mù hoàn toàn suốt 60 phút:

Mất gìHậu quả cụ thể
Thời gian làm bàiServer không biết bắt đầu lúc nào → phải tin started_at client gửi → sinh viên sửa → làm 3 tiếng khai 15 phút
Chống mở nhiều tab3 tab = 3 attempt_id = làm 3 lần, chọn bài điểm cao nhất mà nộp
Bài làm dởTrình duyệt crash hoặc mất điện → mất trắng. Quiz 60 phút không autosave là hỏng nghiệp vụ nặng
Biết ai đang thiKhông giám sát được, không hỗ trợ được khi sinh viên báo lỗi
Hết giờ mà không bấm nộpKhông có attempt nào tồn tại → 0 điểm hay cho làm lại? Không có chỗ nào quyết định

Thêm nữa, attempt_id nằm trong biến JS: sinh viên F5 → mất id → bấm "Bắt đầu làm bài" lại → id mới → nộp thành attempt thứ hai. Đúng ca "F5 mà tầng 2 không cứu được" ở phần 1.2.

Thiết kế đúng: đảo ngược lại — server tạo attempt ngay lúc BẮT ĐẦU

flowchart TB
  A["Bam Bat dau lam bai"] --> B["POST /quizzes/10/attempts
Idempotency-Key: client sinh"] B --> C["Server INSERT quiz_attempts
id do SERVER sinh
started_at = now()
status = in_progress"] C --> D["Tra ve attempt_id = 8842"] D --> E["PATCH /attempts/8842/answers
autosave moi 15-20s"] E --> E E --> F["POST /attempts/8842/submit"] F --> G["UPDATE ... WHERE status='in_progress'
AND con trong gio"] G --> H{"So dong bi anh huong?"} H -- "1" --> I["Nop thanh cong, ghi diem"] H -- "0" --> J["Da nop roi hoac het gio
tra lai ket qua cu"]
A. Chặn mở nhiều tab và làm lại khi chưa nộp — partial unique index
CREATE UNIQUE INDEX one_active_attempt
    ON quiz_attempts (user_id, quiz_id) WHERE status = 'in_progress';

Hai tab cùng bấm "Bắt đầu làm bài" trong khi đã có một bài đang làm dở:

----- tab 1 -----
ERROR:  duplicate key value violates unique constraint "one_active_attempt"
DETAIL:  Key (user_id, quiz_id)=(1, 10) already exists.

----- tab 2 -----
ERROR:  duplicate key value violates unique constraint "one_active_attempt"
DETAIL:  Key (user_id, quiz_id)=(1, 10) already exists.
B. Chống nộp 2 lần — không cần unique index, dùng compare-and-swap

Attempt đã tồn tại sẵn, việc nộp bài chỉ là chuyển trạng thái. Một câu UPDATE có điều kiện là đủ — atomic, không race, không phải bắt exception:

UPDATE quiz_attempts
   SET status = 'submitted', submitted_at = now(), score = ?
 WHERE id = ? AND status = 'in_progress';

Hai request nộp bài chạy song song:

----- submit 1 -----
UPDATE 1          <-- ghi điểm

----- submit 2 -----
UPDATE 0          <-- không làm gì
$affected = DB::update(
    "UPDATE quiz_attempts SET status='submitted', submitted_at=now(), score=?
      WHERE id=? AND status='in_progress'",
    [$score, $attemptId]
);

if ($affected === 0) {
    // Đã nộp rồi (double-click / retry) hoặc hết giờ -> không phải lỗi
    return redirect()->route('attempts.result', $attemptId);
}
C. Ép luôn giới hạn thời gian trong cùng câu lệnh đó
UPDATE quiz_attempts
   SET status = 'submitted', submitted_at = now(), score = ?
 WHERE id = ? AND status = 'in_progress'
   AND now() < started_at + interval '60 minutes';
UPDATE 0          <-- nộp lúc phút thứ 70, bị từ chối

started_at do server ghi nên sinh viên không sửa được. Một câu lệnh vừa chống trùng vừa ép giờ — không có khe hở nào giữa hai lần kiểm tra.

D. Giới hạn số lần làm bài
CREATE UNIQUE INDEX ON quiz_attempts (user_id, quiz_id, attempt_no);

attempt_no tính bằng count + 1 trong transaction. Hai tab đua nhau thì một cái ăn unique violation → bắt lại và báo "bạn đã hết lượt" thay vì tạo lượt thứ ba.

Đối chiếu hai thiết kế

Client sinh attempt_id, lưu lúc nộpServer tạo attempt lúc bắt đầu
Chống double-click nút Nộp
Chống nộp lại sau timeout
Chống làm bài nhiều lầnKhông — sinh viên tự sinh id mớiCó — partial unique index
Chống mở nhiều tabKhông
Ép giới hạn thời gianKhông — tin started_at của clientCó — started_at server ghi
Không mất bài khi crashKhôngCó — autosave
Biết ai đang thiKhông

Vậy key do client sinh bỏ hẳn?

Không — chuyển nó sang đúng vai trò: làm idempotency key cho lời gọi "Bắt đầu làm bài", để mạng chập chờn bấm 2 lần không tạo 2 attempt.

POST /quizzes/10/attempts
Idempotency-Key: <client sinh>      <-- chỉ chống double-click nút "Bắt đầu"

201 { "attempt_id": 8842 }           <-- id thật do SERVER sinh,
                                          sinh viên không chọn được

Câu hỏi phải đổi. Với client thân thiện, câu hỏi là "làm sao để không lưu trùng?". Với client đối địch, câu hỏi là "làm sao để đối phương không chọn được thứ mà tôi dùng để chống trùng?" — và câu trả lời gần như luôn là: định danh do server sinh, trạng thái do server giữ, thời gian do server ghi.

Đã kiểm chứng: ba khối output ở mục A, B, C chạy thật trên PostgreSQL 18.4 (bảng tmp_attempts, đã drop sau khi test).


Phần 7. Câu hỏi phụ interviewer hay hỏi tiếp

1. "Disable nút là đủ chưa?"

Chưa. Nó chỉ chặn 1 trong 6 nguồn duplicate, và chỉ chặn được nếu JS chạy. Retry của client, của proxy, của queue, của webhook đều không đi qua trình duyệt. Disable nút là để UX; chốt chặn thật phải ở database.

2. "Vì sao không chỉ dùng SELECT kiểm tra trước rồi INSERT?"

Vì đó là check-then-act. Hai transaction chạy song song đều SELECT thấy 0 dòng (transaction kia chưa commit nên không nhìn thấy), rồi cả hai cùng INSERT. Tôi đã chạy thử trên PostgreSQL 18.4: không có unique index thì ra 2 dòng, có index thì ra 1.

3. "Idempotency key khác gì CSRF token?"

CSRF token chống "trang web khác gửi hộ request" — nó sống theo session và dùng lại được nhiều lần. Idempotency key chống "cùng một ý định bị gửi nhiều lần" — mỗi ý định một key, dùng đúng một lần, và server phải nhớ kết quả để trả lại. Hai thứ giải quyết hai bài toán khác nhau, cần cả hai.

4. "Ai sinh idempotency key, client hay server?"

Client, và phải sinh lúc mở form (hoặc lúc tạo ý định), không phải lúc bấm gửi. Nếu sinh lúc bấm gửi thì retry sẽ mang key mới và cơ chế vô dụng. Server sinh key thì cũng vô dụng, vì retry của client sẽ nhận key khác.

5. "Sao không băm nội dung request thay cho idempotency key, cho client đỡ phải sinh key?"

Vì hash bắt được nội dung, không bắt được ý định. Hai đơn giống hệt nhau có thể là double-click, mà cũng có thể là khách thật sự muốn mua 2 lần — server không có cách nào phân biệt. Ngược lại, cùng một ý định lúc retry có thể ra hash khác (thứ tự key JSON, field timestamp client chèn, SDK thêm attempt), lúc đó dedupe hỏng im lặng. Và nếu chỉ reject khi đang in-process thì ca khó nhất vẫn lọt: request đầu xong rồi, response mất trên đường về, client retry sau 30 giây — lúc đó không còn in-process nào để mà thấy. Chi tiết ở phần 1.5.

6. "Key do client sinh thì có tin được không?"

Chỉ khi client muốn chống trùng cho chính nó — SDK thanh toán retry, app mobile gửi lại đơn. Khi client có động cơ làm ngược lại (sinh viên thi online, người dùng săn mã giảm giá, bot đặt vé) thì key do client sinh là vô dụng: họ sinh key mới mỗi lần và unique index không thấy gì trùng cả. Quy tắc: giá trị dùng làm khoá chống trùng phải do bên bị thiệt hại kiểm soát. Với client đối địch, định danh phải do server sinh, trạng thái do server giữ, thời gian do server ghi. Xem Case 5 ở Phần 6.

7. "Redis lock có thể mất không?"

Có. Hai cách: TTL hết trước khi công việc xong (hai luồng cùng chạy), và Redis failover (master chết, replica chưa nhận key). Vì vậy Redis lock chỉ nên là tầng tối ưu, đứng trên một unique index. Nếu tiền bạc phụ thuộc vào tính duy nhất thì dùng advisory lock của PostgreSQL — nó nằm chung transaction với dữ liệu, không có TTL, tự nhả khi commit.

8. "Bắt được lỗi unique violation rồi chạy tiếp trong transaction được không?"

Không, trừ khi có SAVEPOINT. PostgreSQL đánh dấu cả transaction là aborted; mọi câu lệnh sau đó trả về current transaction is aborted, commands ignored, và COMMIT âm thầm thành ROLLBACK — mất luôn cả những gì đã ghi thành công trước đó. Laravel biết chuyện này: createOrFirst() bọc lệnh tạo trong một savepoint khi đang ở trong transaction (withSavepointIfNeeded).

9. "firstOrCreate có an toàn với concurrency không?"

Chỉ khi có unique index. Đọc source: nó SELECT trước, không thấy thì gọi createOrFirst() — cái này bắt UniqueConstraintViolationException rồi SELECT lại từ primary. Không có index thì không có exception, và bạn có 2 dòng.

10. "Rate limit chống double-submit được không?"

Không nên. Rate limit đếm số request trên đơn vị thời gian, nó không biết hai request là "cùng một đơn" hay "hai đơn khác nhau". Đặt throttle:1,1 thì khách đặt 2 món liên tiếp bị chặn oan, còn retry của SDK sau 30 giây vẫn lọt. Rate limit là để chống lạm dụng, không phải để chống trùng.

11. "Livewire tự chống double-submit rồi, sao còn cần làm gì?"

Livewire chỉ tự khoá form với wire:submit — đọc trong supportDisablingFormsDuringRequest.js, điều kiện là directive.value !== "submit" return. Mọi wire:click đều không được bảo vệ. Và nó là JS phía client, nên không chặn được retry hay request gửi thẳng bằng curl.

12. "Bảng idempotency_keys phình thì làm sao?"

TTL 24 giờ là đủ cho mọi vòng retry hợp lý. Xoá bằng cron (DELETE WHERE created_at < now() - interval '24 hours') — nhớ xoá theo lô vài nghìn dòng một lần để khỏi khoá bảng lâu. Quy mô lớn hơn thì partition theo ngày và DROP PARTITION, rẻ hơn DELETE rất nhiều vì không sinh dead tuple.

13. "Nếu request đầu vẫn đang chạy, request thứ hai nên trả gì?"

409 Conflict kèm Retry-After, để client biết là nên thử lại chứ không phải hỏng. Đừng trả 500 (client sẽ retry sai cách) và đừng chờ đồng bộ (giữ connection PHP-FPM là tài nguyên đắt nhất, xem scale nginx + PHP-FPM).

14. "Đo hiệu quả của các tầng này thế nào?"

Đếm lỗi 23505 theo giờ. Nếu tầng 1–3 làm tốt thì con số này gần 0 nhưng khác 0 — khác 0 chứng minh tầng 4 vẫn đang bắt được cái gì đó. Về 0 hoàn toàn thì nên nghi ngờ là index đã bị ai đó xoá.


Tóm tắt một trang

Nếu bạn chỉ làm được 1 việcLàm việc này
Form LivewireĐổi wire:click thành <form wire:submit>
Mọi bảng có thể trùngCREATE UNIQUE INDEX CONCURRENTLY (partial nếu là luật nghiệp vụ)
API công khaiBắt buộc header Idempotency-Key
WebhookinsertOrIgnore trên event_id, luôn trả 200
Queue jobBảng ghi nhận "đã làm" + unique index, không chỉ ShouldBeUnique
Client có động cơ gian lận (thi online, săn mã)Định danh do server sinh, không phải client — xem Case 5
Việc có side effect ngoài DBCache::lock hoặc pg_advisory_xact_lock

Ghi chú kiểm chứng: mọi output SQL trong bài chạy thật trên PostgreSQL 18.4 (container my-test-pgdb) với bảng tạm tmp_*. Hành vi Livewire đọc từ vendor/livewire/livewire/dist/livewire.esm.js của livewire/livewire 4.3; hành vi Laravel đọc từ laravel/framework 12.64.0. Hai điểm chưa verify bằng trình duyệt thật: con số buffer 5ms khi gộp message, và cách Livewire mở khoá form khi request bị abort giữa chừng.