Đồng bộ DB → search index

4 câu hỏi~30 phútTừ khóa: dual write, outbox, CDC, Debezium, Kafka, idempotent indexing, external version, tombstone, reindex, alias swap, refresh_interval, index lag

Tiếp đi: đồng bộ dữ liệu từ PostgreSQL sang Elasticsearch. Admin vừa đổi giá áo thun từ 199k xuống 149k mà search vẫn hiện 199k.

Đây là cái giá của bài 6.11: giờ có hai nơi lưu cùng một sản phẩm. PostgreSQL là source of truth, index products là bản sao. Bài này trả lời: làm sao bản sao theo kịp, không sai, không mất.

1. Bài toán

2. Vấn đề: dual write

@Transactional
public void updatePrice(String id, BigDecimal price) {
  Product p = repo.findById(id).orElseThrow();
  p.setPrice(price);
  repo.save(p);
  searchClient.index(toDoc(p));   // ghi ES ngay trong transaction
}

Trông ổn, nhưng có ít nhất 4 cách hỏng:

  1. ES lỗi/timeout → exception → rollback DB. Search sập kéo theo admin không sửa được giá.
  2. Bắt exception cho qua → DB 149k, ES 199k mãi mãi.
  3. ES ghi thành công, sau đó commit DB lỗi → ES có giá chưa từng tồn tại.
  4. Hai request song song: A đặt 149k, B đặt 129k. DB commit theo thứ tự A rồi B, nhưng gọi ES theo thứ tự B rồi A → ES kết thúc ở 149k, sai.

Không có transaction chung giữa PostgreSQL và ES. Đây chính là bài toán dual write đã gặp với Kafka ở bài 14.7.

3. Giải pháp

Nguyên tắc: chỉ ghi DB trong transaction. Việc cập nhật index đi sau, bất đồng bộ, lấy thay đổi từ chính DB. Hai cách:

Cách hoạt động của outbox, relay và Debezium đã ở bài 14.7. Ở đây tập trung vào phần riêng của search: indexer phải idempotent, đúng thứ tự, xử lý xóa, và có đường reindex toàn bộ.

4. Code từng bước

Bước 1: Debezium connector (Kafka Connect), chỉ lấy bảng cần thiết:

{
  "name": "shop-products-cdc",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "plugin.name": "pgoutput",
    "database.hostname": "${file:/secrets/db.properties:host}",
    "database.user": "${file:/secrets/db.properties:user}",
    "database.password": "${file:/secrets/db.properties:password}",
    "database.dbname": "shop",
    "topic.prefix": "shop",
    "table.include.list": "public.products,public.product_stock",
    "slot.name": "search_indexer",
    "tombstones.on.delete": "true"
  }
}

Topic shop.public.products, key = id sản phẩm → mọi thay đổi của cùng sản phẩm vào cùng partition, giữ thứ tự (bài 8.6).

Bước 2: thêm cột version vào DB để ES biết bản nào mới hơn:

ALTER TABLE products ADD COLUMN version bigint NOT NULL DEFAULT 0;
-- JPA: @Version Long version;  → mỗi UPDATE tăng version

Bước 3: indexer idempotent với external versioning:

@Component
@RequiredArgsConstructor
@Slf4j
public class ProductIndexer {
  private final ElasticsearchClient es;
  private final ProductReadRepository reads;   // đọc lại từ DB/replica

  @KafkaListener(topics = "shop.public.products", groupId = "search-indexer")
  public void onChange(ConsumerRecord<String, ProductChange> rec) throws IOException {
    String id = rec.key();
    if (rec.value() == null || rec.value().op() == Op.DELETE) {   // tombstone hoặc delete
      deleteDoc(id, rec.value() == null ? null : rec.value().before().version());
      return;
    }
    ProductRow row = rec.value().after();
    ProductDoc doc = reads.loadDocument(id);   // join category, brand, stock
    if (doc == null) return;                   // đã bị xóa sau đó, bỏ qua
    try {
      es.index(i -> i.index("products")        // ghi qua alias
          .id(id)
          .version(doc.version())
          .versionType(VersionType.External)   // chỉ ghi nếu version > version đang có
          .document(doc));
    } catch (ElasticsearchException e) {
      if (e.status() == 409) {                 // bản cũ tới muộn: bỏ qua là đúng
        log.debug("stale event for {} v{}", id, doc.version());
        return;
      }
      throw e;   // lỗi khác: để Kafka retry / DLT
    }
  }

  private void deleteDoc(String id, Long version) throws IOException {
    es.delete(d -> {
      d.index("products").id(id);
      if (version != null) d.version(version + 1).versionType(VersionType.External);
      return d;
    });   // 404 cũng coi là thành công
  }
}

Bước 4: reindex toàn bộ không downtime bằng alias:

# 1. Tạo index mới với mapping mới
curl -X PUT "$SEARCH_URL/products_v2" -H 'Content-Type: application/json' -d @products-v2.json
# 2. Đổ dữ liệu: job đọc DB theo keyset (id > ?), _bulk vào products_v2, refresh_interval -1 cho nhanh
#    Indexer realtime vẫn ghi vào alias "products" (đang trỏ v1) — và ghi thêm vào v2 trong lúc build
# 3. Bật lại refresh, kiểm tra: count, vài query mẫu
# 4. Đổi alias nguyên tử
curl -X POST "$SEARCH_URL/_aliases" -H 'Content-Type: application/json' -d '{
  "actions": [
    { "remove": { "index": "products_v1", "alias": "products" } },
    { "add":    { "index": "products_v2", "alias": "products", "is_write_index": true } }
  ]}'
# 5. Giữ v1 vài ngày để rollback, rồi xóa

Vì dùng external version, event realtime và bulk load ghi chồng nhau cũng không sai: bản nào version cao hơn thắng. Có thể thay bước 2 bằng: ghi lại offset Kafka, bulk load, rồi cho consumer group mới chạy từ offset đó vào v2.

Bước 5: test bằng curl:

curl -X PATCH localhost:8080/api/admin/products/p-1001 -H 'Content-Type: application/json' \
     -d '{"price":149000}'
sleep 3
curl -s "$SEARCH_URL/products/_doc/p-1001" -u "$SEARCH_USER:$SEARCH_PASSWORD" | jq '._version, ._source.price'
# 8
# 149000

5. Cách hoạt động

 Admin API          PostgreSQL (source of truth)
    │ UPDATE price      │
    └──────────────────►│ WAL ──► logical slot "search_indexer"
                        │                │
                        │          Debezium (Kafka Connect)
                        │                ▼
                        │      Kafka: shop.public.products   key = productId
                        │                │   (cùng key → cùng partition → đúng thứ tự)
                        │                ▼
                        │◄─── đọc lại ── Indexer (consumer group search-indexer)
                        │                │  index(id, version, External)
                        │                ▼
                        │      alias "products" ──► products_v2
                        │                │  refresh_interval 1s
   User search ─────────┼───────────────►│  (thấy giá mới sau ~1–3 giây)
   Checkout ───────────►│ đọc giá từ DB, không từ ES

Near real-time: ES ghi vào buffer, chỉ sau refresh (mặc định 1 giây, chỉ trên index có search gần đây) thì document mới tìm được. Tổng độ trễ = Debezium + Kafka + indexer + refresh, thường 1–5 giây. Tăng refresh_interval lên 5–30 giây giúp ghi nhanh hơn nhiều khi tải lớn. Không gọi refresh=true sau mỗi lần ghi trên production.

6. So sánh

CáchNhất quánĐộ phức tạpNhược điểm
Dual write trong codeCó thể lệch vĩnh viễnThấpMất update, đảo thứ tự, ES sập kéo DB
Batch reindex định kỳ (cron)Trễ phút/giờThấpQuét bảng tốn tải; khó bắt delete
Outbox + consumerEventual, vài giâyTrung bìnhPhải sửa mọi chỗ ghi để ghi outbox
CDC DebeziumEventual, vài giây; bắt cả sửa bằng SQL tayCao (Kafka Connect, slot)Slot treo làm WAL phình; event là dạng bảng, không phải domain

7. Khi nào nên dùng / không nên dùng

PO hỏi: "Sao khách thấy 199k trên trang search, bấm vào lại thành 149k? Phải đồng bộ ngay lập tức chứ." Mình trả lời sao cho hợp lý?

Bạn đừng hứa "ngay lập tức". Hãy đưa ra một con số cam kết và thiết kế để độ lệch không gây hại:

  1. Đặt SLO cho độ trễ: "99% thay đổi xuất hiện trên search trong 5 giây". Đo được thì mới nói chuyện được.
  2. Chỗ quan trọng đọc DB: trang chi tiết, giỏ hàng, checkout luôn lấy giá và tồn kho từ PostgreSQL. Search chỉ để tìm. Khách thấy 199k ở list, vào chi tiết thấy 149k: rẻ hơn thì vui, đắt hơn thì checkout vẫn đúng giá thật.
  3. Read-your-writes cho admin: sau khi admin lưu, UI admin hiển thị bản từ DB, hoặc indexer nhận cờ để ghi với refresh=wait_for riêng cho thao tác admin (ít, nên chấp nhận được).
  4. Flash sale: hết hàng thì search có thể còn hiện vài giây; nút "Mua" kiểm tra tồn kho thật nên không bán quá.

Câu ngắn gọn cho PO: "Search là bản sao để tìm nhanh, trễ tối đa vài giây. Giá và tồn kho khách trả tiền luôn lấy từ nguồn chính." Đây là eventual consistency có kiểm soát (bài 14.5).

Làm sao mình biết indexer đang chậm hay đã chết, trước khi khách phàn nàn?

Đúng, đồng bộ bất đồng bộ mà không đo thì sẽ hỏng âm thầm. Theo dõi 4 tầng:

TầngMetricAlert gợi ý
PostgreSQLDung lượng WAL giữ lại bởi slot: pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)> 10GB: Debezium treo, disk DB sắp đầy
DebeziumMilliSecondsBehindSource> 60 giây
KafkaConsumer lag của group search-indexerLag tăng liên tục 5 phút
End-to-endnow() - updated_at lúc index xong (histogram Micrometer)p99 > 5 giây (SLO)
Timer lag = Timer.builder("search.index.lag")
    .publishPercentiles(0.5, 0.99).register(meterRegistry);
// trong indexer, sau khi index thành công:
lag.record(Duration.between(doc.updatedAt(), Instant.now()));

Thêm một job đối soát mỗi đêm: lấy mẫu ngẫu nhiên 1.000 id, so version giữa DB và ES; lệch thì index lại và báo cáo. Nó bắt được bug mà metric lag không thấy (ví dụ indexer bỏ qua nhầm event).

Phỏng vấn hỏi về đồng bộ database sang Elasticsearch thì trả lời sao?

Q1. Vì sao không ghi DB rồi gọi Elasticsearch ngay trong service?

🇻🇳 Tiếng Việt:

Đó là dual write, không có transaction chung. ES lỗi thì hoặc rollback DB, hoặc bỏ qua làm hai bên lệch vĩnh viễn. Hai request song song còn có thể ghi ES sai thứ tự. Em chỉ ghi DB, rồi đồng bộ bất đồng bộ qua outbox hoặc CDC Debezium vào Kafka, indexer đọc và ghi ES.

🇬🇧 English:

That is a dual write with no shared transaction. If Elasticsearch fails, we either roll back the database or ignore it and the two stay inconsistent forever. Concurrent requests can also reach Elasticsearch out of order. I only write to the database and sync asynchronously through an outbox or Debezium CDC into Kafka, and an indexer consumes and writes to Elasticsearch.

Q2. Làm sao indexer an toàn khi event bị lặp hoặc đến sai thứ tự?

🇻🇳 Tiếng Việt:

Key Kafka là id sản phẩm để cùng sản phẩm vào cùng partition. Mỗi dòng có version tăng dần, indexer ghi với version_type external, nên ES chỉ nhận bản có version lớn hơn. Event lặp hoặc cũ trả về 409 và được bỏ qua. Xóa thì dùng soft delete hoặc delete event và tombstone.

🇬🇧 English:

The Kafka key is the product id, so changes for one product go to the same partition. Each row has an increasing version and the indexer writes with external version type, so Elasticsearch only accepts a higher version. Duplicate or stale events get a 409 and are skipped. Deletes use soft delete, or delete events with tombstones.

Q3. Đổi mapping mà không downtime thế nào?

🇻🇳 Tiếng Việt:

App luôn đọc ghi qua alias. Em tạo index mới với mapping mới, bulk load từ DB, đồng thời cho event realtime ghi vào index mới. Kiểm tra số lượng và query mẫu, rồi đổi alias nguyên tử trong một lệnh _aliases. Giữ index cũ vài ngày để rollback.

🇬🇧 English:

The app always reads and writes through an alias. I create a new index with the new mapping, bulk load it from the database, and also send realtime events to the new index. After checking counts and sample queries, I swap the alias atomically in one _aliases call, and keep the old index for a few days for rollback.

Cách nhớ ngắn gọn


Tổng kết chương 6: Database (PostgreSQL)

BàiChủ đềÝ chính cần nhớ
6.1ACID & isolation level4 tính chất ACID; dirty/non-repeatable/phantom read; PostgreSQL mặc định Read Committed
6.2Kiến trúc PostgreSQL & WAL1 connection = 1 process; ghi WAL trước → durability, replication, PITR
6.3MVCC & VACUUMUPDATE tạo version mới; reader không chặn writer; VACUUM dọn dead tuple
6.4Index & EXPLAINB-tree, composite leftmost prefix; đọc EXPLAIN ANALYZE
6.5Lock & concurrent transactionRow lock, SELECT FOR UPDATE, optimistic vs pessimistic, deadlock
6.6Thiết kế schema & queryNormalize mặc định; tiền = numeric; constraint; keyset thay OFFSET
6.7Quan hệ bảng ↔ JPAFK ở phía "nhiều"; join table cho N-N; index cột FK
6.8Connection poolPool nhỏ; instances × pool < max_connections; PgBouncer
6.9Replication, partition, shardingReplica scale read, shard scale write; shard key
6.10Backup & restoreRPO/RTO; replica ≠ backup; base backup + WAL = PITR; test restore
6.11Full-text searchLIKE → PG FTS → ES/OpenSearch; analyzer bỏ dấu; text vs keyword; filter vs must; BM25
6.12Vì sao Elasticsearch nhanhInverted index + FST; giao posting list đã sort; segment bất biến, refresh ~1s; doc values, BKD; shard song song
6.13Đồng bộ DB → search indexKhông dual write; outbox/CDC → Kafka → indexer; external version; alias swap; đo lag

Tự kiểm tra

Nêu 2 cách dual write làm DB và ES lệch nhau.

ES lỗi bị nuốt exception (ES giữ giá cũ); hai request song song gọi ES sai thứ tự commit (ES giữ giá không phải mới nhất).

version_type=external giải quyết vấn đề gì?

ES chỉ ghi khi version mới lớn hơn version hiện có: event lặp và event đến muộn bị từ chối (409), indexer idempotent.

Vì sao key Kafka phải là id sản phẩm?

Cùng key vào cùng partition, giữ thứ tự thay đổi của một sản phẩm.

Alias giúp reindex không downtime thế nào?

App chỉ biết tên alias; build xong index mới thì đổi alias sang nó trong một lệnh nguyên tử, index cũ giữ để rollback.

Slot replication của Debezium treo thì ảnh hưởng gì tới PostgreSQL?

PostgreSQL phải giữ WAL chưa được xác nhận, WAL phình có thể làm đầy disk DB. Cần alert dung lượng WAL theo slot.