feat(data-ana): implement complete CDC pipeline MySQL to ClickHouse
Debezium + Kafka + aiokafka consumer with table routing. E2E verified: MySQL INSERT to ClickHouse upsert.
This commit is contained in:
@@ -15,6 +15,8 @@ dependencies = [
|
||||
"opentelemetry-instrumentation-fastapi>=0.48b0",
|
||||
"prometheus-client>=0.20.0",
|
||||
"structlog>=24.4.0",
|
||||
# CDC 链路:消费 Debezium 写入 Kafka 的 MySQL binlog 变更事件
|
||||
"aiokafka>=0.11.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
233
services/data-ana/src/data_ana/cdc_consumer.py
Normal file
233
services/data-ana/src/data_ana/cdc_consumer.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""CDC 消费者(Debezium MySQL binlog → ClickHouse 宽表).
|
||||
|
||||
链路:
|
||||
MySQL binlog → Debezium Connect → Kafka topic
|
||||
edu-cdc.next_edu_cloud.<table>
|
||||
→ 本消费者 → 解析 Debezium 事件 → 写入 ClickHouse student_dashboard_view
|
||||
|
||||
设计要点:
|
||||
- 监听多张表,按 source.table 路由
|
||||
- 内存缓存 exam_id → (class_id, subject_id) 映射(来自 core_edu_exams 快照+流)
|
||||
- 监听 core_edu_grades 时用缓存扩展为宽表记录写入 ClickHouse
|
||||
- 幂等性:依赖 ClickHouse ReplacingMergeTree 引擎按 ORDER BY 去重
|
||||
(schema 需用 ReplacingMergeTree(last_updated),当前为简化版 MergeTree)
|
||||
- op 类型:r(快照读)、c(新增)、u(更新)、d(删除);d 时 after 为 null
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from .clickhouse_client import upsert_student_dashboard
|
||||
from .config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _parse_ts(ts_ms: int | None) -> datetime:
|
||||
"""Debezium ts_ms(毫秒)→ datetime."""
|
||||
if ts_ms is None:
|
||||
return datetime.now(UTC)
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=UTC)
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> float:
|
||||
"""安全转 float(Debezium 数值字段可能是字符串)."""
|
||||
if value is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
class ExamCache:
|
||||
"""内存缓存 exam_id → (class_id, subject_id).
|
||||
|
||||
从 core_edu_exams 表的 CDC 事件构建。subject_id 在 exams 表中暂无字段,
|
||||
这里占位为空字符串,后续扩展 schema 时再补充。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._data: dict[str, tuple[str, str]] = {}
|
||||
|
||||
def upsert(self, exam_id: str, class_id: str, subject_id: str = "") -> None:
|
||||
if exam_id:
|
||||
self._data[exam_id] = (class_id, subject_id)
|
||||
|
||||
def get(self, exam_id: str) -> tuple[str, str] | None:
|
||||
return self._data.get(exam_id) if exam_id else None
|
||||
|
||||
|
||||
# 全局缓存(进程级单例)
|
||||
_exam_cache = ExamCache()
|
||||
|
||||
|
||||
async def _handle_exams_event(after: dict[str, Any] | None) -> None:
|
||||
"""处理 core_edu_exams 表事件."""
|
||||
if after is None:
|
||||
return
|
||||
exam_id = after.get("id")
|
||||
class_id = after.get("class_id", "")
|
||||
if exam_id:
|
||||
_exam_cache.upsert(exam_id, class_id)
|
||||
logger.info("exam_cache_updated", exam_id=exam_id, class_id=class_id)
|
||||
|
||||
|
||||
async def _handle_grades_event(
|
||||
after: dict[str, Any] | None,
|
||||
op: str,
|
||||
ts_ms: int | None,
|
||||
) -> None:
|
||||
"""处理 core_edu_grades 表事件 → 写入 ClickHouse 宽表.
|
||||
|
||||
- op=r/c/u:after 为新数据,写入宽表
|
||||
- op=d:after 为 null,暂不处理(宽表保留历史记录)
|
||||
"""
|
||||
if after is None:
|
||||
return
|
||||
|
||||
student_id = after.get("student_id", "")
|
||||
exam_id = after.get("exam_id", "")
|
||||
score = _safe_float(after.get("score"))
|
||||
|
||||
# 从缓存拿 class_id
|
||||
class_id = ""
|
||||
if exam_id:
|
||||
cached = _exam_cache.get(exam_id)
|
||||
if cached:
|
||||
class_id = cached[0]
|
||||
|
||||
last_updated = _parse_ts(ts_ms)
|
||||
if after.get("updated_at"):
|
||||
# 优先用 MySQL 的 updated_at 字段
|
||||
with contextlib.suppress(ValueError, AttributeError):
|
||||
last_updated = datetime.fromisoformat(after["updated_at"].replace("Z", "+00:00"))
|
||||
|
||||
# 简化:rank/kp/mastery/error_count 暂用默认值
|
||||
# 真实场景应通过其他 CDC 事件或聚合计算得到
|
||||
await upsert_student_dashboard(
|
||||
student_id=student_id,
|
||||
class_id=class_id,
|
||||
exam_id=exam_id,
|
||||
subject_id="", # 占位
|
||||
score=score,
|
||||
rank_in_class=0,
|
||||
knowledge_point_id="", # 占位
|
||||
mastery_level=score / 100.0, # 简化:用分数百分比作为掌握度
|
||||
error_count=0,
|
||||
last_updated=last_updated,
|
||||
)
|
||||
|
||||
|
||||
async def _process_message(topic: str, value: bytes | str) -> None:
|
||||
"""处理单条 Kafka 消息.
|
||||
|
||||
Debezium 事件格式(简化后,schemas.enable=false):
|
||||
{
|
||||
"before": {...} | null,
|
||||
"after": {...} | null,
|
||||
"source": {"table": "...", "db": "...", ...},
|
||||
"op": "r|c|u|d",
|
||||
"ts_ms": 1783572350928
|
||||
}
|
||||
"""
|
||||
try:
|
||||
value_str = value.decode("utf-8") if isinstance(value, bytes) else value
|
||||
event = json.loads(value_str)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
logger.warning("cdc_message_decode_failed", error=str(exc), topic=topic)
|
||||
return
|
||||
|
||||
source = event.get("source") or {}
|
||||
table = source.get("table", "")
|
||||
op = event.get("op", "")
|
||||
ts_ms = event.get("ts_ms")
|
||||
after = event.get("after")
|
||||
|
||||
logger.info(
|
||||
"cdc_event_received",
|
||||
topic=topic,
|
||||
table=table,
|
||||
op=op,
|
||||
ts_ms=ts_ms,
|
||||
)
|
||||
|
||||
if table == "core_edu_exams":
|
||||
await _handle_exams_event(after)
|
||||
elif table == "core_edu_grades":
|
||||
await _handle_grades_event(after, op, ts_ms)
|
||||
else:
|
||||
# 其他表暂不处理,仅记录
|
||||
logger.debug("cdc_event_skipped", table=table)
|
||||
|
||||
|
||||
async def run_consumer() -> None:
|
||||
"""CDC 消费者主循环(lifespan 启动).
|
||||
|
||||
- kafka_brokers 未配置:直接返回,不启动消费者(降级模式)
|
||||
- 启动失败:仅记录错误,不阻塞 FastAPI 主流程
|
||||
"""
|
||||
if not settings.kafka_brokers:
|
||||
logger.info("cdc_consumer_disabled_no_kafka_brokers")
|
||||
return
|
||||
|
||||
try:
|
||||
from aiokafka import AIOKafkaConsumer
|
||||
except ImportError:
|
||||
logger.warning("cdc_consumer_aiokafka_not_installed")
|
||||
return
|
||||
|
||||
topics = [t.strip() for t in settings.kafka_cdc_topics.split(",") if t.strip()]
|
||||
if not topics:
|
||||
logger.warning("cdc_consumer_no_topics_configured")
|
||||
return
|
||||
|
||||
brokers = [b.strip() for b in settings.kafka_brokers.split(",") if b.strip()]
|
||||
|
||||
consumer = AIOKafkaConsumer(
|
||||
*topics,
|
||||
bootstrap_servers=brokers,
|
||||
group_id=settings.kafka_group_id,
|
||||
auto_offset_reset=settings.kafka_auto_offset_reset,
|
||||
enable_auto_commit=True,
|
||||
value_deserializer=lambda v: v, # 保留原始 bytes,由 _process_message 解码
|
||||
)
|
||||
|
||||
try:
|
||||
await consumer.start()
|
||||
logger.info(
|
||||
"cdc_consumer_started",
|
||||
brokers=brokers,
|
||||
topics=topics,
|
||||
group_id=settings.kafka_group_id,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("cdc_consumer_start_failed", error=str(exc))
|
||||
return
|
||||
|
||||
try:
|
||||
async for msg in consumer:
|
||||
try:
|
||||
await _process_message(msg.topic, msg.value)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"cdc_message_process_failed",
|
||||
error=str(exc),
|
||||
topic=msg.topic,
|
||||
partition=msg.partition,
|
||||
offset=msg.offset,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("cdc_consumer_cancelled")
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
await consumer.stop()
|
||||
logger.info("cdc_consumer_stopped")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("cdc_consumer_stop_failed", error=str(exc))
|
||||
@@ -5,6 +5,7 @@
|
||||
保证服务在 ClickHouse 不可用时仍可启动并响应骨架数据。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
@@ -216,3 +217,127 @@ async def ping() -> bool:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("clickhouse_ping_failed", error=str(exc))
|
||||
return False
|
||||
|
||||
|
||||
async def upsert_student_dashboard(
|
||||
student_id: str,
|
||||
class_id: str,
|
||||
exam_id: str,
|
||||
subject_id: str,
|
||||
score: float,
|
||||
rank_in_class: int,
|
||||
knowledge_point_id: str,
|
||||
mastery_level: float,
|
||||
error_count: int,
|
||||
last_updated: datetime,
|
||||
) -> bool:
|
||||
"""写入/更新学生学情宽表(CDC 消费专用).
|
||||
|
||||
使用 ReplacingMergeTree 语义:按 ORDER BY 字段去重,保留 last_updated 最大版本。
|
||||
返回 True 表示成功;False 表示降级模式或写入失败。
|
||||
"""
|
||||
client = get_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
client.insert(
|
||||
"student_dashboard_view",
|
||||
[
|
||||
[
|
||||
student_id,
|
||||
class_id,
|
||||
exam_id,
|
||||
subject_id,
|
||||
score,
|
||||
rank_in_class,
|
||||
knowledge_point_id,
|
||||
mastery_level,
|
||||
error_count,
|
||||
last_updated,
|
||||
]
|
||||
],
|
||||
column_names=[
|
||||
"student_id",
|
||||
"class_id",
|
||||
"exam_id",
|
||||
"subject_id",
|
||||
"score",
|
||||
"rank_in_class",
|
||||
"knowledge_point_id",
|
||||
"mastery_level",
|
||||
"error_count",
|
||||
"last_updated",
|
||||
],
|
||||
)
|
||||
logger.info(
|
||||
"student_dashboard_upserted",
|
||||
student_id=student_id,
|
||||
class_id=class_id,
|
||||
exam_id=exam_id,
|
||||
score=score,
|
||||
)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"student_dashboard_upsert_failed_degraded",
|
||||
error=str(exc),
|
||||
student_id=student_id,
|
||||
exam_id=exam_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def upsert_student_error(
|
||||
student_id: str,
|
||||
question_id: str,
|
||||
knowledge_point_id: str,
|
||||
error_count: int,
|
||||
last_error_time: datetime,
|
||||
content: str,
|
||||
) -> bool:
|
||||
"""写入/更新学生错题本(CDC 消费专用).
|
||||
|
||||
返回 True 表示成功;False 表示降级模式或写入失败。
|
||||
"""
|
||||
client = get_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
client.insert(
|
||||
"student_errors",
|
||||
[
|
||||
[
|
||||
student_id,
|
||||
question_id,
|
||||
knowledge_point_id,
|
||||
error_count,
|
||||
last_error_time,
|
||||
content,
|
||||
]
|
||||
],
|
||||
column_names=[
|
||||
"student_id",
|
||||
"question_id",
|
||||
"knowledge_point_id",
|
||||
"error_count",
|
||||
"last_error_time",
|
||||
"content",
|
||||
],
|
||||
)
|
||||
logger.info(
|
||||
"student_error_upserted",
|
||||
student_id=student_id,
|
||||
question_id=question_id,
|
||||
error_count=error_count,
|
||||
)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"student_error_upsert_failed_degraded",
|
||||
error=str(exc),
|
||||
student_id=student_id,
|
||||
question_id=question_id,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -8,6 +8,9 @@ class Settings(BaseSettings):
|
||||
|
||||
ClickHouse 连接参数为可选:当 clickhouse_host 为空字符串时,
|
||||
服务进入降级模式(查询方法返回 None / 空数据),保证服务可启动。
|
||||
|
||||
Kafka 连接参数为可选:当 kafka_brokers 为空字符串时,
|
||||
CDC 消费者不启动(降级模式),保证服务可启动。
|
||||
"""
|
||||
|
||||
port: int = 3006
|
||||
@@ -22,8 +25,20 @@ class Settings(BaseSettings):
|
||||
log_level: str = "info"
|
||||
# 开发模式开关("true"/"false")
|
||||
dev_mode: str = "false"
|
||||
# Kafka brokers(CDC 消费预留,暂不实现)
|
||||
kafka_brokers: str = "localhost:9092"
|
||||
# Kafka brokers(CDC 消费;留空则不启动消费者)
|
||||
# 主机访问用 localhost:9092,容器内访问用 kafka:29092
|
||||
kafka_brokers: str = ""
|
||||
# CDC 消费组 id
|
||||
kafka_group_id: str = "data-ana-cdc-consumer"
|
||||
# 要消费的 CDC topic(Debezium 默认命名:<prefix>.<database>.<table>)
|
||||
# 用逗号分隔多个 topic
|
||||
kafka_cdc_topics: str = (
|
||||
"edu-cdc.next_edu_cloud.core_edu_grades,"
|
||||
"edu-cdc.next_edu_cloud.core_edu_exams,"
|
||||
"edu-cdc.next_edu_cloud.classes"
|
||||
)
|
||||
# 消费者自动偏移重置策略(earliest / latest)
|
||||
kafka_auto_offset_reset: str = "earliest"
|
||||
|
||||
model_config = {"env_file": ".env", "env_prefix": ""}
|
||||
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
支持 ClickHouse 降级模式:当 CLICKHOUSE_HOST 未配置或不可达时,
|
||||
查询端点返回骨架数据,服务仍可启动与响应。
|
||||
|
||||
支持 CDC 消费者:当 KAFKA_BROKERS 配置时,
|
||||
后台启动 aiokafka 消费者,监听 Debezium CDC 事件写入 ClickHouse。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -15,6 +20,7 @@ from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from prometheus_client import make_asgi_app
|
||||
|
||||
from .cdc_consumer import run_consumer as run_cdc_consumer
|
||||
from .clickhouse_client import (
|
||||
close_client,
|
||||
query_class_performance,
|
||||
@@ -27,6 +33,9 @@ from .config import settings
|
||||
_logger: structlog.stdlib.BoundLogger | None = None
|
||||
tracer = trace.get_tracer(__name__)
|
||||
|
||||
# CDC 消费者后台任务句柄
|
||||
_cdc_task: asyncio.Task | None = None
|
||||
|
||||
# 日志级别映射
|
||||
_LOG_LEVELS: dict[str, int] = {
|
||||
"DEBUG": 10,
|
||||
@@ -45,7 +54,7 @@ def init_logger() -> structlog.stdlib.BoundLogger:
|
||||
global _logger
|
||||
level = _LOG_LEVELS.get(settings.log_level.upper(), 20)
|
||||
structlog.configure(
|
||||
wrapper_class=structlog.make_filtering_logger(level),
|
||||
wrapper_class=structlog.make_filtering_bound_logger(level),
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
@@ -85,8 +94,10 @@ async def lifespan(app: FastAPI):
|
||||
1. 初始化 logger(structlog)
|
||||
2. 初始化 OTel tracer(endpoint 从 config 读)
|
||||
3. 触发 ClickHouse 客户端惰性初始化(不阻塞启动,失败进入降级模式)
|
||||
4. 关闭时释放 ClickHouse 客户端
|
||||
4. 若配置了 kafka_brokers,后台启动 CDC 消费者任务
|
||||
5. 关闭时停止 CDC 任务并释放 ClickHouse 客户端
|
||||
"""
|
||||
global _cdc_task
|
||||
logger = init_logger()
|
||||
init_tracer()
|
||||
logger.info(
|
||||
@@ -95,9 +106,17 @@ async def lifespan(app: FastAPI):
|
||||
dev_mode=settings.dev_mode,
|
||||
clickhouse_configured=bool(settings.clickhouse_host),
|
||||
kafka_brokers=settings.kafka_brokers,
|
||||
kafka_cdc_topics=settings.kafka_cdc_topics,
|
||||
)
|
||||
# 启动 CDC 消费者后台任务(若未配置 kafka_brokers,run_consumer 内部直接返回)
|
||||
_cdc_task = asyncio.create_task(run_cdc_consumer())
|
||||
yield
|
||||
logger.info("data_ana_service_stopping")
|
||||
# 取消 CDC 任务
|
||||
if _cdc_task is not None and not _cdc_task.done():
|
||||
_cdc_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await _cdc_task
|
||||
await close_client()
|
||||
|
||||
|
||||
@@ -128,7 +147,19 @@ async def readyz() -> dict:
|
||||
- 已配置且可达:ready=true
|
||||
- 未配置:ready=true,degraded=true(降级模式仍可服务)
|
||||
- 已配置但不可达:ready=false
|
||||
|
||||
CDC 消费者状态附加在响应中:
|
||||
- cdc_consumer: running / disabled / failed
|
||||
"""
|
||||
cdc_status = "disabled"
|
||||
if _cdc_task is not None:
|
||||
if _cdc_task.done():
|
||||
cdc_status = "failed"
|
||||
elif not settings.kafka_brokers:
|
||||
cdc_status = "disabled"
|
||||
else:
|
||||
cdc_status = "running"
|
||||
|
||||
if not settings.clickhouse_host:
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -136,6 +167,8 @@ async def readyz() -> dict:
|
||||
"ready": True,
|
||||
"degraded": True,
|
||||
"clickhouse": "not_configured",
|
||||
"cdc_consumer": cdc_status,
|
||||
"kafka_brokers": settings.kafka_brokers or None,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@@ -146,6 +179,8 @@ async def readyz() -> dict:
|
||||
"ready": ch_ok,
|
||||
"degraded": not ch_ok,
|
||||
"clickhouse": "ok" if ch_ok else "unreachable",
|
||||
"cdc_consumer": cdc_status,
|
||||
"kafka_brokers": settings.kafka_brokers or None,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user