Khó DevOps

Migration database không downtime với Expand/Contract: đổi tên cột mà không làm gãy deploy

Đổi tên cột giữa lúc deploy là server cũ với queue worker gãy liền. Xài expand/contract, backfill theo batch và đúng thứ tự deploy để đổi schema an toàn.

28 Th10, 2026 16 phút Cập nhật 23 Th10, 2026 6 Khối code
Sơ đồ
graph LR A[Expand: thêm cột full_name nullable] --> B[Ghi song song cả name và full_name] B --> C[Backfill dữ liệu cũ theo batch] C --> D[Chuyển đọc sang full_name] D --> E[Ngưng ghi cột name] E --> F[Contract: drop cột name]

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.

graph LR A[Expand: thêm cột full_name nullable] --> B[Ghi song song cả name và full_name] B --> C[Backfill dữ liệu cũ theo batch] C --> D[Chuyển đọc sang full_name] D --> E[Ngưng ghi cột name] E --> F[Contract: drop cột name]

Với vụ đổi name thành full_name, các phase sẽ như vầy:

  1. Expand: thêm cột full_name nullable. Code cũ lơ nó đi. Không có gì gãy.
  2. Ghi song song (dual write): deploy code ghi vô cả name lẫn full_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ị.
  3. Backfill: copy name qua full_name cho 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.
  4. Chuyển đọc: deploy code đọc full_name (cẩn thận thì fallback về name), vẫn ghi cả hai.
  5. Ngưng ghi cột cũ: deploy code chỉ đụng tới full_name. Giữ name thêm một release nữa để rollback vẫn chạy được.
  6. Contract: drop name ở một release sau đó, và tới lúc này mới set full_name thà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:

  • chunkById phân trang bằng WHERE id > ? ORDER BY id LIMIT n (keyset pagination). Còn chunk() thường thì xài OFFSET, 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ện WHERE thay đổi giữa chừng. Ở đây mình update đúng cái cột đang filter, nên xài chunk() 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 đã set full_name rồ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 --batch với --sleep cho 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ài chunkById() hoặc lazyById().
  • Thêm NOT NULL ngay 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 TABLE chạ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-ost hoặc pt-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 UPDATE to đù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 tương tác

Thử thách

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

Đề bài

Viết 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

php
/**
 * 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 [];
}
python
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 ý
Gợi ý 1

Đi qua khoảng id với start += batchSize, còn end là min(start + batchSize - 1, maxId).

Gợi ý 2

Vòng for với điều kiện start <= maxId tự động trả về list rỗng khi minId > maxId.

Gợi ý 3

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.

Lời giải · php
/**
 * 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));
}
Lời giải · python
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]

Cùng chủ đề DevOps