Logging trên ClickHouse thay Elasticsearch: schema, pipeline, tiering S3 và retention một năm

Bản thiết kế thực tế để chuyển log từ Elasticsearch sang ClickHouse: Vector và Kafka để ingest, schema theo chuẩn OpenTelemetry với text index mới, tiering sang S3 bằng TTL, tìm kiếm đa region trên một UI, và những cái bẫy hay gặp khi chạy production.

Read in English →

Elasticsearch tìm kiếm rất giỏi. Vấn đề là phần lớn log không phải bài toán tìm kiếm. Log chỉ ghi thêm, sắp theo thời gian, khổng lồ và hiếm khi được đọc. Phần lớn lượt đọc là “cho tôi xem lỗi của service X trong 30 phút qua” hoặc “mỗi phút bao nhiêu 5xx”. Kết cục là bạn trả tiền cho inverted index trên mọi field, cho JVM heap, cho replica trên đĩa nhanh và cho một ILM policy cần người trông. Đổi lại là relevance scoring mà chẳng ai dùng cho dòng log.

ClickHouse lật ngược bài toán. Nó là cơ sở dữ liệu dạng cột, nên một bảng log trở thành tập các cột nén rất tốt, được sắp theo service và thời gian. Query chỉ đọc những cột nó cần, phân tích bằng SQL, và dữ liệu cũ trôi xuống S3 ngay trong cùng một bảng. Từ ClickHouse 26.2, full-text text index đã GA, nên tìm theo token trong nội dung log là tính năng chính thức chứ không còn là mẹo chữa cháy.

Bài này là bản thiết kế tôi sẽ dùng cho một cuộc migration production: pipeline, schema, tiering sang S3, tìm kiếm đa region, lộ trình chuyển đổi và các cái bẫy.

Trong một lần migration production theo đúng mô hình này, khoảng 10 TB log/ngày, retention một năm, tổng chi phí logging giảm khoảng 60% so với cụm Elasticsearch cũ. Con số của bạn sẽ tuỳ vào hình dạng log và kiểu query. Những cơ chế bên dưới là thứ tạo ra mức tiết kiệm đó.

Vì sao log hợp với cơ sở dữ liệu dạng cột

Elasticsearch ClickHouse
Mô hình lưu trữ Document + inverted index mỗi field + doc values + _source Các cột sắp theo ORDER BY, nén theo từng cột
Độ nén với log Vừa phải, index còn cộng thêm dung lượng Cao: giá trị giống nhau nằm cạnh nhau, codec ZSTD và Delta
Tìm kiếm Full-text, scoring, fuzzy Tìm theo token bằng text index, LIKE, regex; không scoring
Phân tích Aggregations DSL SQL đầy đủ, join, window function
Retention / tiering ILM, hot-warm-cold, searchable snapshot Partition theo ngày + TTL … TO VOLUME / DELETE
Áp lực bộ nhớ JVM heap, field data, số lượng shard Không có JVM; bộ nhớ tính theo query, giới hạn được bằng setting

Công bằng mà nói, Elasticsearch có frozen tier và searchable snapshot. Nếu bạn thật sự cần xếp hạng theo độ liên quan, fuzzy match hay “ý bạn là” trên log, nó vẫn là công cụ tốt hơn. Còn với kiểu “lọc theo service, thời gian, severity, rồi grep một token, rồi đếm”, ClickHouse làm cùng việc đó với ít byte trên đĩa và ít CPU hơn nhiều.

Kiến trúc

Vector agent trên mỗi node đẩy log vào Kafka; Vector aggregator đọc, biến đổi và insert theo lô lớn vào cụm ClickHouse có shard và replica, điều phối bởi ClickHouse Keeper; part cũ chuyển xuống tầng lạnh S3; HyperDX hoặc Grafana truy vấn ClickHouse
Agent giữ đơn giản và rẻ. Kafka hấp thụ đột biến. Aggregator gom hàng triệu event nhỏ thành vài lần insert lớn.
  • Vector agent chạy dạng DaemonSet (hoặc trên VM), đọc log container và đẩy event thô vào Kafka. Agent làm càng ít càng tốt, để một transform lỗi không thể làm sập node.
  • Kafka / MSK là bộ giảm xóc. Khi ClickHouse chậm hoặc đang nâng cấp, log dồn lại trong Kafka chứ không dồn trên node. Khi parser có bug, bạn còn có thể replay lại một khoảng thời gian.
  • Vector aggregator đọc từ Kafka, parse và làm giàu dữ liệu, che PII, rồi ghi vào ClickHouse theo lô lớn. Với acknowledgement end-to-end, offset Kafka chỉ được commit sau khi ClickHouse đã nhận lô.
  • ClickHouse: N shard × 2 replica ReplicatedMergeTree, một bảng Distributed ở trên, và ClickHouse Keeper (3 node) giữ metadata replication.
  • HyperDX (UI trong ClickStack, bộ observability của ClickHouse) hoặc Grafana để tìm kiếm, làm dashboard và cảnh báo. HyperDX nhận cả cú pháp kiểu Lucene lẫn SQL, và liên kết log, trace, metric với nhau.

Nếu bạn bắt đầu từ đầu và đã dùng OpenTelemetry, OTel collector của ClickStack có thể thay Vector. Schema bên dưới theo đúng hình dạng log của OpenTelemetry, nên sau này đổi qua lại vẫn được.

Schema

Phần lớn hiệu năng nằm ở schema, nên đây là chỗ đáng làm cho đúng. Dưới đây là bảng local theo hình dạng OTel, chỉnh từ hướng dẫn schema của chính ClickHouse:

CREATE TABLE otel.otel_logs_local ON CLUSTER logs
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    SeverityText       LowCardinality(String) CODEC(ZSTD(1)),
    SeverityNumber     UInt8,
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    Body               String CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    LogAttributes      Map(LowCardinality(String), String) CODEC(ZSTD(1)),

    -- các attribute hay dùng được đưa thành cột thật
    K8sNamespace LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.namespace.name'],
    HttpStatus   UInt16 MATERIALIZED toUInt16OrZero(LogAttributes['http.status_code']),

    INDEX idx_body     Body    TYPE text(tokenizer = splitByNonAlpha),
    INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)
TTL toDateTime(Timestamp) + INTERVAL 7 DAY TO VOLUME 'cold',
    toDateTime(Timestamp) + INTERVAL 365 DAY DELETE
SETTINGS storage_policy = 'hot_cold',
         ttl_only_drop_parts = 1;

CREATE TABLE otel.otel_logs ON CLUSTER logs AS otel.otel_logs_local
ENGINE = Distributed(logs, otel, otel_logs_local, rand());

Lý do của từng lựa chọn:

  • ORDER BY (ServiceName, SeverityText, time, …): sort key cũng chính là primary index. Đặt các cột ít giá trị mà bạn luôn lọc lên đầu. Khi đó một query ServiceName = 'checkout' AND SeverityText = 'ERROR' bỏ qua phần lớn bảng mà không cần đọc. Thứ tự này cũng quyết định độ nén, vì các giá trị service và severity giống nhau xếp thành những dải dài.
  • PARTITION BY toDate(Timestamp): mỗi ngày một partition. Đủ thô để không sinh ra hàng nghìn part, và giúp retention xoá được nguyên cả ngày.
  • LowCardinality(String) cho service, severity và key của attribute: mã hoá theo dictionary, nên nhỏ hơn và lọc nhanh hơn.
  • Codec: Delta + ZSTD cho timestamp (các giá trị liên tiếp chênh nhau rất ít), ZSTD cho phần còn lại. ZSTD là codec đa dụng được khuyến nghị cho dữ liệu log.
  • Map cho attribute, cột materialized cho key hay dùng: Map linh hoạt, nhưng đọc LogAttributes['x'] là đọc cả cột map. Nếu dashboard lọc theo namespace hay HTTP status cả ngày, hãy đưa các key đó thành cột thật. Giá trị trong Map là chuỗi, nên mới cần toUInt16OrZero.
  • text index trên Body: inverted index trên các token của message, tách theo ký tự không phải chữ hoặc số. Nó thay cho các skip index dạng bloom filter tokenbf_v1 / ngrambf_v1 cũ, vốn không còn được ClickHouse khuyến nghị cho việc này.
  • Distributed(…, rand()): chia đều lượng insert. Nếu muốn toàn bộ log của một trace nằm trên một shard, hãy shard theo cityHash64(TraceId).

text index GA từ ClickHouse 26.2. Ở các bản cũ hơn nó là experimental/beta và phải bật bằng setting, nên kiểm tra tài liệu đúng phiên bản của bạn trước khi dựa vào nó. Thêm index vào bảng có sẵn bằng ALTER TABLE … ADD INDEX chỉ áp dụng cho part mới, cho đến khi bạn chạy MATERIALIZE INDEX.

Tiering: NVMe nóng, S3 lạnh, rồi xoá

Partition theo ngày nằm trên NVMe local trong vài ngày đầu, một rule TTL chuyển chúng xuống S3 qua cache local, và rule TTL thứ hai xoá nguyên partition sau một năm
Cùng một bảng, cùng câu SQL qua cả ba giai đoạn. Chỉ có độ trễ và giá mỗi GB là thay đổi.

Storage policy, đặt thành file cấu hình trên mọi server (/etc/clickhouse-server/config.d/storage.xml):

<clickhouse>
  <storage_configuration>
    <disks>
      <s3_cold>
        <type>s3</type>
        <endpoint>https://my-logs-cold.s3.ap-southeast-1.amazonaws.com/{shard}/{replica}/</endpoint>
        <use_environment_credentials>true</use_environment_credentials>
        <metadata_path>/var/lib/clickhouse/disks/s3_cold/</metadata_path>
      </s3_cold>
      <s3_cold_cache>
        <type>cache</type>
        <disk>s3_cold</disk>
        <path>/var/lib/clickhouse/disks/s3_cold_cache/</path>
        <max_size>200Gi</max_size>
      </s3_cold_cache>
    </disks>
    <policies>
      <hot_cold>
        <volumes>
          <hot><disk>default</disk></hot>
          <cold><disk>s3_cold_cache</disk></cold>
        </volumes>
      </hot_cold>
    </policies>
  </storage_configuration>
</clickhouse>
  • use_environment_credentials cho ClickHouse dùng IAM role của pod hoặc instance, nên không có key nào trong cấu hình. Trên EKS đó là Pod Identity hoặc IRSA.
  • Cache disk giữ dữ liệu S3 vừa đọc trên đĩa local, nên query lần hai trên cùng khoảng “tháng trước” không phải chịu độ trễ S3 lần nữa.
  • Đừng đặt S3 lifecycle rule để expire hay transition object trên bucket này. ClickHouse theo dõi từng object trong metadata của nó, và một object bị xoá sau lưng nó là một part hỏng. Hãy để mệnh đề TTL … DELETE lo việc xoá.
  • ttl_only_drop_parts = 1 khiến việc hết hạn xoá nguyên part thay vì viết lại từng dòng. Kết hợp với partition theo ngày, xoá ngày thứ 366 chỉ còn là một thao tác metadata.

Pipeline với Vector

Aggregator, đọc từ Kafka và ghi vào ClickHouse:

sources:
  kafka_logs:
    type: kafka
    bootstrap_servers: "b-1.logs.xxxx.kafka.ap-southeast-1.amazonaws.com:9096"
    group_id: vector-clickhouse
    topics: ["logs.raw"]
    auto_offset_reset: earliest
    decoding:
      codec: json
    sasl:
      enabled: true
      mechanism: SCRAM-SHA-512
      username: "${KAFKA_USER}"
      password: "${KAFKA_PASSWORD}"
    tls:
      enabled: true

transforms:
  shape:
    type: remap
    inputs: ["kafka_logs"]
    source: |
      body = string(.message) ?? encode_json(.message)
      # che secret rõ ràng trước khi lưu suốt một năm
      body = redact(body, filters: [r'(?i)(password|token|secret)=\S+'])
      # dựng event mới: chỉ các cột trong schema rời khỏi aggregator
      . = compact({
        "Timestamp": .timestamp,
        "ServiceName": .kubernetes.pod_labels."app.kubernetes.io/name" || .kubernetes.container_name || "unknown",
        "SeverityText": upcase(string(.level) ?? "INFO"),
        "TraceId": .trace_id,
        "Body": body,
        "ResourceAttributes": {
          "k8s.namespace.name": .kubernetes.pod_namespace,
          "k8s.pod.name": .kubernetes.pod_name,
          "k8s.node.name": .kubernetes.pod_node_name
        }
      })

sinks:
  clickhouse:
    type: clickhouse
    inputs: ["shape"]
    endpoint: "http://clickhouse-logs.logging.svc:8123"
    database: otel
    table: otel_logs
    format: json_each_row
    compression: zstd
    skip_unknown_fields: true
    date_time_best_effort: true
    auth:
      strategy: basic
      user: vector
      password: "${CLICKHOUSE_PASSWORD}"
    batch:
      max_bytes: 20000000   # ~20 MB mỗi lần insert
      timeout_secs: 5
    buffer:
      type: disk
      max_size: 10737418240 # 10 GiB trên PVC của aggregator
      when_full: block
    acknowledgements:
      enabled: true

Điểm quan trọng:

  • Kích thước lô là tất cả. ClickHouse muốn vài lần insert lớn, không phải hàng nghìn lần insert nhỏ. Mỗi lần insert tạo một part, và quá nhiều part nhỏ sẽ dẫn đến lỗi nổi tiếng Too many parts. Mặc định của Vector là batch.max_bytes 10 MB, timeout 1 giây. Với log, tăng timeout lên vài giây thường không sao.
  • acknowledgements.enabled: true cùng Kafka source nghĩa là offset chỉ được commit sau khi ClickHouse nhận lô. Aggregator có crash thì Kafka gửi lại.
  • Disk buffer với when_full: block tạo back-pressure lên việc đọc Kafka thay vì vứt event.
  • Async insert (query_settings.async_insert_settings.enabled) dành cho tình huống ngược lại: nhiều client mỗi bên gửi lô nhỏ, để ClickHouse tự gom ở phía server. Với vài aggregator đã gửi lô 10–20 MB thì không cần.
  • skip_unknown_fields cho phép producer thêm field mà không làm hỏng insert. Field nào không có trong schema sẽ bị bỏ, nên hãy đưa field quan trọng vào LogAttributes.

Truy vấn

95% trường hợp: lọc theo service và thời gian, rồi tìm theo token.

SELECT Timestamp, ServiceName, K8sNamespace, Body
FROM otel.otel_logs
WHERE ServiceName = 'checkout'
  AND Timestamp >= now() - INTERVAL 30 MINUTE
  AND hasAllTokens(Body, ['connection', 'refused'])
ORDER BY Timestamp DESC
LIMIT 200;

text index được dùng bởi hasAnyTokens / hasAllTokens (hai hàm được khuyến nghị), hasToken, = / IN, LIKE / match, startsWith / endsWith và các hàm map. Nó không được dùng cho NOT LIKE / NOT IN, nên điều kiện phủ định sẽ phải quét.

Chỗ ClickHouse bỏ xa Elasticsearch là phân tích bằng SQL thuần:

-- tỉ lệ lỗi theo service theo phút
SELECT toStartOfMinute(Timestamp) AS t,
       ServiceName,
       countIf(SeverityText = 'ERROR') / count() AS error_ratio
FROM otel.otel_logs
WHERE Timestamp >= now() - INTERVAL 3 HOUR
GROUP BY t, ServiceName
ORDER BY t;

-- top "dạng" lỗi: gộp số và id để các message giống nhau về chung nhóm
SELECT replaceRegexpAll(Body, '[0-9a-f]{8,}|\\d+', '?') AS pattern,
       count() AS n,
       any(Body) AS example
FROM otel.otel_logs
WHERE SeverityText = 'ERROR' AND Timestamp >= now() - INTERVAL 1 DAY
GROUP BY pattern
ORDER BY n DESC
LIMIT 20;

Xem schema nén tốt đến đâu, theo từng cột:

SELECT name,
       formatReadableSize(sum(data_compressed_bytes))   AS compressed,
       formatReadableSize(sum(data_uncompressed_bytes)) AS raw,
       round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 1) AS ratio
FROM system.columns
WHERE database = 'otel' AND table = 'otel_logs_local'
GROUP BY name
ORDER BY sum(data_compressed_bytes) DESC;

Kích thước theo cột chỉ có với part dạng wide. Bảng test nhỏ dùng part compact nên hiện toàn số 0, còn part cỡ production thì là wide. Body gần như luôn là cột lớn nhất. Nếu một service ghi stack trace dài và lặp lại, byte nằm hết ở đó, và sửa cách log còn rẻ hơn mọi codec.

Đa region: log ở yên, query di chuyển

Mỗi region giữ log trong cụm ClickHouse riêng; một UI gửi query tới mọi region, mỗi region lọc và tổng hợp tại chỗ, chỉ kết quả nhỏ đi qua region khác
Ingest và lưu trữ theo region. Chỉ câu query và kết quả đã LIMIT đi qua ranh giới region.

Đẩy mọi dòng log về một region trung tâm vừa tốn kém (egress) vừa thường không được phép (data residency). Thay vào đó, hãy chạy toàn bộ pipeline theo từng region, rồi cho mọi người một chỗ duy nhất để tìm:

  • Một định nghĩa cluster trải qua các region: một mục remote_servers, ví dụ logs_global, liệt kê mọi shard của các region cùng replica của chúng. Một bảng Distributed(logs_global, otel, otel_logs_local) trên một node “query” nhỏ sẽ gửi query đi khắp nơi. Mỗi region lọc và tổng hợp tại chỗ, rồi chỉ gửi kết quả về. Bật skip_unavailable_shards = 1 cho user dùng bảng này, để một region sập chỉ làm kết quả tìm kiếm thiếu đi chứ không làm hỏng cả query.
  • Hoặc để UI làm: HyperDX và Grafana có thể giữ mỗi region một data source, người dùng chọn region hoặc query song song.
  • Phân quyền: đặt UI sau SSO và map group sang user hoặc role của ClickHouse, dùng row policy (ví dụ ServiceName IN (…) cho từng team) và settings profile chỉ đọc có max_execution_time, max_memory_usage. Một câu SELECT * trên cả năm của một người không được phép làm sập cụm của tất cả mọi người.

Chuyển đổi không cần big bang

  1. Ghi song song. Thêm sink ClickHouse cạnh sink Elasticsearch hiện có trong Vector (hoặc Logstash/Fluent Bit). Cả hai nhận cùng luồng dữ liệu từ Kafka với consumer group khác nhau.
  2. Đối chiếu. So số lượng theo service theo giờ giữa hai hệ thống. Dựng lại các dashboard và alert quan trọng nhất. Cho đội on-call dùng UI mới trong một vài sự cố thật.
  3. Chuyển phía đọc. Trỏ dashboard, alert, runbook sang ClickHouse. Giữ Elasticsearch ở chế độ chỉ đọc.
  4. Để Elasticsearch tự hết hạn. Dừng sink ES và để retention hiện có tự xoá dữ liệu, hoặc snapshot sang S3 nếu compliance yêu cầu. Backfill lịch sử sang ClickHouse hiếm khi đáng công.

Những cái bẫy

  • Too many parts: gần như luôn do insert nhỏ. Xem system.parts theo partition, rồi tăng kích thước lô hoặc timeout. Đừng “sửa” bằng cách nâng giới hạn.
  • ORDER BY sai thì sửa rất đắt. Đổi nó nghĩa là tạo bảng mới và copy dữ liệu. Hãy nhìn các điều kiện lọc thật trước khi chọn.
  • Bùng nổ key trong Map: nếu producer nhét request ID hay user ID vào key của attribute, cột Map sẽ phình không giới hạn. Chuẩn hoá trong transform của Vector.
  • Không có relevance, không có fuzzy: Body LIKE '%timeot%' sẽ không ra “timeout”. Người dùng quen Kibana nên biết trước đây là tìm token chính xác.
  • Điều kiện phủ định không dùng text index (NOT LIKE, NOT IN). Hãy thu hẹp theo service và thời gian trước.
  • Xoá không miễn phí. “Xoá log của user này” nghĩa là lightweight DELETE hoặc mutation, mà ở quy mô này thì rất đắt. Ưu tiên che dữ liệu ngay lúc ingest, và giữ retention là công cụ xoá chính.
  • Keeper là thành phần sống còn. Chạy trên node riêng (3 hoặc 5 node), trải qua nhiều zone, đĩa nhanh. Keeper mất quorum thì các bảng replicated chuyển sang chỉ đọc.
  • Part khổng lồ: một part vượt khoảng 4,29 tỷ dòng sẽ khiến text index rơi về quét toàn bộ. Partition theo ngày trên từng shard thường giữ bạn ở mức thấp hơn nhiều.
  • Query chạy lâu giữ part lại: query giữ tham chiếu tới mọi part nó có thể đọc, nên part cũ không xoá được cho tới khi query kết thúc, kể cả bản local của part mà TTL đã chuyển sang S3. Hãy alert khi query đã bị huỷ mà vẫn chạy. Xem sự cố production bên dưới.

Từ production: query treo đã giữ lại 1,1 TiB

Mọi thứ ở trên là phần thiết kế. Đây là chuyện xảy ra khi một mắt xích trục trặc. Cluster chính là cluster trong bài: 4 shard × 2 replica, dữ liệu mới ghi vào SSD local, và rule TTL chuyển part sang S3 khoảng bảy ngày sau dòng mới nhất trong part.

Diễn biến sự cố: một query dashboard bị treo trên replica A từ ngày 15/10 và giữ tham chiếu tới mọi part trong ảnh chụp của nó; ngày 16 và 17/10 TTL chuyển 33 part trong số đó sang S3 nhưng bản local không xoá được, nên disk của replica A tăng lên 90% trong khi replica B đứng ở 66%; dọn dẹp và restart ngày 17/10 đưa disk về 67%
Hai replica của cùng một shard lẽ ra chứa đúng cùng số byte. Khi chúng lệch nhau, chỗ lệch chính là lỗi.

Triệu chứng. Chiều 17/10, alert disk báo một replica (A) đã dùng 90% (5,68 TiB). Replica còn lại trong cùng shard (B) chỉ ở 66%. system.disks cho thấy bảy replica kia dùng từ 4,45 đến 4,59 TiB, riêng A dùng 5,66 TiB.

Đi tìm phần dư thừa. A và B là bản sao của nhau, nên chỗ nào khác nhau thì chỗ đó là rác. Part active giống hệt nhau đến từng byte. Part inactive, part detached và S3 cache cộng lại chỉ khoảng 67 GiB. Hơn 1 TiB còn lại thì không bảng hệ thống nào báo. Chạy du thì ra một bảng, bảng log lớn nhất: 4,2 T trên A, 3,0 T trên B. So các thư mục part trên disk với những gì ClickHouse biết thì tìm ra:

cd <local-disk-path>/store/<prefix>/<table-uuid>
ls -1 | grep -E '^[0-9]{8}_' | sort > /tmp/disk.txt
clickhouse-client -q "SELECT name FROM system.parts
  WHERE database='<db>' AND table='<table>' AND disk_name='<local_disk>'" | sort > /tmp/ch.txt
comm -23 /tmp/disk.txt /tmp/ch.txt   # có trên disk, ClickHouse không biết

A có 33 thư mục không nằm trong system.parts của disk local: 1.158,69 GiB, toàn bộ thuộc các partition 08–10/10. B không có cái nào. Cả 33 đều là part đang active, chỉ có điều nằm trên disk S3. system.part_log ghi nhận mỗi part có một lần MovePart thành công vào ngày 16 hoặc 17/10, nhưng không có RemovePart nào. TTL đã chuyển chúng sang S3, còn bản local thì cứ thế nằm lại.

Nguyên nhân. ClickHouse chỉ xoá file của một part khi không còn gì tham chiếu tới nó. Trên A, những part inactive hết hạn từ 07:58 ngày 15/10 vẫn còn đó với refcount = 2. Trên B, part inactive biến mất sau vài phút. system.processes cho biết thứ gì đang giữ chúng: một query dashboard đã chạy 194.158 giây (khoảng 54 giờ) và mới đọc được 3,1 triệu dòng. system.stack_trace cho thấy nó nằm trong epoll bên trong RemoteQueryExecutor::finish(), chờ một hedged connection tới replica khác, chờ một phản hồi không bao giờ tới. Lúc đó A ghi rất nhiều lỗi Broken pipe trên các kết nối tới replica khác, nên nhiều khả năng thủ phạm là một kết nối “nửa sống nửa chết”. Điều này chưa chứng minh được.

Query đang chạy giữ tham chiếu tới mọi part nó có thể đọc. Các part bị merge sau khi query bắt đầu dồn lại thành part inactive (291 part, khoảng 88 GiB, trong khi B chỉ có 175 part và 6,5 GiB). Rồi TTL chuyển 33 part lớn sang S3: ClickHouse gạch bản local khỏi danh sách part nhưng không xoá được file. Đó chính là 1,13 TiB mà không bảng hệ thống nào hiển thị. Tên partition là ngày của dữ liệu, không phải ngày move, nên query từ 15/10 mới giữ dữ liệu của 08–10/10: sáng hôm đó các part này vẫn còn trên SSD, và chỉ được move một tuần sau dòng mới nhất của chúng. Nếu không restart, các ngày sau cũng sẽ lần lượt thành orphan khi tới hạn move.

Hai lưới an toàn không bắt được. KILL QUERY bật is_cancelled = 1, nhưng thread đang kẹt trong epoll không bao giờ kiểm tra cờ đó, nên query vẫn chạy. Profile của user dashboard vốn đã đặt max_execution_time = 60, nhưng giới hạn này chỉ được kiểm tra khi pipeline đang xử lý dữ liệu, mà query này đã ngừng xử lý từ lâu.

Dọn dẹp mà không mất dữ liệu. Xoá các thư mục mồ côi trước rồi mới restart: vừa giải phóng dung lượng ngay, vừa tránh phải thử trên production xem bảng có load được không khi một part nằm cả trên SSD lẫn S3.

  1. Tạo lại danh sách mồ côi và kiểm tra từng part trong 33 part đều đang active trên disk S3 và có metadata ở đó.

  2. Chuyển chứ không xoá. Cùng filesystem nên thao tác tức thì và hoàn tác dễ dàng:

    while read p; do mv "$p" "detached/ignored_$p"; done < /tmp/orphan.txt
  3. Lúc này ClickHouse thấy chúng trong system.detached_parts (1,13 TiB). Kiểm tra dữ liệu ba ngày bị ảnh hưởng vẫn đọc đúng từ S3, rồi xoá qua ClickHouse:

    ALTER TABLE <db>.<table> DROP DETACHED PART 'ignored_<name>'
    SETTINGS allow_drop_detached = 1;
  4. A giảm từ 5,66 TiB xuống 4,6 TiB (90% → 67%). KILL QUERY … ASYNC vẫn không dừng được query, nên sau khi lưu stack trace và kiểm tra B vẫn khoẻ (is_readonly = 0, không trễ replication, không có orphan mới), pod của A được restart. Pod lên lại trên cùng volume, đồng bộ replication xong và tự dọn các part inactive cũ.

Những gì sẽ thay đổi. Alert disk đã làm đúng việc của nó, nhưng chỉ kêu ở mức 90%, hai ngày sau khi nguyên nhân xuất hiện. Mục tiêu là bắt được ngay từ nguyên nhân. Trước hết, không cho query dashboard đi qua đoạn code đã treo. Với 2 replica mỗi shard, hedged requests gần như không mang lại gì:

profiles:
  <dashboard_profile>:
    max_execution_time: 60
    use_hedged_requests: 0

Sau đó thêm alert cho những dấu hiệu xuất hiện trước khi disk đầy:

Alert Kêu khi Mức độ
Query đã huỷ mà vẫn chạy is_cancelled = 1 và elapsed > 600 Critical
Query chạy quá lâu elapsed > 3600 trên bất kỳ replica nào Warning
Part inactive tồn lâu part inactive có remove_time cũ hơn 1 giờ Warning
Dung lượng ClickHouse không quản lý dung lượng disk đã dùng trừ bytes_on_disk của part vượt 200 GiB Warning
Hai replica lệch nhau dung lượng đã dùng lệch quá 10% trong cùng shard Warning

Alert đầu tiên là cái sẽ bắt được đúng sự cố lần này. Query cho ba alert đầu:

-- query đã huỷ mà vẫn chạy, và query chạy quá lâu
SELECT hostName() AS host, query_id, user, elapsed, is_cancelled
FROM clusterAllReplicas('<cluster>', system.processes)
WHERE elapsed > 3600 OR (is_cancelled AND elapsed > 600);

-- part inactive lẽ ra đã bị xoá từ lâu
SELECT hostName() AS host, database, table, count() AS parts, min(remove_time) AS oldest
FROM clusterAllReplicas('<cluster>', system.parts)
WHERE NOT active AND remove_time < now() - INTERVAL 1 HOUR
GROUP BY host, database, table;

Với dung lượng không được quản lý, chạy câu này trên từng replica và đặt ngưỡng cao hơn kích thước cache local:

SELECT
  (SELECT total_space - free_space FROM system.disks WHERE name = '<local_disk>')
  - (SELECT sum(bytes_on_disk) FROM system.parts WHERE disk_name = '<local_disk>') AS untracked_bytes;

Rút ra được gì.

  • So một replica với replica còn lại của cùng shard. Hai bên lẽ ra giống hệt nhau, nên chỗ nào khác chính là chỗ có vấn đề.
  • Bảng hệ thống chỉ hiển thị những gì ClickHouse còn đang quản lý. Lần này rác lại chính là những thứ nó đã thôi quản lý, nên phải tự so du với system.parts.
  • Một query không bao giờ kết thúc không chỉ tốn CPU. Nó giữ các part cũ lại, và khi có TTL move thì đó có thể là cả một bản sao thứ hai của mọi thứ đã được chuyển đi.
  • KILL không phải lúc nào cũng có tác dụng. Một query đã bị huỷ mà mười phút sau vẫn còn thì cần có người được báo.

Còn hai điều chưa rõ: lỗi treo này có phải lỗi đã biết của ClickHouse không (sẽ tìm trên issue tracker bằng stack trace, chưa có thì báo lỗi), và client có ghi đè max_execution_time cho query đó không (xem trong system.query_log).

Tổng kết

Log là bài toán dạng cột theo chuỗi thời gian, mà lâu nay ta lại giải bằng một search engine. Chuyển chúng sang ClickHouse với:

  • schema theo chuẩn OTel có ORDER BY khớp với cách bạn lọc,
  • text index để tìm theo token,
  • Vector và Kafka để ingest bền vững theo lô, và
  • TTL tiering sang S3.

Bạn sẽ có retention dài hơn, SQL thật sự và hoá đơn nhỏ hơn nhiều. Hãy giữ Elasticsearch, hoặc OpenSearch, cho những nơi thực sự cần full-text search có xếp hạng.

Nguồn

Bình luận