Một form vừa báo lưu thành công, trang redirect về màn hình chi tiết, nhưng dữ liệu vẫn là bản cũ. Bấm tải lại thêm lần nữa thì mọi thứ mới đúng. Nếu ứng dụng Laravel đang tách kết nối ghi và đọc, đây có thể là stale read từ replica chứ không hẳn là cache trình duyệt hay Eloquent.
Laravel 13.38 thêm hai công cụ đúng vào khoảng trống này: middleware PreventStaleDatabaseReads giữ các lần đọc tiếp theo của cùng session trên primary trong một cửa sổ ngắn, còn usingWritePdo() ép toàn bộ query trong một callback dùng write connection. Chúng không biến replication thành đồng bộ; giá trị nằm ở việc chọn phạm vi nhất quán vừa đủ.
Mục lục [Hiển thị]
Vì sao sticky chưa đủ sau redirect?
Cấu hình read/write của Laravel tự đưa SELECT sang read connection và INSERT, UPDATE, DELETE sang write connection. Theo tài liệu database Laravel 13, tùy chọn sticky bảo đảm những lần đọc sau một write trong cùng request tiếp tục dùng write connection.
'mysql' => [
'driver' => 'mysql',
'read' => ['host' => [env('DB_READ_HOST')]],
'write' => ['host' => [env('DB_WRITE_HOST')]],
'sticky' => true,
// database, username, password, charset...
],
Điểm dễ nhầm nằm ở chữ “cùng request”. Luồng cập nhật thường là POST /profile, ghi primary, rồi trả redirect sang GET /profile. GET là request mới, nên trạng thái “request này đã ghi” của sticky không còn. Nếu replica chưa nhận thay đổi, người vừa bấm lưu sẽ thấy bản cũ.
Fixture của bài dùng Laravel Framework 13.38.0, PHP 8.4.24 và hai file SQLite độc lập: primary được cập nhật, replica cố ý giữ dữ liệu cũ. Với sticky=false, lần đọc sau write đi route read và trả replica-v1. Với sticky=true trong cùng request, route đổi thành write và trả primary-v3. Khi purge connection để mô phỏng request mới sau redirect, lần đọc lại đi read và thấy replica-v1.

Đây là test connection routing có kiểm soát, không phải benchmark replication MySQL/PostgreSQL thật. Bài không gán con số lag trong pull request thành kết quả production. Nếu cần phân biệt với dữ liệu cũ do cache, có thể đối chiếu thêm bài Laravel 13 và cách gia hạn TTL cache; hai lỗi nhìn giống nhau ở giao diện nhưng nằm ở hai lớp khác hẳn.
Middleware giữ read trên primary qua request kế tiếp
Laravel Framework 13.38.0 phát hành ngày 20/10/2026 với PreventStaleDatabaseReads. Cách bật được ghi trực tiếp trong tài liệu chính thức:
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
})
Middleware đọc một timestamp từ session trước khi request chạy. Nếu timestamp còn nằm trong cửa sổ cho phép, default connection được chuyển sang read bằng write PDO. Sau response, nếu Laravel ghi nhận connection vừa sửa record, middleware lưu timestamp mới vào session. Cửa sổ mặc định là hai giây và có thể đổi bằng PreventStaleDatabaseReads::for(5).
Thứ tự middleware quan trọng: nó phải chạy sau StartSession. Việc append vào nhóm web như ví dụ trên đáp ứng điều đó ở cấu hình mặc định. Nếu tự dựng middleware stack hoặc gắn theo route, hãy kiểm tra session đã được khởi động trước khi lớp này đọc/ghi timestamp.
Ma trận fixture cho kết quả khá rõ. Session A vừa write thì ở đúng mốc hai giây vẫn đọc route write và thấy primary-v4; sau ba giây trở lại route read. Session B ở cùng thời điểm vẫn đọc replica. Với cửa sổ tùy chỉnh năm giây, mốc năm giây còn dùng primary, còn sáu giây đã về replica. Điều kiện ở code framework dùng phép so sánh bao gồm biên, nên “trong hai giây” ở đây có cả timestamp đúng mốc hai giây.
Đừng lấy năm giây làm con số chuẩn
Tham số for(5) chỉ là cấu hình, không phải khuyến nghị chung. Cửa sổ quá ngắn vẫn để người dùng gặp dữ liệu cũ khi replica chậm; quá dài lại chuyển thêm read traffic về primary. Cách hợp lý là đo replication lag của chính hệ thống, chọn percentile phù hợp với mức chấp nhận của route và theo dõi tỷ lệ read bị chuyển.
Nên bắt đầu ở các flow nhạy cảm như đổi email hiển thị, chỉnh quyền, cập nhật trạng thái đơn hoặc tạo record rồi redirect về trang chi tiết. Không nhất thiết gắn middleware cho toàn bộ website. Route chỉ đọc feed công khai có thể chấp nhận eventual consistency, trong khi màn hình xác nhận vừa lưu thì không.
Khi rollout, query log phải được redact: không đưa credential, session ID, cookie hay dữ liệu khách hàng vào log. Metric cần trả lời ba câu: replica thường chậm bao lâu, bao nhiêu read bị dồn về primary và primary còn headroom hay không. Nếu chưa có ba dữ kiện này, tăng cửa sổ “cho chắc” rất dễ biến một lỗi UX thành lỗi tải database.
Khi nào nên dùng usingWritePdo()?
Có lúc không cần giữ cả session trên primary; chỉ một đoạn đọc quan trọng phải nhất quán. Pull request usingWritePdo() giải quyết đúng phạm vi đó và đặc biệt tránh một bẫy của onWriteConnection().
$result = DB::connection()->usingWritePdo(function () use ($email) {
$user = User::query()->where('email', $email)->firstOrFail();
return [
'user' => $user,
'drafts' => $user->posts()->whereNull('published_at')->get(),
];
});
Trong fixture, FixtureUser::onWriteConnection() đưa query lấy user sang primary, nhưng relationship query tiếp theo quay lại replica: log là write, read, kết quả ghép primary-v4 với replica-post-v1. Khi bọc cùng flow bằng usingWritePdo(), cả hai query đều đi write, write và lấy relationship từ primary.
Phương thức cũng lưu read preference cũ rồi phục hồi trong khối finally. Fixture cố ý ném fixture-exception trong callback; query kế tiếp trở lại route read và trả dữ liệu replica. Đây là khác biệt thực dụng so với tự gọi useWriteConnectionWhenReading(true) rồi nhớ tắt bằng tay: exception không để connection “dính” primary ngoài ý muốn.
usingWritePdo() chỉ ảnh hưởng các query dùng cùng connection trong callback. Nó không mở transaction, không chờ replica đồng bộ, không đồng bộ nhiều database và không bảo vệ job chạy sau đó. Tên phương thức nói đúng việc nó làm: chọn write PDO cho read trong một phạm vi có kiểm soát.
So sánh bốn cách trước khi chọn

| Cách | Phạm vi | Hợp khi | Giới hạn chính |
|---|---|---|---|
sticky=true | Cùng request sau write | Write và read nối nhau không redirect | Reset ở request kế tiếp |
PreventStaleDatabaseReads | Cùng session, qua nhiều request trong cửa sổ | POST → redirect → GET cần thấy write của chính người dùng | Default connection, cần session; session khác không được bảo vệ |
usingWritePdo() | Một callback trên một connection | Query gốc và relationship phải cùng đọc primary | Không tự mở transaction hay xử lý nhiều connection |
| Đọc primary mọi lúc | Toàn bộ read đã cấu hình | Replica không còn phù hợp với yêu cầu nhất quán | Mất lợi ích scale read, tăng tải primary |
Nếu ứng dụng chưa lên 13.38, đừng copy riêng middleware mà bỏ qua quy trình nâng cấp. Hãy đọc changelog, cập nhật lockfile, chạy suite và kiểm lại read/write config theo một checklist như bài nâng cấp Laravel sau khi hết bug fix. Hai API mới nhỏ, nhưng lỗi routing database thường chỉ lộ ở flow có redirect và replica thật.
Những giới hạn dễ bỏ sót
Middleware theo dõi default database connection. Nếu một request ghi connection tenant nhưng đọc connection reporting, timestamp session không tự tạo strong consistency giữa hai nơi. App nhiều tenant, database shard hoặc nhiều connection cần chính sách riêng cho từng flow.
API stateless thường không có session xuyên request, nên middleware không phải lời giải mặc định. Queue worker cũng không đại diện cho cùng phiên trình duyệt; read sau write trong job nên dùng transaction boundary, job ordering, retry hoặc primary routing phù hợp với nghiệp vụ. Với dữ liệu yêu cầu nhất quán tuyệt đối, cần xem lại kiến trúc thay vì kéo cửa sổ session lên vài phút.
Timestamp chỉ bảo vệ người vừa thực hiện write. Người dùng khác vẫn có thể đọc replica cũ, đúng như giới hạn được nêu trong pull request của middleware. Đây là read-your-writes consistency theo session, không phải strong consistency cho mọi người.
Cũng đừng nhầm stale read với accessor cache trong model. Nếu object giữ giá trị đã tính sau khi thuộc tính đổi, hướng chẩn đoán nằm ở vòng đời model; nếu query log nói đang đọc read connection khác primary, mới quay về bài toán replica. Các test có positive và negative control giúp tách hai lớp này tốt hơn kiểu “refresh vài lần thấy hết”, tương tự cách trình bày trong bài test xanh chưa đủ cho code AI.
Checklist rollout không làm primary nghẹt thở
Trước hết, tái hiện flow bằng hai nguồn dữ liệu kiểm soát: primary có giá trị mới, replica cố ý giữ giá trị cũ. Ghi readWriteType hoặc connection name đã redact cho từng query. Chạy ít nhất các case sticky tắt/bật trong cùng request, redirect không middleware, cùng session trong/ngoài cửa sổ, session khác và callback có relationship.
Sau đó bật trên nhóm route hẹp, theo dõi replica lag và tải primary. Chọn cửa sổ theo số đo thật, chuẩn bị rollback bằng cách tháo middleware hoặc giảm phạm vi route. Nếu dùng usingWritePdo(), thêm case exception để chắc read preference được phục hồi và case relationship để tránh chỉ kiểm query đầu tiên.
Fixture của bài đạt 11/11 case trên hai SQLite cục bộ, nhưng không mô phỏng mạng, failover, connection pool hay lag thật. Nó chứng minh Laravel chọn đúng read/write PDO trong các tình huống đã nêu. Production vẫn cần quan sát riêng, oke.
Chốt lại
sticky phù hợp khi write và read nằm trong cùng request. PreventStaleDatabaseReads nối khoảng trống sau redirect cho cùng session, mặc định hai giây. usingWritePdo() dành cho một callback cần cả query gốc lẫn relationship đi primary và tự khôi phục trạng thái khi xong.
Đừng bật tất cả chỉ vì “dữ liệu cũ”. Hãy xác định phạm vi consistency mà flow thật sự cần, đo replica lag, test cả nhánh âm và theo dõi tải primary. Chọn đúng một lớp thường đủ; ép mọi read về primary chỉ nên là quyết định kiến trúc có số đo, không phải nút chữa cháy.