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 |
|---|---|
| idempotent | Gọ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 key | Mã 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 condition | Hai luồng chạy song song, kết quả phụ thuộc vào cái nào tới trước |
| check-then-act | Kiể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 index | Chỉ mục database, cấm 2 dòng có cùng giá trị. Do database ép, không phải app |
| partial unique index | Unique nhưng chỉ áp cho các dòng thoả một điều kiện, ví dụ WHERE status = 'pending' |
| advisory lock | Khoá "tự đặt tên" trong PostgreSQL, không gắn với dòng nào cả, chỉ là một con số |
| atomic lock | Khoá phân tán trên Redis/Memcached, ai SET NX được trước thì thắng |
| at-least-once | Queue/webhook đảm bảo tin nhắn đến ít nhất 1 lần — nghĩa là có thể 2 lần |
| PRG | Post/Redirect/Get — sau khi POST xong thì redirect, để F5 không POST lại |
| optimistic locking | Khô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ồn | Chuyện gì xảy ra | Disable nút có cứu không? |
|---|---|---|---|
| 1 | Double-click | Người dùng bấm 2 lần vì thấy trang không phản hồi | Có |
| 2 | F5 / back rồi submit lại | Trình duyệt gửi lại POST cũ | Không |
| 3 | Client tự retry | Request timeout ở giây 30, client gửi lại — nhưng server vẫn đang chạy cái cũ | Không |
| 4 | Load balancer / proxy retry | Upstream chậm, nginx hoặc SDK tự thử node khác | Không |
| 5 | Queue at-least-once | Job chạy xong nhưng chết trước khi ack → worker khác chạy lại | Không |
| 6 | Webhook bên thứ ba | Cổng thanh toán gửi lại IPN vì lần trước không nhận được 200 | Khô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ầng | Bắt loại lỗi nào | Có bỏ được không? |
|---|---|---|
| 1. Client | Người dùng bấm 2 lần | Bỏ được — chỉ mất UX, không sai dữ liệu |
| 2. Idempotency key | Cù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. Lock | Hai luồng chạy cùng lúc đụng nhau | Bỏ được nếu tầng 4 đã đủ (xem dưới) |
| 4. Unique index | Mọi thứ ba tầng trên để lọt, kể cả bug của chính bạn | Khô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ợp | Vì sao tầng 2 không lo được |
|---|---|
| Không có client để mà gửi key: queue job, cron, artisan command | Khô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ảnh | Tầng 1 Client | Tầng 2 Idem key | Tầng 3 Lock | Tầng 4 Unique index |
|---|---|---|---|---|
| Form admin nội bộ (CRUD) | Có | Thừa | Thừa | Có |
| Checkout trên web | Có | Nên có (token server sinh) | Nên có (FOR UPDATE trên cart) | Có |
| API công khai / mobile | Không áp dụng | Bắt buộc | Thường thừa | Có |
| Webhook thanh toán | Không áp dụng | Có — event_id đóng vai key | Thừa | Có |
| Queue job gửi email | Không áp dụng | Không có client để sinh key | Có, hoặc bảng sent_notifications | Có (trên bảng đó) |
| Cron đồng bộ tồn kho | Không áp dụng | Thừa | Bắt buộc | Không áp dụng |
Quy tắc 3 dòng nếu phải nhớ:
- 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.
- Tầng 1 có nếu có giao diện. Rẻ, và nó chặn ~95% ca thực tế.
- 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> và
<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ẫy | Vì sao | Cá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ác | Tự 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:ignore | Bị 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.js và
js/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.jscủalivewire/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 client | Sinh key lúc nào | Cất ở đâu | Sinh 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 key | Server làm gì với dòng key | Vì 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_hashkhô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:
$idemKeylà public property nên nó nằm trong snapshot gửi về client mỗi request. Đừng đặt tên kiểu$secretKeyvà đừ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 sai | Hỏng thế nào |
|---|---|
Sinh trong hàm onClick / lúc gọi API | Mỗ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 key | Chí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 ở đâu | Trình duyệt làm gì | Key ra sao | Tầng 2 bắt? | Có 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ũ | 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ũ | 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_progresstrướ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:
- Chỉ 1 đơn được tạo. Database ép, không cần app hợp tác.
- 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ệnh | Trùng thì sao | Dùng khi |
|---|---|---|
INSERT trần | Ném lỗi 23505 | Trùng là bất thường, muốn thấy trong log |
ON CONFLICT DO NOTHING | Im lặng, INSERT 0 0 | Trùng là bình thường (webhook, retry) |
ON CONFLICT DO UPDATE ... RETURNING | Trả 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)
Để ý
idnhảy từ 1 sang 3. LầnINSERTthấ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ống | Payload | Ý định thật |
|---|---|---|
| Khách nạp ví 100k, xong nạp thêm 100k nữa | giống hệt | 2 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ệt | 2 đơn |
| Bấm "+1" vào giỏ 3 lần để mua 3 cái | giống hệt | 3 lần |
| Admin gửi email nhắc nợ, khách không thấy, gửi lại | giống hệt | 2 email, cố ý |
| Chấm công vào/ra | giống hệt | 2 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ữ hash | Chặn được retry sau timeout? | Chặn nhầm request thật? |
|---|---|---|
| 5 giây | Không — retry thường ở 30s/60s | Hiếm |
| 60 giây | Một phần | Khách mua lại trong 1 phút bị chặn |
| 24 giờ | Có | 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 | Có | Có |
| Chống F5 / back | Có | Có |
| Chống client retry sau timeout | Không — đã hết in-process | Có |
| Chống proxy / load balancer retry | Không | Có |
| Chống webhook retry sau 60s | Không | Có |
| Cho phép 2 request giống hệt cố ý | Không — chặn oan | Có |
| Trả lại kết quả cũ | Không — chỉ 409 mù | Có |
| Chịu được payload đổi byte khi retry | Không — hỏng im lặng | Có — 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ăm | Lư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ọ có 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ụ đó:
- Form "Lưu cấu hình" của admin — lưu 2 lần cùng nội dung là no-op, chặn cũng không mất gì.
- Thao tác kiểu PUT (thay thế toàn bộ) — vốn đã idempotent, chặn hay không đều đúng.
- Cửa sổ 3–5 giây thuần để chặn double-click, và bên dưới vẫn có unique index.
Đây là cách dùng hợp lý nhất: coi nó như một
wire:loadingphía server, không phải như cơ chế idempotency.
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ầng | Chặn được nguồn nào (theo Phần 0) | Lợi ích thật | Chi phí thêm |
|---|---|---|---|
| 1. Client | #1 double-click | Giả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ữa | Gần như 0 |
| 2. Idempotency key | #1 #2 #3 #4 #6 | Duy 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ệt | 1 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ào | Bao được cả side effect ngoài DB: gọi API, gửi mail, ghi file | Thê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ạn | Khô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ảng | 1 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àm | Gãy khi |
|---|---|
| Disable nút bằng JS | JS 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á form | Chỉ với wire:submit. Mọi wire:click đều trần trụi |
wire:loading.attr="disabled" không có wire:target | Nú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 |
| PRG | Không cứu được click thứ hai trước khi redirect kịp về |
| Debounce phía client | Chỉ 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ệncommit. 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
- Client sinh key sai chỗ. Sinh key mỗi lần gửi thay vì mỗi ý định thì retry mang key mới, và cơ chế vô dụng. Key phải sinh lúc mở form, không phải lúc bấm nút.
- Không kiểm tra
request_hash. Client dùng lại key cho payload khác, server trả về response cũ của đơn khác. Lỗi này im lặng và cực khó truy. - Trạng thái
in_progresskẹt vĩnh viễn. Server chết giữa chừng → dòng key nằm lại ởin_progress→ mọi retry đều bị 409. Cần job dọn các dòngin_progressquá 5 phút. - Bảng phình. 1 triệu request/ngày × 30 ngày = 30 triệu dòng vô dụng. Phải có TTL và partition hoặc cron xoá.
- Lưu response body vào DB. Response nặng (danh sách 500 item) làm bảng phình nhanh và làm chậm cả những request không trùng.
3.3. Lock gãy ở đâu
| Vấn đề | Chuyện gì xảy ra | Cách giảm |
|---|---|---|
| TTL ngắn hơn công việc | Lock 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àn | TTL >> 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út | Luôn dùng finally, hoặc dùng dạng closure withoutOverlapping() |
| Redis failover | Master chết, replica lên thay chưa kịp nhận key lock → hai client cùng cầm lock | Chấ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ộng | Khoá theo "place-order" thay vì "place-order:user:9" → cả hệ thống đặt hàng nối đuôi nhau | Key lock phải chứa định danh nhỏ nhất còn đúng |
| Advisory lock quên COMMIT | Dù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 deadlock | Hai transaction khoá 2 dòng theo thứ tự ngược nhau | Luô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 kiaINSERTcó 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 UPDATEghi 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 được | Chi phí | Điểm gãy chính | Chọ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ảnh | Combo | Vì 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 request | 1 INSERT ... ON CONFLICT + 1 UPDATE ≈ +1–3ms |
| Redis cho lock | 2 lệnh/request, không đáng kể — nhưng thêm 1 điểm chết vào đường ghi |
| Unique index | Ghi chậm hơn ~5–10%; đọc nhanh hơn nếu index dùng được cho query |
5.2. Cần monitor gì
- Số lỗi
23505(unique violation) theo giờ. Đây là chỉ báo tốt, không phải xấu: nó cho biết tầng 4 đang thật sự bắt được cái mà tầng 1–3 để lọt. Tăng đột biến = có ai đó vừa deploy code bỏ mất một tầng. - Tỉ lệ
Idempotency-Keytrùng. Bình thường 0.1–1%. Nhảy lên 10% = client đang retry liên tục, tức là có gì đó chậm hoặc timeout sai. - Số dòng
in_progressquá 5 phút. Mỗi dòng như vậy là một người dùng đang bị kẹt ở 409. - Số lần
LockTimeoutException. Tăng = lock đang giữ quá lâu hoặc key lock quá rộng. - p99 thời gian giữ transaction. Đây là biến điều khiển thời gian request khác phải đợi.
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
23505trê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:
- Cầm máu ngay (5 phút): đổi
wire:click="placeOrder"thành<form wire:submit="placeOrder">, hoặc thêmwire:loading.attr="disabled" wire:target="placeOrder". Deploy được ngay, giảm ~95% ca. - Chốt chặn thật (cùng ngày):
Từ giờ database không cho tạo trùng, dù code có sai thế nào.CREATE UNIQUE INDEX CONCURRENTLY one_pending_order_per_cart ON orders (cart_id) WHERE status = 'pending'; - Xử lý lỗi cho tử tế (cùng ngày): bắt
UniqueConstraintViolationExceptionrồ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 - 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 INDEXsẽ 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: ghipending→ làm → đánh dấudone, và có job quét lại các dòngpendingquá 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"
- Sinh viên bấm "Bắt đầu làm bài" → client sinh
attempt_id(UUID v4). - Làm bài trong trình duyệt.
- Bấm "Nộp bài" → gửi kèm
attempt_id, nút submit bị disable. - Server
INSERTvào bảngquiz_attempts, cộtattempt_idcóUNIQUE.
Chấm theo tài liệu này thì thiết kế trên làm đúng ba việc:
- Sinh key lúc ý định ra đời ("Bắt đầu làm bài"), không phải lúc bấm nút gửi — đúng phần 1.2.
- Có tầng 4 unique index, không chỉ dựa vào JS.
- Có tầng 1 khoá nút.
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ài | Server 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 tab | 3 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 thi | Khô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ộp | Khô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ộp | Server tạo attempt lúc bắt đầu | |
|---|---|---|
| Chống double-click nút Nộp | Có | Có |
| Chống nộp lại sau timeout | Có | Có |
| Chống làm bài nhiều lần | Không — sinh viên tự sinh id mới | Có — partial unique index |
| Chống mở nhiều tab | Không | Có |
| Ép giới hạn thời gian | Không — tin started_at của client | Có — started_at server ghi |
| Không mất bài khi crash | Không | Có — autosave |
| Biết ai đang thi | Không | Có |
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ệc | Làm việc này |
|---|---|
| Form Livewire | Đổi wire:click thành <form wire:submit> |
| Mọi bảng có thể trùng | CREATE UNIQUE INDEX CONCURRENTLY (partial nếu là luật nghiệp vụ) |
| API công khai | Bắt buộc header Idempotency-Key |
| Webhook | insertOrIgnore trên event_id, luôn trả 200 |
| Queue job | Bả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 DB | Cache::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ạmtmp_*. Hành vi Livewire đọc từvendor/livewire/livewire/dist/livewire.esm.jscủalivewire/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.