14 giờ 05, pipeline deploy xanh lè, rồi Sentry bắt đầu đỏ rực với lỗi SQLSTATE[42S22]: Column not found: 1054 Unknown column 'name'. Migration đổi tên users.name thành users.full_name chạy ngon lành. Vấn đề là trong khoảng bốn chục giây, bản release cũ vẫn còn phục vụ traffic trên một nửa số server, mà code cũ thì vẫn SELECT name. Queue worker chưa restart còn tệ hơn: job cứ fail liên tục cho tới khi có người vô gõ tay php artisan queue:restart.
Trong câu chuyện đó, bản thân migration hổng có bug gì hết. Cái sai nằm ở giả định rằng schema và code đổi cùng một khoảnh khắc. Thực tế không bao giờ như vậy. Deploy nào cũng có một khoảng mà code cũ chạy trên schema mới, hoặc code mới chạy trên schema cũ, và với rolling deploy hay blue/green thì khoảng đó có thể kéo dài vài phút. Zero-downtime migration nói gọn là: mỗi thay đổi schema phải tương thích với cả hai phiên bản code có thể đang chạy cùng lúc.
Vì sao "cứ chạy migrate" lại toang trên production
Một lần deploy thường gồm ba việc: đẩy code mới, chạy php artisan migrate --force, chuyển traffic. Sắp thứ tự kiểu gì thì cũng có chỗ chồng lấn:
- Migrate trước, đổi code sau: code cũ chạy trên schema mới. Rename hay drop cột là nó gãy liền.
- Đổi code trước, migrate sau: code mới chạy trên schema cũ. Cột mới mà nó đọc thì chưa tồn tại.
- Rolling deploy (Kubernetes, nhiều server Forge, Vapor): hai phiên bản cùng phục vụ traffic suốt quá trình rollout.
- Queue worker và scheduled command giữ code cũ trong memory cho tới khi restart, nên luôn chậm chân hơn web server.
- Rollback: release mới lỗi thì mình deploy lại code cũ. Nếu migration đã lỡ drop thứ gì đó, code cũ giờ cũng chết theo.
Phía database cũng có chuyện riêng. Với bảng lớn, một số lệnh ALTER TABLE sẽ giữ metadata lock hoặc rewrite cả bảng. MySQL 8 làm được nhiều thay đổi với ALGORITHM=INSTANT hoặc INPLACE, PostgreSQL thêm cột nullable mà không rewrite, nhưng một câu UPDATE users SET full_name = name chạy trên 20 triệu dòng sẽ giữ row lock lâu, làm phình undo log hoặc WAL, và làm replica lag theo. Vậy nên "zero downtime" có hai nửa: code phải tương thích, và thao tác trên database phải nhỏ, thân thiện với lock.
Pattern Expand/Contract
Expand/contract (còn gọi là parallel change) chẻ một thay đổi phá vỡ tương thích ra thành nhiều thay đổi nhỏ, không phá gì hết. Mình expand schema để hình dạng cũ và mới cùng tồn tại, chuyển dữ liệu và code từng bước nhỏ, rồi contract bằng cách bỏ hình dạng cũ khi không còn ai xài. Mỗi bước là một lần deploy riêng, và lần deploy nào cũng rollback về bản trước được an toàn.
Với vụ đổi name thành full_name, các phase sẽ như vầy:
- Expand: thêm cột
full_namenullable. Code cũ lơ nó đi. Không có gì gãy. - Ghi song song (dual write): deploy code ghi vô cả
namelẫnfull_name, nhưng vẫn đọc từname. Từ giờ dòng nào mới tạo hoặc mới sửa cũng có đủ hai giá trị. - Backfill: copy
namequafull_namecho các dòng cũ, chia batch nhỏ, chạy bằng job hoặc command, chứ không bỏ vô transaction của migration. - Chuyển đọc: deploy code đọc
full_name(cẩn thận thì fallback vềname), vẫn ghi cả hai. - Ngưng ghi cột cũ: deploy code chỉ đụng tới
full_name. Giữnamethêm một release nữa để rollback vẫn chạy được. - Contract: drop
nameở một release sau đó, và tới lúc này mới setfull_namethành NOT NULL nếu cần.
Đúng rồi, một lần rename có thể tốn tới năm lần deploy. Đó là cái giá để không bao giờ có khoảng downtime, và nó trả công xứng đáng ngay lần đầu tiên mình cần rollback.
Cài đặt trong Laravel
Phase 1: expand
Migration càng nhỏ càng tốt và chỉ thêm vô. Không copy dữ liệu ở đây.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('full_name')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('full_name');
});
}
};
Cột để nullable là có chủ ý. Nếu thêm NOT NULL mà không có default, code cũ insert user không có full_name sẽ fail ngay lượt đăng ký kế tiếp.
Phase 2: ghi song song trong model
Chỗ gọn nhất để dual write là model event hoặc mutator, vậy là mọi đường đi của code (controller, import, form Filament, seeder) đều được hưởng mà không cần sửa từng chỗ.
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $fillable = ['name', 'full_name', 'email', 'password'];
protected static function booted(): void
{
static::saving(function (User $user): void {
if ($user->isDirty('name') && ! $user->isDirty('full_name')) {
$user->full_name = $user->name;
}
if ($user->isDirty('full_name') && ! $user->isDirty('name')) {
$user->name = $user->full_name;
}
});
}
}
Model event không chạy với update bằng query builder kiểu User::where(...)->update([...]) hay raw SQL. Grep cả codebase tìm mấy chỗ mass update trên bảng đó rồi sửa tay, không thì backfill cứ gặp hoài dòng bị lệch.
Phase 3: backfill theo batch
Chạy backfill bằng Artisan command hoặc queued job, đừng để trong migration. Migration chạy ngay trong lúc deploy, và trên PostgreSQL còn nằm trong transaction; một migration chạy hai chục phút là block deploy và giữ lock hai chục phút.
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class BackfillUserFullName extends Command
{
protected $signature = 'backfill:user-full-name {--batch=1000} {--sleep=100}';
protected $description = 'Copy users.name into users.full_name in small batches';
public function handle(): int
{
$batch = (int) $this->option('batch');
$sleepMs = (int) $this->option('sleep');
$total = 0;
DB::table('users')
->whereNull('full_name')
->select(['id'])
->chunkById($batch, function ($rows) use (&$total, $sleepMs): void {
$ids = $rows->pluck('id')->all();
$updated = DB::table('users')
->whereIn('id', $ids)
->whereNull('full_name')
->update(['full_name' => DB::raw('name')]);
$total += $updated;
$this->info("Updated {$total} rows, last id {$ids[array_key_last($ids)]}");
usleep($sleepMs * 1000);
});
$this->info("Done. {$total} rows backfilled.");
return self::SUCCESS;
}
}
Mấy chi tiết quan trọng:
chunkByIdphân trang bằngWHERE id > ? ORDER BY id LIMIT n(keyset pagination). Cònchunk()thường thì xàiOFFSET, trang sau chậm hơn trang trước, và tệ hơn nữa là bỏ sót dòng khi điều kiệnWHEREthay đổi giữa chừng. Ở đây mình update đúng cái cột đang filter, nên xàichunk()sẽ âm thầm bỏ qua cả nửa bảng.- Câu update lặp lại
whereNull('full_name'), nên command là idempotent. Lỡ user sửa profile giữa lúc backfill thì dual write đã setfull_namerồi, mình không ghi đè. Muốn kill rồi chạy lại lúc nào cũng được. - Mỗi batch là một câu lệnh ngắn, lock chỉ giữ vài mili giây, replica theo kịp. Khoảng sleep để replication và traffic khác có chỗ thở. Trong lúc chạy nhớ coi replica lag rồi chỉnh
--batchvới--sleepcho phù hợp. DB::raw('name')để database tự copy giá trị, không phải đi vòng qua PHP.
Nếu bảng rất lớn và ID dày đặc, còn một vòng lặp đơn giản hơn: đi theo từng khoảng ID cố định (WHERE id BETWEEN 1 AND 1000, rồi 1001 tới 2000, cứ vậy). Cách này bỏ được câu SELECT thừa mỗi batch, và resume cực dễ: chỉ cần log lại khoảng cuối cùng đã xong. Challenge bên dưới sẽ bắt bạn viết đúng cái hàm chia khoảng đó.
Phase 4 và 5: chuyển đọc, rồi ngưng ghi cột cũ
Chuyển đọc kèm accessor có fallback trong một release. Gần như không tốn gì mà đỡ được mấy dòng backfill lỡ sót.
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected function displayName(): Attribute
{
return Attribute::get(fn (): string => $this->full_name ?? $this->name ?? '');
}
}
Trước phase 5, kiểm tra cho chắc là backfill đã xong thiệt: SELECT COUNT(*) FROM users WHERE full_name IS NULL AND name IS NOT NULL phải ra 0. Sau đó gỡ hết mọi chỗ ghi vô name. Grep trong Blade view, API resource, export, raw SQL và cả $fillable.
Phase 6: contract
Chỉ khi release ngưng ghi name đã chạy đủ lâu, lâu tới mức mình chắc chắn không rollback lùi qua nó nữa, thì mới drop cột. Siết full_name thành NOT NULL có thể bỏ chung migration này.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('full_name')->nullable(false)->change();
$table->dropColumn('name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name')->nullable()->after('id');
$table->string('full_name')->nullable()->change();
});
}
};
Để ý là down() không khôi phục được dữ liệu trong name. Chuyện này bình thường với bước contract, và đó cũng là lý do nó đi sau cùng, khi mình đã tự tin. Từ Laravel 11, ->change() bắt mình khai báo lại mọi thuộc tính muốn giữ (độ dài, default, comment), vì modifier nào bỏ sót là bị mất.
Bảng tra thứ tự deploy
Nguyên tắc chung: thay đổi schema kiểu thêm vô thì chạy trước code cần nó; thay đổi kiểu phá bỏ thì chạy sau khi code đã hết cần nó.
| Thay đổi | Lúc chạy migration | Thay đổi code |
|---|---|---|
| Thêm cột nullable hoặc bảng mới | Trước khi deploy code | Release sau mới bắt đầu xài |
| Thêm index | Trước, ưu tiên làm online | Không cần |
| Đổi tên cột | Không làm trực tiếp; dùng expand/contract | Rải ra nhiều release |
| Drop cột | Sau khi code đã ngưng đọc và ghi | Ở release trước đó |
| Thêm NOT NULL | Sau khi backfill xong | Code luôn ghi giá trị |
| Đổi kiểu dữ liệu | Cột mới cộng backfill | Giống rename |
Ngoài ra, deploy nào cũng phải restart queue worker (php artisan queue:restart, hoặc php artisan horizon:terminate nếu xài Horizon); worker là phần chậm nhất trong cả hệ thống khi nhận code mới.
Kiểm thử backfill
Downtime thì không unit test được, nhưng mình test được từng phase có làm đúng như nó nói hay không: backfill có idempotent không, có ghi đè lên dòng mà dual write đã xử lý không, và có xử lý hết dữ liệu qua nhiều batch không. Một Pest feature test với RefreshDatabase là đủ.
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('backfills full_name in batches without overwriting newer values', function () {
User::factory()->count(25)->create(['full_name' => null]);
DB::table('users')->update(['full_name' => null]);
$edited = User::factory()->create(['name' => 'Old Name']);
DB::table('users')->where('id', $edited->id)->update(['full_name' => 'New Name']);
$this->artisan('backfill:user-full-name', ['--batch' => 10, '--sleep' => 0])
->assertSuccessful();
expect(DB::table('users')->whereNull('full_name')->count())->toBe(0)
->and(DB::table('users')->where('id', $edited->id)->value('full_name'))->toBe('New Name');
$this->artisan('backfill:user-full-name', ['--batch' => 10, '--sleep' => 0])
->assertSuccessful();
expect(DB::table('users')->whereColumn('full_name', '!=', 'name')->count())->toBe(1);
});
Batch size 10 với 26 dòng ép command phải chạy ít nhất ba chunk, đó là chỗ bug phân trang hay trốn. Trên CI, chạy thêm php artisan migrate:rollback --step=1 sau mỗi migration expand để chứng minh down() chạy được; rollback chưa từng được chạy thử là rollback không xài được.
Những lỗi hay gặp
- Backfill ngay trong migration. Nó chạy trong lúc deploy, block pipeline và giữ lock. Migration chỉ nên đổi schema; dữ liệu thì đẩy qua command hoặc job.
- Xài
chunk()trong khi sửa chính cột đang filter. Phân trang bằng offset sẽ bỏ sót dòng. XàichunkById()hoặclazyById(). - Thêm
NOT NULLngay bước expand. Code cũ không biết cột đó sẽ fail khi insert. Chỉ thêm ràng buộc sau khi backfill xong và mọi chỗ ghi đã set giá trị. - Quên mấy chỗ ghi không qua Eloquent. Update bằng query builder, raw SQL, service khác xài chung database, trigger của database đều đi vòng qua model event.
- Drop quá sớm. Drop chung release với lúc ngưng xài cột thì rollback sẽ đem lại code cần cột đó. Đợi thêm một release.
ALTER TABLEchạy lâu trên bảng nóng. Kiểm tra engine có làm online được không. Với MySQL, mấy thay đổi không instant thì cógh-osthoặcpt-online-schema-change.- Không restart worker. Queue worker giữ code cũ sống rất lâu sau khi web đã chuyển xong.
Khi nào nên xài, khi nào không
Nên xài expand/contract khi:
- Deploy lên nhiều hơn một server, xài rolling hoặc blue/green, hoặc có queue worker chạy dài hạn.
- Bảng đủ lớn để một lần rewrite hay một câu
UPDATEto đùng sẽ lock thấy rõ. - Mình cần rollback code được mà không phải restore backup database.
- Nhiều service hoặc app đọc chung một bảng và không thể deploy cùng lúc.
Bỏ qua, hoặc đi tắt, khi:
- Bảng mới toanh, chưa ai đọc: cứ sửa thẳng.
- App nhỏ, một server, và người dùng chấp nhận được hai phút
php artisan down. - Thay đổi chỉ là thêm vô (cột nullable mới, bảng mới, index tạo online). Bản thân nó đã là bước expand rồi, không có gì để contract.
Điều quan trọng nhất là đổi cách nghĩ: đừng coi migration là một sự kiện duy nhất, mà là một chuỗi trạng thái, trong đó trạng thái nào cũng tương thích với code đứng trước và code đứng sau nó.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết hàm chia backfill thành các khoảng id cố định, để chạy từng batch nhỏ và resume được khi bị crash giữa chừng. 1. idRangeBatches(int $minId, int $maxId, int $batchSize): array (Python: id_range_batches(min_id, max_id, batch_size) -> list[tuple[int, int]]) trả về các cặp [start, end] liên tiếp, bao gồm cả hai đầu, phủ hết minId..maxId. Mỗi batch tối đa batchSize id, batch cuối bị cắt tại maxId. Ví dụ: (1, 10, 4) ra [[1,4],[5,8],[9,10]]. Nếu minId > maxId thì trả về list rỗng. Nếu batchSize < 1 thì throw InvalidArgumentException (Python: raise ValueError). 2. remainingBatches(int $minId, int $maxId, int $batchSize, ?int $lastDoneId): array (Python: remaining_batches(..., last_done_id: int | None)) trả về các batch như trên nhưng chỉ giữ batch có start lớn hơn lastDoneId. lastDoneId là null/None nghĩa là chưa xử lý gì, trả về hết. Bên PHP trả về list (đánh lại index từ 0); bên Python trả về tuple.
Code khởi tạo
/**
* Split the inclusive id range [$minId, $maxId] into consecutive batches.
*
* @return list<array{0: int, 1: int}>
*/
function idRangeBatches(int $minId, int $maxId, int $batchSize): array
{
// TODO: throw InvalidArgumentException when $batchSize < 1
// TODO: return [[start, end], ...] covering $minId..$maxId inclusive,
// each batch at most $batchSize ids, last batch clipped to $maxId
// TODO: return [] when $minId > $maxId
return [];
}
/**
* Resume a backfill: return only the batches that start after $lastDoneId.
*
* @return list<array{0: int, 1: int}>
*/
function remainingBatches(int $minId, int $maxId, int $batchSize, ?int $lastDoneId): array
{
// TODO: null $lastDoneId means nothing was done yet: return every batch
// TODO: otherwise keep only batches whose start id is greater than $lastDoneId
return [];
}
def id_range_batches(min_id: int, max_id: int, batch_size: int) -> list[tuple[int, int]]:
"""Split the inclusive id range [min_id, max_id] into consecutive batches."""
# TODO: raise ValueError when batch_size < 1
# TODO: return [(start, end), ...] covering min_id..max_id inclusive,
# each batch at most batch_size ids, last batch clipped to max_id
# TODO: return [] when min_id > max_id
raise NotImplementedError
def remaining_batches(
min_id: int, max_id: int, batch_size: int, last_done_id: int | None
) -> list[tuple[int, int]]:
"""Resume a backfill: return only the batches that start after last_done_id."""
# TODO: None means nothing was done yet: return every batch
# TODO: otherwise keep only batches whose start id is greater than last_done_id
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýĐi qua khoảng id với start += batchSize, còn end là min(start + batchSize - 1, maxId).
Vòng for với điều kiện start <= maxId tự động trả về list rỗng khi minId > maxId.
Trong PHP, array_filter giữ nguyên key cũ; bọc thêm array_values để kết quả so sánh bằng với một list.
/**
* Split the inclusive id range [$minId, $maxId] into consecutive batches.
*
* @return list<array{0: int, 1: int}>
*/
function idRangeBatches(int $minId, int $maxId, int $batchSize): array
{
if ($batchSize < 1) {
throw new InvalidArgumentException('Batch size must be at least 1.');
}
$batches = [];
for ($start = $minId; $start <= $maxId; $start += $batchSize) {
$batches[] = [$start, min($start + $batchSize - 1, $maxId)];
}
return $batches;
}
/**
* Resume a backfill: return only the batches that start after $lastDoneId.
*
* @return list<array{0: int, 1: int}>
*/
function remainingBatches(int $minId, int $maxId, int $batchSize, ?int $lastDoneId): array
{
$batches = idRangeBatches($minId, $maxId, $batchSize);
if ($lastDoneId === null) {
return $batches;
}
return array_values(array_filter($batches, fn (array $b): bool => $b[0] > $lastDoneId));
}
def id_range_batches(min_id: int, max_id: int, batch_size: int) -> list[tuple[int, int]]:
"""Split the inclusive id range [min_id, max_id] into consecutive batches."""
if batch_size < 1:
raise ValueError("batch_size must be at least 1")
return [
(start, min(start + batch_size - 1, max_id))
for start in range(min_id, max_id + 1, batch_size)
]
def remaining_batches(
min_id: int, max_id: int, batch_size: int, last_done_id: int | None
) -> list[tuple[int, int]]:
"""Resume a backfill: return only the batches that start after last_done_id."""
batches = id_range_batches(min_id, max_id, batch_size)
if last_done_id is None:
return batches
return [b for b in batches if b[0] > last_done_id]

