🇬🇧 Read the English version of this article on tanhdev.com

Answer-first: Chuyển đổi Composable Commerce từ Monolith sang MACH yêu cầu áp dụng Strangler Fig qua API Gateway, đồng bộ dữ liệu CDC Debezium từ binlog tránh dual-write, và phân tán khóa Redis tại tầng BFF. Kiến trúc đảm bảo tính nhất quán cuối cùng (Eventual Consistency) và zero-downtime cho hệ thống triệu đơn.

Kiến trúc Ecommerce 2026: Vượt Qua Nợ Kỹ Thuật Khi Chuyển Đổi Sang Composable Commerce

Trên lý thuyết, MACH (Microservices, API-first, Cloud-native, Headless) và Composable Commerce là “chén thánh” của ngành thương mại điện tử. Tuy nhiên, khi hệ thống đạt mốc xử lý hàng triệu giao dịch, những vấn đề về tính nhất quán dữ liệu và chi phí quan sát hệ thống (Observability) mới thực sự lộ diện. Bài viết này đúc kết kinh nghiệm xương máu từ đội ngũ kiến trúc sư trưởng (Chief Architect) khi đưa hệ thống từ Monolith lên Composable.

Điểm nghẽn thực sự khi chia nhỏ hệ thống (Eventual Consistency)

Bản chất kỹ thuật: Điểm nghẽn lớn nhất không nằm ở việc thiết lập Kafka hay API Gateway, mà ở bài toán “Eventual Consistency” (Tính nhất quán cuối cùng). Để ngăn chặn tình trạng khách mua lố hàng (oversell) do độ trễ cập nhật tồn kho từ Event-bus, hệ thống bắt buộc phải áp dụng cơ chế Optimistic Locking ngay tại tầng BFF.

Khi tách biệt Search Engine (như Algolia) và Inventory Service thông qua Event-bus, dữ liệu cần vài giây để đồng bộ.

  • Nếu khách hàng “Add to Cart” sản phẩm cuối cùng, Inventory Service trừ stock ngay lập tức.
  • Tuy nhiên, Search UI chưa kịp nhận event để ẩn sản phẩm. Khách hàng thứ 2 vẫn thấy hàng và bấm mua, dẫn đến lỗi.
  • Giải pháp thực chiến: Sử dụng Redis tại tầng BFF (Backend-For-Frontend) để khóa tạm thời (Lock) trạng thái giỏ hàng trước khi event hoàn tất vòng đời. Đừng bao giờ phó mặc hoàn toàn cho Event-bus trong giao dịch thanh toán.

Snippet: Go / Redis Distributed Lock Implementation (cartlock Package)

package cartlock

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

var (
	ErrLockAcquisitionFailed = errors.New("cartlock: unable to acquire redis lock, cart state is currently busy")
	
	// Atomic Lua Release Script: Checks if key value matches token before deleting to prevent unlocking another instance's lock
	luaReleaseScript = redis.NewScript(`
		if redis.call("get", KEYS[1]) == ARGV[1] then
			return redis.call("del", KEYS[1])
		else
			return 0
		end
	`)
)

type CartLocker struct {
	client *redis.Client
}

func NewCartLocker(client *redis.Client) *CartLocker {
	return &CartLocker{client: client}
}

// generateToken creates a 16-byte cryptographically secure random string
func generateToken() (string, error) {
	b := make([]byte, 16)
	_, err := rand.Read(b)
	if err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

// AcquireLock attempts to set a distributed lock key in Redis using `SET NX PX`
func (l *CartLocker) AcquireLock(ctx context.Context, cartID string, ttl time.Duration) (string, error) {
	token, err := generateToken()
	if err != nil {
		return "", fmt.Errorf("failed to generate lock token: %w", err)
	}

	lockKey := fmt.Sprintf("lock:cart:%s", cartID)
	
	// SET lock:cart:<cartID> <token> NX PX <ttl_ms>
	ok, err := l.client.SetNX(ctx, lockKey, token, ttl).Result()
	if err != nil {
		return "", fmt.Errorf("redis setnx error: %w", err)
	}
	if !ok {
		return "", ErrLockAcquisitionFailed
	}

	return token, nil
}

// ReleaseLock releases the lock atomically via Lua script
func (l *CartLocker) ReleaseLock(ctx context.Context, cartID string, token string) (bool, error) {
	lockKey := fmt.Sprintf("lock:cart:%s", cartID)

	res, err := luaReleaseScript.Run(ctx, l.client, []string{lockKey}, token).Int64()
	if err != nil {
		return false, fmt.Errorf("redis lua release lock error: %w", err)
	}

	return res == 1, nil
}

// WithCartLock wraps a mutation callback function with automatic acquisition and atomic lock release
func (l *CartLocker) WithCartLock(ctx context.Context, cartID string, ttl time.Duration, fn func(ctx context.Context) error) error {
	token, err := l.AcquireLock(ctx, cartID, ttl)
	if err != nil {
		return err
	}

	defer func() {
		// Use a detached background context for lock release to guarantee cleanup even if parent ctx is cancelled
		releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		_, _ = l.ReleaseLock(releaseCtx, cartID, token)
	}()

	return fn(ctx)
}

Chi phí ẩn và thời gian “Stabilize” của Composable

Answer-first: Cần trung bình 3-4 tháng để thay thế và ổn định một module lõi như Checkout. Chi phí ẩn đắt đỏ nhất chính là việc xây dựng hệ thống Khả năng quan sát (Observability), vốn tiêu tốn thêm 20-30% nguồn lực dự án để thiết lập Distributed Tracing.

Trong kiến trúc Monolith, trace một lỗi thanh toán chỉ cần đọc một file log. Với Composable, một request thanh toán đi qua:

  1. Storefront
  2. BFF (Backend-For-Frontend)
  3. Cart Service
  4. Payment Gateway
  5. Order Service

Thiếu OpenTelemetry ngay từ dòng code đầu tiên, đội vận hành (SRE) sẽ hoàn toàn “mù” khi sự cố xảy ra. Tracing không phải là tính năng bổ sung (nice-to-have), nó là tiêu chuẩn bắt buộc (Definition of Done) của Composable Commerce.

Snippet: OpenTelemetry Go SDK Setup (telemetry Package)

package telemetry

import (
	"context"
	"fmt"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

// InitTracer initializes OpenTelemetry SDK with OTLP gRPC Exporter and global W3C context propagators
func InitTracer(ctx context.Context, serviceName, serviceVersion, otlpEndpoint string) (func(context.Context) error, error) {
	// 1. Create OTLP gRPC Exporter targeting Tempo / Jaeger / OpenTelemetry Collector
	exporter, err := otlptracegrpc.New(ctx,
		otlptracegrpc.WithEndpoint(otlpEndpoint),
		otlptracegrpc.WithTLSCredentials(insecure.NewCredentials()), // Use WithTLSCredentials for TLS in prod
		otlptracegrpc.WithDialOption(grpc.WithBlock()),
		otlptracegrpc.WithTimeout(5*time.Second),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create OTLP gRPC trace exporter: %w", err)
	}

	// 2. Define Service Metadata Resource
	res, err := resource.New(ctx,
		resource.WithAttributes(
			semconv.ServiceNameKey.String(serviceName),
			semconv.ServiceVersionKey.String(serviceVersion),
			semconv.DeploymentEnvironmentKey.String("production"),
		),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create otel resource: %w", err)
	}

	// 3. Construct TracerProvider with BatchSpanProcessor
	bsp := sdktrace.NewBatchSpanProcessor(exporter)
	tp := sdktrace.NewTracerProvider(
		sdktrace.WithSampler(sdktrace.AlwaysSample()), // Use ParentBased(TraceIDRatioBased(0.1)) in ultra-high traffic
		sdktrace.WithResource(res),
		sdktrace.WithSpanProcessor(bsp),
	)

	// 4. Register Global OpenTelemetry TracerProvider & W3C TraceContext Propagator
	otel.SetTracerProvider(tp)
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{},
		propagation.Baggage{},
	))

	// Return graceful shutdown closure
	return tp.Shutdown, nil
}

Giải bài toán đồng bộ Monolith cũ: Kiến trúc CDC

Chiến lược cốt lõi: Tránh tuyệt đối việc ghi song song (Dual-write) từ logic ứng dụng vì rủi ro partial failure. Giải pháp tối ưu là áp dụng Change Data Capture (CDC) với Debezium, đọc trực tiếp từ binlog của hệ thống cũ và stream qua Kafka để đồng bộ về hệ thống mới.

Nhiều team mắc sai lầm khi bắt Application Code gọi API ghi vào cả database cũ và mới. Nếu lời gọi API thứ 2 bị timeout, dữ liệu sẽ bất đồng bộ vĩnh viễn.

Dưới đây là sơ đồ luồng dữ liệu chuẩn cho giai đoạn Strangler Fig (chạy song song cũ - mới):

flowchart TD
    subgraph Monolith Legacy
        APP["Monolith App"] -->|Ghi dữ liệu| DB[("Legacy DB MySQL/PG")]
    end
    
    subgraph CDC & Event Streaming
        DB -.->|Đọc Binlog| DEB["Debezium CDC"]
        DEB -->|Publish Event| KAFKA["Kafka Event Bus"]
    end
    
    subgraph Composable Services
        KAFKA -->|Consume| OS["Order Service"]
        KAFKA -->|Consume| INV["Inventory Service"]
        OS --> O_DB[("Order DB Mới")]
        INV --> I_DB[("Inventory DB Mới")]
    end

Nhờ CDC, hệ thống mới chỉ đóng vai trò Consumer thụ động. Tiến trình giao dịch chính của người dùng trên hệ thống cũ hoàn toàn không bị suy giảm hiệu năng hay chịu rủi ro timeout từ mạng.

Cấu hình Connector Debezium MySQL CDC (debezium-mysql-connector.json)

{
  "name": "legacy-mysql-inventory-cdc",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "tasks.max": "1",
    
    "database.hostname": "mysql-monolith-primary.internal.net",
    "database.port": "3306",
    "database.user": "debezium_cdc",
    "database.password": "${file:/secrets/db-credentials.properties:cdc_password}",
    "database.server.id": "184054",
    "database.include.list": "magento_production",
    "table.include.list": "magento_production.cataloginventory_stock_status,magento_production.sales_order",
    
    "topic.prefix": "cdc_legacy",
    "schema.history.internal.kafka.bootstrap.servers": "kafka-cluster.internal:9092",
    "schema.history.internal.kafka.topic": "schema-changes.inventory",
    
    "snapshot.mode": "when_needed",
    "snapshot.locking.mode": "minimal",
    "gtid.source.includes": "3E11FA47-71CA-11E1-9E33-C80AA9429A4E:1-100",
    
    "decimal.handling.mode": "double",
    "tombstones.on.delete": "true",
    "inconsistent.schema.handling.mode": "warn",
    
    "transforms": "unwrap,reroute",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": "false",
    "transforms.unwrap.delete.handling.mode": "rewrite",
    
    "transforms.reroute.type": "io.debezium.transforms.ByLogicalTableRouter",
    "transforms.reroute.topic.regex": "cdc_legacy\\.magento_production\\.(.*)",
    "transforms.reroute.topic.replacement": "events-cdc-$1-v1"
  }
}

FAQ: Chuyển đổi Composable Commerce

Khi nào doanh nghiệp mới nên chuyển sang Composable Commerce?

Khi doanh thu đạt mốc 5 triệu USD/năm hoặc khi nền tảng nguyên khối (Monolith) hiện tại bắt đầu cản trở tốc độ ra mắt tính năng mới. Đối với các startup nhỏ, việc dùng SaaS đóng gói vẫn tối ưu về chi phí hơn.

Tại sao lại cần BFF (Backend-For-Frontend)?

BFF giúp tổng hợp dữ liệu từ nhiều microservices khác nhau thành một API duy nhất trả về cho Frontend, giảm thiểu số lượng network calls và đóng vai trò như một lớp đệm bảo vệ (Circuit Breaker) khi các service phía sau bị nghẽn.


🔗 Đọc thêm các chuyên đề & Series liên quan: