Tách code là nửa dễ thấy của một cuộc migration; dữ liệu là nửa khó, và nó chiếm chương dài nhất trong sách. Mỗi lần extract đều phải trả lời cùng những câu hỏi: ai sở hữu dữ liệu này, chuyển nó đi thế nào mà không downtime, và cái gì thay thế các join và transaction mà trước đây ta có sẵn miễn phí?
Ở đây mình nhóm các pattern của chương theo vấn đề mà chúng giải quyết, với ví dụ từ một nền tảng khóa học online: learner, course, enrollment, payment và certificate.
Ứng với chương 4 trong sách.
Ý chính
Sống chung với shared database trong lúc làm
Một shared database (database dùng chung) là implementation coupling ở mức mạnh nhất: không ai biết cột nào đổi được an toàn, và logic quyết định state của một entity nằm rải rác qua nhiều codebase. Nếu trạng thái enrollment có thể bị đổi bởi course service, payment service và một job chạy hằng đêm, thì không có nơi nào duy nhất đảm bảo đâu là một transition hợp lệ.
Newman chỉ chấp nhận chia sẻ trực tiếp trong hai trường hợp: reference data tĩnh, chỉ đọc, và một database mà một service chủ động công bố như một endpoint cho nhiều consumer. Mọi thứ khác rồi sẽ phải tách. Cho đến khi tách được, ba coping pattern (pattern để cầm cự) giữ cho tình hình không tệ thêm:
| Pattern | Cách hoạt động | Giới hạn |
|---|---|---|
| Database view | Consumer đọc một projection thay vì các bảng gốc | Chỉ đọc, cùng database engine, cần có owner |
| Database wrapping service | Một service mỏng đặt trước một schema rối trở thành lối vào duy nhất | Consumer phải chuyển từ SQL sang gọi API |
| Database-as-a-service interface | Một database chỉ đọc riêng cho reporting và công cụ SQL, được đổ dữ liệu bởi một quy trình mapping | Dữ liệu bị trễ; phần mapping cần engineering thật sự |
View là bước đầu rẻ nhất. Nếu công cụ của team marketing đọc trực tiếp các dòng learner, một schema riêng với một view hẹp cho phép team course thoải mái tái cấu trúc bảng bên dưới:
CREATE SCHEMA marketing_read;
CREATE VIEW marketing_read.active_learners AS
SELECT l.id AS learner_id,
l.display_name,
l.country_code,
l.last_active_at
FROM learner AS l
WHERE l.deleted_at IS NULL;
Sách coi một view đã công bố là một interface do team sở hữu bảng nguồn quản lý, và coi cả ba pattern là những bước đệm hướng tới một lần tách thật sự. Newman cũng khuyên cấp cho mỗi consumer một bộ credential database riêng, để ít nhất bạn thấy được ai đang đọc gì.
Quyết định ai sở hữu dữ liệu
Trước khi chuyển bất kỳ dòng dữ liệu nào, hãy quyết định mỗi phần dữ liệu nên nằm ở đâu. Quy tắc của Newman đi ra từ việc coi một service là behaviour cộng state: ai sở hữu các state transition thì sở hữu dữ liệu.
Nếu service mới cần dữ liệu mà monolith vẫn sở hữu, hãy dùng pattern aggregate exposing monolith. Monolith công bố một API (hoặc event) cho aggregate đó, với các operation thật sự thay vì truy cập bảng thô. Giả sử một certificate service mới cần biết một learner đã hoàn thành course chưa. Logic enrollment vẫn nằm trong monolith, nên monolith expose GET /enrollments/{id} và certificate service không bao giờ chạm vào bảng enrollment. Như sách chỉ ra, API đó cũng là bản phác thảo của một enrollment service trong tương lai.
Nếu service mới sở hữu logic thay đổi dữ liệu, hãy dùng change data ownership. Dữ liệu chuyển vào schema của service, và monolith trở thành một client gọi sang service. Nếu monolith chỉ cần đọc, service có thể project một view ngược vào database của monolith, nhưng Newman thích sửa monolith để gọi service hơn.
Một quy tắc liên quan rất dễ áp dụng: dữ liệu mới mà monolith chưa từng có thì đi vào schema riêng của service mới ngay từ ngày đầu (sách gọi là multischema storage), kể cả khi dữ liệu cũ vẫn đang được đọc từ monolith.
Chuyển source of truth mà không big bang
Sách đưa ra hai pattern để chuyển dữ liệu trong khi cả code cũ và code mới đều đang chạy, và cả hai đều giữ một đường rollback.
Synchronize data in application đến từ một cuộc migration hồ sơ y tế ở Đan Mạch sang một database mới. Nó chạy theo các giai đoạn:
stage 1 bulk copy old -> new, then catch up with change data capture
stage 2 write to both, read from old (new store is on probation)
stage 3 write to both, read from new (old store is the fallback)
stage 4 stop writing to old, retire it
Nó hiệu quả nhất khi mỗi lúc chỉ có một codebase ghi; nếu cả hai đều có thể nhận write cho cùng các record, như trong một canary, việc đồng bộ trở nên rất khó.
Tracer write chuyển source of truth (nguồn dữ liệu gốc) dần dần và chấp nhận có hai source of truth trong một thời gian. Bạn đồng bộ một lát dữ liệu nhỏ, chuyển từng consumer một, rồi thêm lát tiếp theo. Trên nền tảng khóa học, bạn có thể chuyển các record payment cơ bản trước, rồi refund, rồi invoice. Ví dụ trong sách là Square, nơi một fulfilment service mới được đổ dữ liệu bởi một background worker nằm sau feature flag và được giữ đồng bộ nhờ caller cập nhật cả hai hệ thống.
Câu hỏi thiết kế là write đến hai nguồn bằng cách nào:
| Lựa chọn | Cách hoạt động | Đánh giá |
|---|---|---|
| Write to one source | Client ghi vào một phía; một background process copy sang phía kia | Đơn giản, một chiều |
| Send writes to both | Client (hoặc một intermediary) gửi mọi write đến cả hai phía | Dùng được; client phải xử lý lỗi một phần |
| Seed writes to either | Client ghi vào phía nào cũng được; dữ liệu đồng bộ hai chiều | Tránh: đồng bộ hai chiều rất khó |
Cách nào thì bạn cũng có eventual consistency: một batch hằng đêm có thể khiến bạn trễ một ngày, streaming CDC thì chỉ vài giây. Lời khuyên mình thấy giá trị nhất là xây một bước reconciliation check (đối soát) ngay từ đầu, dù chỉ là vài query count và sum chạy trên cả hai store. Không có nó, bạn chỉ phát hiện lệch dữ liệu khi một learner phàn nàn.
Schema trước hay code trước?
Một lần extract chỉ xong khi code chạy trong service riêng và dữ liệu của nó nằm trong schema riêng được cô lập về mặt logic. (Newman dùng schema với nghĩa một ranh giới logic; nhiều schema có thể dùng chung một database engine.) Có ba thứ tự:
| Thứ tự | Lợi ích | Rủi ro |
|---|---|---|
| Schema trước, code giữ nguyên | Lộ sớm vấn đề về join, latency và transaction; dễ revert | Ít lợi ích ngắn hạn; không làm được nếu monolith là hộp đen |
| Code trước, schema sau | Có service deploy được sớm hơn; thấy rõ hơn dữ liệu nó cần | Team dừng giữa chừng và giữ shared database mãi mãi |
| Cả hai cùng lúc | Một bước | Bước lớn, phản hồi muộn; sách khuyên tránh |
Quan điểm riêng của Newman: nếu sửa được monolith và lo về performance hoặc consistency thì schema trước; nếu không thì code trước, với cam kết thật lòng sẽ hoàn tất việc tách dữ liệu. Hai pattern giúp ích cho cả hai cách. Repository per bounded context cho thấy code nào chạm vào bảng nào, và một công cụ như SchemaSpy làm lộ ra các foreign key sẽ cắt ngang ranh giới tương lai. Database per bounded context giữ các schema tách riêng ngay cả bên trong một monolith; Newman khuyên dùng nó cho phần lớn hệ thống mới hoàn toàn, và ví dụ ở ThoughtWorks của ông chưa bao giờ cần tách ra.
Phẫu thuật ở mức schema: bảng, foreign key và reference data
Split table. Một bảng thường phục vụ hai context. Bảng course có thể chứa cả field catalogue (title, syllabus) lẫn field bán hàng (price, discount). Hãy tách nó ngay trong schema hiện tại trước, rồi chuyển từng nửa đi. Khi một cột được ghi bởi hai context, hãy quyết định ai sở hữu state machine: trong sách, một cột status của customer bị đổi bởi cả code đăng ký lẫn code finance thì thuộc về phía customer, và finance phải nhờ customer service để suspend ai đó.
Move foreign-key relationship to code. Khi payment và course nằm ở hai schema khác nhau, foreign key giữa chúng biến mất, và bạn mất hai thứ:
- Join. Một báo cáo doanh thu từng join payment với tên course giờ trở thành một query cộng một lần gọi service. Nó sẽ chậm hơn, nên hãy gom lookup thành batch hoặc cache dữ liệu course, và đo xem latency có quan trọng không.
- Referential integrity. Database không còn chặn việc ai đó xóa một course mà payment vẫn đang tham chiếu.
Với việc xóa, sách bác bỏ cách hỏi từng consumer trước khi xóa (mong manh và coupling cao). Các lựa chọn tốt hơn là xử lý record bị thiếu một cách êm, ví dụ với HTTP 410 Gone để caller phân biệt được "đã bị xóa" với "chưa từng tồn tại", và không xóa gì cả, dùng một status soft-delete. Newman sẽ kết hợp cả hai. Trước khi cắt bất kỳ foreign key nào, hãy kiểm tra xem bạn có đang tách đôi một aggregate không: một order và các order line của nó thuộc về nhau.
Static reference data. Với các bảng tra cứu dùng chung như mã quốc gia hay cấp độ course, có bốn lựa chọn:
| Lựa chọn | Khi nào phù hợp |
|---|---|
| Duplicate per service | Tập dữ liệu lớn mà các service không cần khớp tuyệt đối |
| Dedicated reference data schema | Dữ liệu lớn hoặc cần join qua schema; coi schema là một interface có version |
| Static reference data library | Dữ liệu nhỏ, hiếm khi đổi, chấp nhận version khác nhau trong một thời gian |
| Static reference data service | Dữ liệu phải nhất quán ở mọi nơi, hoặc có vòng đời được quản lý bằng code |
Mặc định của Newman là library cho dữ liệu nhỏ không cần nhất quán tuyệt đối, một service khi cần nhất quán, và một schema riêng chỉ khi khó biện minh cho một service mới. Một library có thể nhỏ như thế này:
export const courseLevels = ["beginner", "intermediate", "advanced"] as const;
export type CourseLevel = (typeof courseLevels)[number];
export function isCourseLevel(value: string): value is CourseLevel {
return (courseLevels as readonly string[]).includes(value);
}
Các service sẽ chạy những version khác nhau của nó, nên hãy thêm một level mới từ rất lâu trước khi có ai phụ thuộc vào nó.
Transaction và saga
Tách dữ liệu làm vỡ các transaction atomic. Khi một learner thanh toán, nền tảng ghi nhận payment, kích hoạt enrollment và cấp quyền truy cập; trong monolith, một transaction làm cả ba việc. Newman giải thích vì sao ông tránh two-phase commit: các participant giữ lock giữa lúc vote và lúc commit, các commit không xảy ra cùng một thời điểm, và một participant vote yes rồi im lặng sẽ khiến mọi người bị kẹt. Gợi ý đầu tiên của ông: nếu state thật sự cần thay đổi atomic, đừng tách nó vội.
Nếu không, hãy dùng saga: một chuỗi transaction cục bộ với cách recovery tường minh. Backward recovery chạy các compensating transaction, vốn là semantic rollback (rollback về mặt nghiệp vụ); bạn không thể thu hồi email chào mừng, chỉ có thể gửi một email đính chính. Forward recovery thử lại từ bước bị lỗi. Đặt những bước dễ lỗi nhất lên trước, và các tác động không đảo ngược được xuống cuối, sẽ loại bỏ được nhiều compensation. Một phiên bản orchestrated tối giản:
type SagaStep<Context> = {
name: string;
run: (context: Context) => Promise<void>;
compensate?: (context: Context) => Promise<void>;
};
type SagaResult =
| { status: "completed" }
| { status: "compensated"; failedStep: string };
export async function runSaga<Context>(
steps: SagaStep<Context>[],
context: Context,
): Promise<SagaResult> {
const completed: SagaStep<Context>[] = [];
for (const step of steps) {
try {
await step.run(context);
completed.push(step);
} catch {
for (const done of [...completed].reverse()) {
await done.compensate?.(context);
}
return { status: "compensated", failedStep: step.name };
}
}
return { status: "completed" };
}
Một bản triển khai thật phải persist state của saga và xử lý được compensation bị lỗi; đoạn này chỉ cho thấy hình dạng.
Một orchestrated saga, như ví dụ này, giữ quy trình tường minh ở một chỗ, nhưng orchestrator biết mọi participant và business logic có xu hướng trôi dần vào nó. Một choreographed saga để các service phản ứng với event mà không có điều phối viên; coupling lỏng hơn, nhưng bạn cần một correlation ID trên mọi event và một view được project ra để biết saga đang ở state nào. Newman thoải mái với orchestration khi một team sở hữu toàn bộ saga và thích choreography khi nhiều team cùng chia sẻ nó, và ông thừa nhận bản thân nghiêng về choreography.
Đọc lại ở năm 2026
Các pattern vẫn đứng vững; tooling xung quanh thì đã thay đổi.
- Outbox pattern. Sách không gọi tên nó, nhưng giờ đây nó là cách thông dụng để publish event một cách tin cậy khi làm change data ownership hoặc một choreographed saga. Bạn ghi thay đổi nghiệp vụ và một dòng event trong cùng một transaction cục bộ, rồi một relay hoặc công cụ CDC publish dòng đó sau.
BEGIN;
UPDATE enrollment
SET status = 'active', activated_at = now()
WHERE id = 'enr_42';
INSERT INTO outbox_event (id, aggregatetype, aggregateid, type, payload)
VALUES (gen_random_uuid(), 'enrollment', 'enr_42', 'EnrollmentActivated',
'{"enrollmentId": "enr_42"}');
COMMIT;
- CDC. Newman đã gọi tên Debezium là lựa chọn của ông cho mapping engine. Nó vẫn là lựa chọn open source phổ biến, đã chuyển sang Commonhaus Foundation vào cuối năm 2024, và outbox event router của nó tiêu thụ đúng loại bảng như trên.
- Workflow engine. Các durable execution engine như Temporal là cách phổ biến để xây orchestrated saga mà không phải tự viết phần persistence và retry. Các câu hỏi thiết kế của sách (thứ tự, compensation, ai sở hữu flow) vẫn còn nguyên.
Áp dụng vào dự án
- Liệt kê mọi service, job và công cụ kết nối vào database của monolith, cấp cho mỗi cái credential riêng, và đặt một view hoặc wrapping service trước những reader bạn chưa gỡ được.
- Với mỗi lần extract, ghi lại ai sở hữu từng bảng, chọn aggregate exposing monolith hoặc change data ownership cho phù hợp, và đặt dữ liệu hoàn toàn mới vào schema của service mới.
- Quyết định schema trước hay code trước cho từng lần extract, ghi lại lý do, và đặt ngày hoàn tất việc tách dữ liệu.
- Khi chuyển một source of truth, giữ write một chiều, và xây reconciliation check trước khi chuyển bất kỳ reader nào.
- Trước khi bỏ một foreign key, kiểm tra xem bạn có đang tách đôi một aggregate không, và quyết định consumer xử lý record bị thiếu thế nào.
- Với dữ liệu hiện đang thay đổi atomic, thử để nó ở cùng nhau; nếu buộc phải tách, hãy mô hình hóa một saga có compensation và publish event qua outbox.
Tự kiểm tra
Khi nào Newman chấp nhận một shared database? Với reference data tĩnh chỉ đọc, và với một database được chủ động công bố như một endpoint cho consumer.
Vì sao sách cảnh báo về "seed writes to either source" trong tracer write? Nó cần đồng bộ hai chiều, thứ rất khó làm đúng.
Bạn mất gì khi chuyển một foreign key vào code? Join của database (giờ là một lần gọi service chậm hơn) và referential integrity do database đảm bảo.
Với dữ liệu nhỏ và ổn định, Newman nghiêng về lựa chọn reference data nào? Một shared library, chấp nhận các service có thể chạy những version khác nhau trong một thời gian.
Khi nào ông coi orchestration là chấp nhận được? Khi một team sở hữu toàn bộ saga; khi có nhiều team, ông thích choreography hơn.
Đọc thêm
- Debezium: outbox event router: cách các dòng outbox trở thành event Kafka.
- Tài liệu Temporal: durable workflow để triển khai orchestrated saga.
- PostgreSQL: CREATE VIEW: view làm được gì và không làm được gì, kể cả updatable view.