Skip to content
Published on

Feast Feature Store 实战运维指南:从特征工程到实时服务与训练-服务偏差防治

分享
Authors

Feast Feature Store

引言

ML 模型在生产环境中失败的原因中,相当一部分并非出在模型本身,而是出在特征(Feature)上。训练时计算的特征与服务时查询到的特征不一致——训练-服务偏差(Training-Serving Skew)、特征流水线故障导致提供陈旧(stale)数据、特征定义变更时破坏下游模型的兼容性,都是典型的例子。Feature Store 正是为在架构层面解决这些问题而诞生的基础设施组件。

Feast(Feature Store)是 2019 年由 Gojek 和 Google 发起的开源 Feature Store 项目,目前拥有最活跃的社区。本文全面覆盖在生产环境中实际运维 Feast 时会遇到的设计决策、架构选择、故障应对,以及从结构上防治训练-服务偏差的策略。目标不是停留在简单教程的层面,而是希望成为管理着数十个 Feature View 与数百万实体的团队可以参考的实战运维指南

Feature Store 的必要性

特征管理的现实问题

如果不使用 Feature Store 运维 ML 系统,就会反复出现以下问题。

  1. 特征逻辑重复:数据科学家在 Jupyter Notebook 中编写的特征转换代码,与后端工程师在服务端实现的代码存在细微差异。同样是 avg_purchase_amount_30d 这个特征,训练时把 NULL 填充为 0,服务时却用平均值填充。
  2. 特征缺乏可发现性:团队 A 已经计算好的 user_click_rate_7d 特征,团队 B 并不知情,又重新计算了一遍。组织内的特征资产得不到统一管理。
  3. 无法时间旅行(Time Travel):无法回答"两周前这个用户的特征值是多少"这类问题,重新训练或调试也因此变得不可能。
  4. 服务延迟:模型推理时如果从多个数据源实时计算特征,p99 延迟会飙升到数百毫秒。

Feature Store 解决的问题

Feature Store 扮演着特征的单一真相来源(Single Source of Truth)的角色。由于离线训练数据和在线服务数据都从同一个特征定义生成,逻辑不一致的问题从根本上被消除。借助中央注册表可以检索和复用特征,并通过 Point-in-Time Join 精确复现过去某个时点的特征。

Feast 架构

Feast 的架构主要由离线存储在线存储注册表,以及特征服务器四个核心组件构成。

离线存储 (Offline Store)

离线存储负责保存大量历史特征数据,并在生成训练数据时执行 Point-in-Time Join。后端通常使用 BigQuery、Snowflake、Redshift、S3/Parquet 等大规模分析型数据仓库或数据湖。读取性能十分关键,必须能够高效扫描数十 TB 规模的数据。

在线存储 (Online Store)

在线存储是面向实时服务的低延迟键值存储。后端通常使用 Redis、DynamoDB、Bigtable 等,需要以实体键为基准,在 p99 10ms 以内返回最新的特征值。通过 Materialization(具体化)过程,离线存储的数据会被复制到在线存储。

注册表 (Registry)

注册表是保存实体、Feature View、Feature Service 等元数据的中央目录。可以基于文件(本地、S3、GCS)运行,也可以基于 SQL(PostgreSQL、MySQL)运行。生产环境中推荐使用基于 SQL 的注册表,因为它能在多个团队同时执行 feast apply 时防止冲突。

特征服务器 (Feature Server)

Feast 内置的特征服务器通过 REST/gRPC 端点提供在线特征服务。它是基于 Go 实现的高性能服务器,可以在模型服务基础设施(KServe、Seldon 等)中以 sidecar 或独立服务的形式部署。

安装与初始设置

安装 Feast 并初始化项目

# Feast 설치 (Redis, PostgreSQL 백엔드 포함)
pip install "feast[redis,postgres]"

# 새 프로젝트 초기화
feast init recommendation_features
cd recommendation_features

# 프로젝트 구조 확인
# recommendation_features/
#   feature_repo/
#     __init__.py
#     feature_store.yaml    # Feast 프로젝트 설정
#     entities.py            # 엔티티 정의
#     feature_views.py       # Feature View 정의
#     feature_services.py    # Feature Service 정의
#     data/                  # 로컬 테스트용 데이터

生产环境 feature_store.yaml 配置

# feature_store.yaml - 프로덕션 환경 구성
project: recommendation_platform
provider: local
entity_key_serialization_version: 3

registry:
  registry_type: sql
  path: postgresql://feast_user:${FEAST_REGISTRY_PASSWORD}@pg-registry.internal:5432/feast_registry
  cache_ttl_seconds: 60
  sqlalchemy_config_kwargs:
    pool_size: 10
    max_overflow: 20
    pool_pre_ping: true

offline_store:
  type: bigquery
  project_id: ml-platform-prod
  dataset: feast_offline_store
  location: asia-northeast3

online_store:
  type: redis
  connection_string: redis://:${REDIS_PASSWORD}@redis-feast.internal:6379
  key_ttl_seconds: 172800 # 48시간 TTL
  redis_type: redis_cluster

flags:
  alpha_features: true
  on_demand_transforms: true

这份配置中需要注意的是 cache_ttl_seconds。如果注册表缓存 TTL 设置得过长,feast apply 之后的变更不会立即生效,可能引发服务故障;反之设置得过短,则会导致负载集中在注册表数据库上。生产环境中 30~120 秒比较合适。

特征定义与实体管理

实体定义

实体表示特征的主体。用户、商品、订单等业务领域中的核心对象都会被定义为实体。

# entities.py
from feast import Entity, ValueType

# 유저 엔티티
user = Entity(
    name="user_id",
    description="서비스 유저의 고유 식별자. UUID v4 형식.",
    value_type=ValueType.STRING,
    tags={
        "owner": "ml-platform-team",
        "pii": "true",
        "domain": "user",
    },
)

# 아이템(상품) 엔티티
item = Entity(
    name="item_id",
    description="상품 카탈로그의 고유 식별자. 정수형 ID.",
    value_type=ValueType.INT64,
    tags={
        "owner": "recommendation-team",
        "domain": "catalog",
    },
)

# 유저-아이템 상호작용 엔티티 (복합 키)
user_item = Entity(
    name="user_item_id",
    description="유저-아이템 상호작용 복합 키. 'user_id:item_id' 형식.",
    value_type=ValueType.STRING,
    tags={
        "owner": "recommendation-team",
        "domain": "interaction",
    },
)

设计实体时的核心原则是:一个实体应当代表一个业务对象。需要复合键时应定义单独的实体,但要考虑到关联(join)性能,注意不要让键的基数(cardinality)过度膨胀。

Feature View 与 Feature Service

Feature View 定义

Feature View 是从单一数据源派生出的一组相关特征的逻辑分组,是在离线/在线存储中以一致方式提供特征的核心抽象。

# feature_views.py
from datetime import timedelta
from feast import (
    FeatureView,
    Field,
    BigQuerySource,
    PushSource,
    StreamSource,
)
from feast.types import Float32, Float64, Int64, String, UnixTimestamp

# 유저 구매 통계 Feature View
user_purchase_source = BigQuerySource(
    name="user_purchase_stats_source",
    table="ml-platform-prod.feast_offline_store.user_purchase_stats",
    timestamp_field="event_timestamp",
    created_timestamp_column="created_at",
    description="유저별 구매 통계 집계 테이블. 매시간 dbt로 갱신.",
)

user_purchase_stats = FeatureView(
    name="user_purchase_stats",
    entities=[user],
    ttl=timedelta(days=7),
    schema=[
        Field(name="total_purchases_7d", dtype=Int64, description="최근 7일 총 구매 건수"),
        Field(name="total_purchases_30d", dtype=Int64, description="최근 30일 총 구매 건수"),
        Field(name="avg_order_value_7d", dtype=Float64, description="최근 7일 평균 주문 금액"),
        Field(name="avg_order_value_30d", dtype=Float64, description="최근 30일 평균 주문 금액"),
        Field(name="max_order_value_30d", dtype=Float64, description="최근 30일 최대 주문 금액"),
        Field(name="purchase_frequency_score", dtype=Float32, description="구매 빈도 정규화 점수 (0~1)"),
        Field(name="last_purchase_days_ago", dtype=Int64, description="마지막 구매 후 경과 일수"),
    ],
    source=user_purchase_source,
    online=True,
    tags={
        "team": "recommendation",
        "sla_freshness_minutes": "60",
        "data_quality_owner": "data-eng@company.com",
    },
)

# 유저 세션 피처 (실시간 Push 소스 포함)
user_session_push_source = PushSource(
    name="user_session_push",
    batch_source=BigQuerySource(
        name="user_session_batch_source",
        table="ml-platform-prod.feast_offline_store.user_session_features",
        timestamp_field="event_timestamp",
    ),
)

user_session_features = FeatureView(
    name="user_session_features",
    entities=[user],
    ttl=timedelta(hours=24),
    schema=[
        Field(name="session_count_24h", dtype=Int64, description="최근 24시간 세션 수"),
        Field(name="avg_session_duration_min", dtype=Float32, description="평균 세션 시간(분)"),
        Field(name="pages_viewed_1h", dtype=Int64, description="최근 1시간 조회 페이지 수"),
        Field(name="last_active_minutes_ago", dtype=Int64, description="마지막 활동 후 경과 분"),
        Field(name="is_currently_active", dtype=Int64, description="현재 활성 세션 여부 (0/1)"),
    ],
    source=user_session_push_source,
    online=True,
    tags={
        "team": "recommendation",
        "sla_freshness_minutes": "5",
        "realtime": "true",
    },
)

On-Demand Feature View

当训练和服务两侧都需要应用相同的转换逻辑时,就要使用 On-Demand Feature View。这是防治训练-服务偏差的核心机制之一。

# on_demand_features.py
from feast import on_demand_feature_view, Field
from feast.types import Float32, Int64
import pandas as pd

@on_demand_feature_view(
    sources=[user_purchase_stats, user_session_features],
    schema=[
        Field(name="engagement_score", dtype=Float32),
        Field(name="purchase_recency_bucket", dtype=Int64),
        Field(name="activity_intensity", dtype=Float32),
    ],
    description="구매와 세션 피처를 결합하여 계산하는 파생 피처",
)
def user_engagement_features(inputs: pd.DataFrame) -> pd.DataFrame:
    """
    학습과 서빙에서 동일하게 실행되는 변환 로직.
    이 함수 안에서 정의된 로직은 get_historical_features()와
    get_online_features() 양쪽에서 동일하게 적용된다.
    """
    df = pd.DataFrame()

    # 참여도 점수: 구매 빈도와 세션 활동을 결합
    purchase_norm = inputs["purchase_frequency_score"].fillna(0.0)
    session_norm = (
        inputs["session_count_24h"].fillna(0).clip(upper=50) / 50.0
    )
    df["engagement_score"] = (0.6 * purchase_norm + 0.4 * session_norm).astype("float32")

    # 구매 최신성 버킷: 0(오늘), 1(1~3일), 2(4~7일), 3(8~14일), 4(15일+)
    days = inputs["last_purchase_days_ago"].fillna(999)
    df["purchase_recency_bucket"] = pd.cut(
        days,
        bins=[-1, 0, 3, 7, 14, float("inf")],
        labels=[0, 1, 2, 3, 4],
    ).astype("int64")

    # 활동 강도: 최근 1시간 페이지 뷰 기반
    df["activity_intensity"] = (
        inputs["pages_viewed_1h"].fillna(0).clip(upper=100) / 100.0
    ).astype("float32")

    return df

Feature Service 定义

Feature Service 是把特定模型所使用的特征集合打包在一起进行版本管理的单位。将模型与 Feature Service 一一映射后,就能清楚地知道哪个模型依赖哪些特征。

# feature_services.py
from feast import FeatureService

# 추천 모델 v3용 Feature Service
recommendation_v3_service = FeatureService(
    name="recommendation_v3",
    features=[
        user_purchase_stats[["total_purchases_7d", "avg_order_value_30d", "purchase_frequency_score"]],
        user_session_features[["session_count_24h", "last_active_minutes_ago"]],
        user_engagement_features,  # On-Demand Feature View 전체 포함
    ],
    description="추천 모델 v3이 사용하는 피처 세트. 모델 버전 변경 시 새 FeatureService를 생성할 것.",
    tags={
        "model_version": "v3.2.1",
        "model_owner": "rec-team",
        "last_validated": "2026-03-07",
    },
)

# 이탈 예측 모델용 Feature Service
churn_prediction_service = FeatureService(
    name="churn_prediction_v1",
    features=[
        user_purchase_stats,  # 전체 피처 사용
        user_session_features[["session_count_24h", "avg_session_duration_min"]],
    ],
    description="이탈 예측 모델 v1용 피처 세트",
    tags={
        "model_version": "v1.0.0",
        "model_owner": "growth-team",
    },
)

生成离线训练数据

生成训练数据时必须使用 Point-in-Time Join。这一原则要求:以预测时点为基准,只使用在那个时点上实际可知的特征值。一旦违反这个原则就会产生 Feature Leakage(特征泄漏),导致模型在训练环境中表现良好,但在实际服务中性能大幅下降。

# training_data_generator.py
from feast import FeatureStore
from datetime import datetime, timedelta
import pandas as pd

store = FeatureStore(repo_path="./feature_repo")

# 1. 엔티티 데이터프레임 준비: 예측 대상 + 예측 시점
# 각 row가 "이 유저에 대해 이 시점에 예측해야 했다"는 의미
entity_df = pd.DataFrame({
    "user_id": ["u_1001", "u_1002", "u_1003", "u_1004", "u_1005"],
    "event_timestamp": [
        datetime(2026, 2, 15, 10, 0, 0),
        datetime(2026, 2, 20, 14, 30, 0),
        datetime(2026, 2, 25, 9, 0, 0),
        datetime(2026, 3, 1, 11, 15, 0),
        datetime(2026, 3, 5, 16, 45, 0),
    ],
})

# 2. Feature Service를 통해 학습 데이터 조회
# Point-in-Time Join이 자동으로 적용됨
training_df = store.get_historical_features(
    entity_df=entity_df,
    features=store.get_feature_service("recommendation_v3"),
).to_df()

print(f"학습 데이터 shape: {training_df.shape}")
print(f"컬럼: {list(training_df.columns)}")

# 3. 대규모 학습 데이터 생성 시 BigQuery에서 직접 실행
# 수백만 건의 엔티티 데이터는 BigQuery로 직접 읽어 효율적으로 처리
large_entity_df = pd.read_gbq(
    """
    SELECT
        user_id,
        prediction_timestamp AS event_timestamp
    FROM `ml-platform-prod.labels.recommendation_labels`
    WHERE prediction_timestamp BETWEEN '2026-01-01' AND '2026-03-01'
    """,
    project_id="ml-platform-prod",
)

training_df_large = store.get_historical_features(
    entity_df=large_entity_df,
    features=store.get_feature_service("recommendation_v3"),
).to_df()

# 4. 학습 데이터 품질 검증
assert training_df_large.isnull().sum().sum() / training_df_large.size < 0.05, \
    "NULL 비율이 5%를 초과합니다. 데이터 소스를 점검하세요."

training_df_large.to_parquet(
    "gs://ml-datasets/recommendation/training_2026_q1.parquet",
    index=False,
)

在线服务流水线

部署 Feast Feature Server

# online_serving.py
"""
모델 서빙 시 Feast에서 온라인 피처를 조회하는 클라이언트 코드.
KServe Transformer 또는 서빙 애플리케이션에서 사용한다.
"""
from feast import FeatureStore
from typing import Dict, List, Any
import time
import logging

logger = logging.getLogger(__name__)

class FeatureClient:
    """
    프로덕션용 Feast 온라인 피처 클라이언트.
    타임아웃, fallback, 메트릭 수집을 포함한다.
    """

    def __init__(
        self,
        repo_path: str = "./feature_repo",
        timeout_ms: int = 50,
        fallback_enabled: bool = True,
    ):
        self.store = FeatureStore(repo_path=repo_path)
        self.timeout_ms = timeout_ms
        self.fallback_enabled = fallback_enabled
        self._default_values = self._load_default_values()

    def _load_default_values(self) -> Dict[str, Any]:
        """Feature View별 기본값을 사전에 로드한다."""
        return {
            "total_purchases_7d": 0,
            "avg_order_value_30d": 0.0,
            "purchase_frequency_score": 0.0,
            "session_count_24h": 0,
            "last_active_minutes_ago": 1440,
            "engagement_score": 0.0,
            "purchase_recency_bucket": 4,
            "activity_intensity": 0.0,
        }

    def get_features_for_prediction(
        self, user_ids: List[str]
    ) -> Dict[str, List[Any]]:
        """
        추천 모델 추론을 위한 피처 조회.
        타임아웃 시 fallback 기본값을 반환한다.
        """
        entity_rows = [{"user_id": uid} for uid in user_ids]

        start_time = time.monotonic()
        try:
            result = self.store.get_online_features(
                features=self.store.get_feature_service("recommendation_v3"),
                entity_rows=entity_rows,
            ).to_dict()

            elapsed_ms = (time.monotonic() - start_time) * 1000
            logger.info(
                f"Feature retrieval: {len(user_ids)} entities, {elapsed_ms:.1f}ms"
            )

            if elapsed_ms > self.timeout_ms:
                logger.warning(
                    f"Feature retrieval exceeded SLA: {elapsed_ms:.1f}ms > {self.timeout_ms}ms"
                )

            return result

        except Exception as e:
            elapsed_ms = (time.monotonic() - start_time) * 1000
            logger.error(
                f"Feature retrieval failed after {elapsed_ms:.1f}ms: {e}"
            )

            if self.fallback_enabled:
                return self._build_fallback_response(user_ids)
            raise

    def _build_fallback_response(
        self, user_ids: List[str]
    ) -> Dict[str, List[Any]]:
        """피처 조회 실패 시 기본값으로 fallback 응답을 생성한다."""
        response = {"user_id": user_ids}
        for feat_name, default_val in self._default_values.items():
            response[feat_name] = [default_val] * len(user_ids)
        logger.warning(
            f"Returning fallback features for {len(user_ids)} entities"
        )
        return response


# 사용 예시
client = FeatureClient(repo_path="./feature_repo")
features = client.get_features_for_prediction(["u_1001", "u_1002"])

Materialization 策略

Materialization 是把离线存储的数据复制到在线存储的过程,批量服务和实时服务的需求不同,所采用的策略也随之不同。

批量 Materialization

# 전체 Feature View를 특정 시간 범위로 materialize
feast materialize 2026-03-06T00:00:00 2026-03-07T00:00:00

# 점진적 materialization (마지막 실행 이후 ~ 현재)
feast materialize-incremental $(date -u +"%Y-%m-%dT%H:%M:%S")

以编程方式实现 Materialization (Airflow DAG)

# airflow_materialization_dag.py
"""
Airflow DAG: 1시간 간격으로 Feast Feature View를 materialize한다.
Feature View별로 독립적인 태스크로 실행하여 병렬 처리하고,
실패 시 개별 재시도가 가능하도록 구성한다.
"""
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.python import PythonOperator
from feast import FeatureStore

FEATURE_VIEWS_TO_MATERIALIZE = [
    "user_purchase_stats",
    "user_session_features",
    "item_popularity_stats",
]

def materialize_feature_view(feature_view_name: str, **context):
    """개별 Feature View를 materialize한다."""
    store = FeatureStore(repo_path="/opt/feast/feature_repo")

    end_date = context["data_interval_end"]
    start_date = end_date - timedelta(hours=2)  # 2시간 lookback (안전 마진)

    store.materialize(
        start_date=start_date,
        end_date=end_date,
        feature_views=[feature_view_name],
    )

    # 성공 메트릭 기록
    row_count = _verify_materialization(store, feature_view_name, end_date)
    context["ti"].xcom_push(
        key=f"{feature_view_name}_materialized_rows",
        value=row_count,
    )

def _verify_materialization(store, fv_name, end_date):
    """Materialization 결과를 검증한다."""
    # 샘플 엔티티로 온라인 스토어 조회 테스트
    sample_result = store.get_online_features(
        features=[f"{fv_name}:total_purchases_7d"],
        entity_rows=[{"user_id": "u_health_check"}],
    ).to_dict()
    return len(sample_result.get("user_id", []))

with DAG(
    dag_id="feast_materialization",
    schedule_interval="0 * * * *",  # 매시간
    start_date=datetime(2026, 3, 1),
    catchup=False,
    default_args={
        "retries": 3,
        "retry_delay": timedelta(minutes=5),
        "execution_timeout": timedelta(minutes=30),
    },
    tags=["feast", "feature-store", "materialization"],
) as dag:

    for fv_name in FEATURE_VIEWS_TO_MATERIALIZE:
        PythonOperator(
            task_id=f"materialize_{fv_name}",
            python_callable=materialize_feature_view,
            op_args=[fv_name],
        )

Materialization 周期决策指南

特征类型变更频率推荐的 Materialization 周期示例
静态画像几乎不变每天 1 次用户注册日期、性别、地区
按天聚合每天更新 1 次每天 1~2 次30 天平均购买额、每周访问次数
按小时聚合每小时更新每小时最近 24 小时会话数
实时特征按秒更新基于 Push / 流式处理当前会话是否活跃、最近点击

训练-服务偏差防治

训练-服务偏差(Training-Serving Skew)是 ML 系统中最隐蔽、最难发现的 bug。它是指模型训练时看到的特征分布与服务时接收到的特征分布不同,从而导致性能下降的现象。使用 Feast 并不会自动解决这个问题,仍然需要有意识的设计与验证。

偏差产生的主要原因

  1. 转换逻辑重复实现:用于训练的 SQL 与用于服务的 Python 代码相互独立,存在细微差异。
  2. Materialization 延迟:在线存储中残留着过时的特征。
  3. NULL 处理方式不一致:离线端把 NULL 去掉了,在线端却用 0 填充。
  4. 时区差异:离线端按 UTC 计算,在线端按 KST(韩国标准时间)计算,导致"最近 7 天"的范围不一致。
  5. Feature Leakage(特征泄漏):训练时混入了未来数据。

结构性防治策略

# skew_prevention_test.py
"""
학습-서빙 스큐를 자동으로 탐지하는 CI 테스트.
정기적으로 실행하여 온라인-오프라인 값의 일관성을 검증한다.
"""
import pytest
import pandas as pd
import numpy as np
from datetime import datetime
from feast import FeatureStore

@pytest.fixture(scope="module")
def store():
    return FeatureStore(repo_path="./feature_repo")

@pytest.fixture(scope="module")
def sample_entity_ids():
    """검증용 샘플 엔티티. 프로덕션 엔티티 중 랜덤 샘플링."""
    return ["u_1001", "u_1002", "u_1003", "u_1005", "u_1010"]

FEATURE_LIST = [
    "user_purchase_stats:total_purchases_7d",
    "user_purchase_stats:avg_order_value_30d",
    "user_purchase_stats:purchase_frequency_score",
    "user_session_features:session_count_24h",
]

def test_online_offline_consistency(store, sample_entity_ids):
    """
    온라인 스토어 값과 오프라인 스토어 최신 값이 일치하는지 검증.
    Materialization이 정상적으로 동작하면 두 값은 동일해야 한다.
    """
    # 온라인 조회
    online_result = store.get_online_features(
        features=FEATURE_LIST,
        entity_rows=[{"user_id": eid} for eid in sample_entity_ids],
    ).to_dict()

    # 오프라인 조회 (현재 시점 기준)
    entity_df = pd.DataFrame({
        "user_id": sample_entity_ids,
        "event_timestamp": [datetime.utcnow()] * len(sample_entity_ids),
    })

    offline_result = store.get_historical_features(
        entity_df=entity_df,
        features=FEATURE_LIST,
    ).to_df()

    # 각 피처별 온라인-오프라인 값 비교
    for feat in FEATURE_LIST:
        feat_short = feat.split(":")[1]
        for i, eid in enumerate(sample_entity_ids):
            online_val = online_result[feat_short][i]
            offline_val = offline_result.loc[
                offline_result["user_id"] == eid, feat_short
            ].iloc[0]

            if online_val is None and pd.isna(offline_val):
                continue  # 둘 다 NULL이면 일치

            if isinstance(online_val, float):
                assert np.isclose(online_val, offline_val, rtol=1e-5), (
                    f"Skew detected for {eid}.{feat_short}: "
                    f"online={online_val}, offline={offline_val}"
                )
            else:
                assert online_val == offline_val, (
                    f"Skew detected for {eid}.{feat_short}: "
                    f"online={online_val}, offline={offline_val}"
                )

def test_null_handling_consistency(store, sample_entity_ids):
    """
    NULL 처리가 온라인과 오프라인에서 동일한지 검증.
    존재하지 않는 엔티티를 조회했을 때 양쪽의 동작이 같아야 한다.
    """
    ghost_entities = ["u_nonexistent_001", "u_nonexistent_002"]

    online_result = store.get_online_features(
        features=FEATURE_LIST,
        entity_rows=[{"user_id": eid} for eid in ghost_entities],
    ).to_dict()

    for feat in FEATURE_LIST:
        feat_short = feat.split(":")[1]
        for val in online_result[feat_short]:
            assert val is None, (
                f"Non-existent entity should return None, got {val} for {feat_short}"
            )

偏差检测仪表盘指标

指标正常范围告警阈值说明
Online-Offline Parity Rate100%< 99.5%在线/离线值的一致率
Feature Distribution Drift (KL Divergence)< 0.01> 0.05相对训练时点,服务时点的特征分布变化
NULL Rate Difference0%> 1%在线/离线 NULL 比例差异
Materialization Lag p95< 60min> 120minMaterialization 完成所需时间
Stale Feature Rate0%> 5%超过 TTL 的特征比例

流式特征集成

仅靠批量 Materialization 无法服务实时特征(当前会话状态、上一次点击等)。需要把 Feast 的 Push API 与 Kafka 流式处理结合起来,把实时特征反映到在线存储中。

# streaming_feature_ingest.py
"""
Kafka에서 유저 세션 이벤트를 소비하여 Feast Online Store에
실시간으로 반영하는 스트리밍 워커.
Faust(Python stream processing) 또는 Kafka Consumer로 구현한다.
"""
import json
from datetime import datetime
from confluent_kafka import Consumer, KafkaError
from feast import FeatureStore
import pandas as pd

store = FeatureStore(repo_path="./feature_repo")

consumer = Consumer({
    "bootstrap.servers": "kafka-broker:9092",
    "group.id": "feast-session-materializer",
    "auto.offset.reset": "latest",
    "enable.auto.commit": False,
    "max.poll.interval.ms": 300000,
    "session.timeout.ms": 45000,
})
consumer.subscribe(["user-session-events"])

MICRO_BATCH_SIZE = 200
FLUSH_INTERVAL_SEC = 5
buffer = []
last_flush = datetime.utcnow()

try:
    while True:
        msg = consumer.poll(timeout=1.0)

        if msg is not None and not msg.error():
            event = json.loads(msg.value().decode("utf-8"))
            buffer.append({
                "user_id": event["user_id"],
                "session_count_24h": event["session_count_24h"],
                "avg_session_duration_min": event["avg_session_duration_min"],
                "pages_viewed_1h": event["pages_viewed_1h"],
                "last_active_minutes_ago": 0,  # 방금 활동했으므로 0
                "is_currently_active": 1,
                "event_timestamp": datetime.utcnow(),
            })
        elif msg is not None and msg.error():
            if msg.error().code() != KafkaError._PARTITION_EOF:
                raise Exception(f"Kafka error: {msg.error()}")

        elapsed = (datetime.utcnow() - last_flush).total_seconds()
        should_flush = (
            len(buffer) >= MICRO_BATCH_SIZE
            or (buffer and elapsed >= FLUSH_INTERVAL_SEC)
        )

        if should_flush:
            df = pd.DataFrame(buffer)
            # PushSource를 통해 온라인 + 오프라인 스토어 동시 기록
            store.push(
                push_source_name="user_session_push",
                df=df,
                to=store.PushMode.ONLINE_AND_OFFLINE,
            )
            consumer.commit()
            buffer.clear()
            last_flush = datetime.utcnow()

finally:
    consumer.close()

Feature Store 对比

项目FeastTectonHopsworksDatabricks Feature Store
许可协议Apache 2.0(开源)商用(SaaS/自托管)AGPL + 商用依赖 Databricks 平台
部署方式自行管理托管 SaaS自行管理 / 托管内置于 Databricks Workspace
离线存储BigQuery, Snowflake, Redshift, S3 Parquet, FileSpark, Snowflake, RiftHopsFS, S3Delta Lake (Unity Catalog)
在线存储Redis, DynamoDB, Bigtable, PostgreSQL, SQLiteDynamoDB(内置优化)RonDB (MySQL NDB Cluster)Cosmos DB, DynamoDB
流式支持Push API、Kafka(需手动实现)原生 Spark Streaming原生 Kafka/SparkSpark Structured Streaming
转换引擎On-Demand(Python)、dbt 集成Tecton SDK (Spark/Rift)Hopsworks Feature PipelineSpark UDF
Feature Registry基于 SQL / File内置(丰富的 UI)内置(Hopsworks UI)集成 Unity Catalog
训练-服务偏差防治On-Demand FV, Point-in-Time JoinTime-Travel、保证 MaterializationProvenance(溯源)追踪Time-Travel (Delta Lake)
社区活跃度非常高(GitHub 5.5k+ stars)中等(以商用为主)高(学术/开源)高(Databricks 生态)
适合的团队不依赖特定云、需要定制化的团队偏好企业级、完全托管的团队On-prem、以 GPU 训练为主的团队已在使用 Databricks 的团队

监控

核心监控指标

运维生产环境 Feature Store 时,有一些指标是必须持续跟踪的。

  1. Freshness(新鲜度):在线存储中的特征有多新,用当前时间 - 特征的 event_timestamp来衡量。
  2. Serving Latency(服务延迟):在线特征查询的 p50/p95/p99 延迟,SLA 通常要求 p99 < 50ms。
  3. Materialization Success Rate:Materialization 任务的成功/失败比例。
  4. Feature Staleness Rate:超过 TTL 的特征比例。
  5. Online Store Hit Rate:查询请求中实际返回值的比例(非 NULL 响应的比例)。
# feast_metrics_exporter.py
"""
Feast 상태를 Prometheus 메트릭으로 내보내는 exporter.
Kubernetes CronJob으로 5분 간격 실행을 권장한다.
"""
from prometheus_client import Gauge, Histogram, start_http_server
from feast import FeatureStore
import time

# Prometheus 메트릭 정의
FRESHNESS_GAUGE = Gauge(
    "feast_feature_view_freshness_seconds",
    "Feature View의 온라인 스토어 freshness (초)",
    ["feature_view", "project"],
)

MATERIALIZATION_LAG = Gauge(
    "feast_materialization_lag_seconds",
    "마지막 Materialization 이후 경과 시간 (초)",
    ["feature_view"],
)

ONLINE_STORE_LATENCY = Histogram(
    "feast_online_store_latency_seconds",
    "온라인 스토어 조회 레이턴시",
    ["feature_view"],
    buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5],
)

def collect_metrics():
    store = FeatureStore(repo_path="./feature_repo")
    feature_views = store.list_feature_views()

    for fv in feature_views:
        # Freshness 측정
        try:
            start = time.monotonic()
            result = store.get_online_features(
                features=[f"{fv.name}:{fv.features[0].name}"],
                entity_rows=[{"user_id": "u_health_check"}],
            ).to_dict()
            elapsed = time.monotonic() - start

            ONLINE_STORE_LATENCY.labels(feature_view=fv.name).observe(elapsed)
        except Exception:
            pass

if __name__ == "__main__":
    start_http_server(8000)
    while True:
        collect_metrics()
        time.sleep(300)  # 5분 간격

故障排查

问题 1:feast apply 之后在线服务找不到特征

现象: feast apply 成功后 get_online_features() 中发生 KeyError
错误: KeyError: "Feature view 'user_purchase_stats_v2' not found in registry"
原因: 在注册表缓存 TTL 内引用了过时的元数据

解决:
  1. 立即刷新注册表缓存:
     store = FeatureStore(repo_path="./feature_repo")
     store.refresh_registry()
  2. 在部署流程中加入 registry refresh
  3. 把 cache_ttl_seconds 设置为 60 秒以下

问题 2:Materialization 过程中内存不足

现象: feast materialize 执行过程中 OOMKilled
错误: Container killed due to OOM (exit code 137)
原因: 一次性 materialize 大规模 Feature View 时,试图把全部数据
      加载到内存中

解决:
  1. 缩小时间范围、分批执行:
     feast materialize 2026-03-06T00:00:00 2026-03-06T06:00:00
     feast materialize 2026-03-06T06:00:00 2026-03-06T12:00:00
  2.Feature View 逐个单独 materialize
  3. 提高 Kubernetes Job 的内存 limit(推荐至少 4Gi)
  4. 设置减小批大小的环境变量:
     FEAST_BATCH_MATERIALIZATION_MAX_WORKERS=2

问题 3:Redis 在线存储连接超时激增

现象: 服务延迟 p99 骤增到 200ms 以上
错误: redis.exceptions.TimeoutError: Timeout reading from socket
原因: Redis 集群再平衡(rebalancing)或单节点过载

解决:
  1. 检查 Redis 集群各节点的内存/CPU
  2. 分析热点键(hot key):用 MONITOR 或 redis-cli --hotkeys 确认
  3. 在 connection_string 中添加 timeout 参数:
     redis://host:6379?socket_timeout=0.1&socket_connect_timeout=0.1
  4. 在服务代码中应用 circuit breaker + fallback 模式

运维注意事项

TTL 设计

Feature View 的 ttl 值定义的是"这个特征最多可以旧到什么程度仍然有效"。如果 TTL 设置得太短,就会与 Materialization 周期对不上,导致在线查询时返回 NULL。TTL 必须设置为 Materialization 周期的 2 倍以上——例如每小时 materialize 一次,TTL 至少要在 2 小时以上。

实体键设计

实体键的基数(cardinality)决定了在线存储的规模。如果用户数是 1 亿,Feature View 有 10 个,那么在线存储中就要保存 10 亿个键值对。以 Redis 为例,假设每个键占用约 500 字节,就需要约 500GB 内存。因此需要通过 TTL 让不活跃用户自动过期,或者只对最近 N 天内有活动的用户执行 materialize。

feast apply 与部署顺序

变更 Feature View 的 schema 时必须维持向后兼容性(backward compatibility)。新增特征是安全的,但删除已有特征或修改其类型会让依赖它的模型立即出故障。部署顺序如下。

  1. 编写添加了新特征的 Feature View 定义
  2. feast apply 更新注册表
  3. 执行 Materialization,把新特征加载到在线存储
  4. 部署使用新特征的模型
  5. 如果旧特征不再被使用,就在下一次发布中移除

故障案例与恢复

案例 1:Materialization 故障导致提供 48 小时陈旧特征

情况: Airflow DAG 故障导致 Materialization 中断了 48 小时,
      期间在线存储一直服务着两天前的特征。
影响: 推荐模型的 CTR 下降了 15%,
      原因是"最近 24 小时会话数"用的是 48 小时前的值。

恢复步骤:
  1. 分析并修复 Airflow DAG 故障原因(BigQuery 认证令牌过期)
  2. 执行手动 backfill materialization:
     feast materialize 2026-03-05T00:00:00 2026-03-07T12:00:00
  3. 确认在线存储 freshness 恢复正常后解除告警
  4. 后续措施: 设置 Materialization 失败 30 分钟内触发 PagerDuty 告警

教训: 如果不单独监控 Materialization 状态,
      问题只能通过模型性能下降才被发现,应对会滞后。

案例 2:Feature View schema 变更导致整体服务下线

情况:"avg_order_value_30d" 特征的类型从 Float64 改为 Int64,
      执行 feast apply 之后,正在服务中的推荐模型立即出现类型错误。
影响: 推荐 API 500 错误持续 5 分钟,执行了紧急回滚。

恢复步骤:
  1. 用 git revert 把 Feature View 定义恢复到上一个版本
  2. 重新执行 feast apply
  3. 确认服务恢复正常

教训: Feature View 的 schema 变更必须先在 staging 环境测试,
      通过依赖模型的兼容性测试之后,才能应用到生产环境。

案例 3:基于 Push 的流式特征中的重复事件处理

情况: 由于 Kafka consumer 的 at-least-once 保证,同一事件被
      处理了两次,导致 session_count_24h 被记录得比实际值更高。
影响: 部分用户的 engagement_score 异常偏高,
      造成推荐结果失真。

恢复步骤:
  1. 增加重复事件检测逻辑(基于事件 ID 去重)
  2. 识别受影响的实体后重新 materialize
  3.Kafka consumer 中增加幂等处理

教训: 流式特征流水线必须保证幂等性(idempotency),
      可以用 Redis Set 管理事件 ID 来去除重复。

检查清单

初始搭建检查清单

  • 完成生产环境 feature_store.yaml 配置(SQL 注册表、Redis 在线存储)
  • 定义实体并建立命名规范
  • 定义 Feature View 并设置 TTL
  • 建立 Feature Service 与模型版本的映射
  • feast apply 集成到 CI/CD 流水线
  • 搭建 Materialization DAG 并配置调度
  • 估算在线存储容量并完成资源配置

日常运维检查清单

  • 每日检查 Materialization 是否成功
  • 确认在线存储 freshness 是否符合 SLA
  • Feature View 变更时验证向后兼容性
  • 每周执行在线-离线 parity 测试
  • 监控在线存储内存使用量的变化趋势
  • 跟踪特征服务延迟 p99

Feature View 变更部署检查清单

  • 在 staging 环境测试 feast apply 与 materialization
  • 执行 schema 兼容性测试(覆盖全部依赖模型)
  • 通过 Point-in-Time Leakage 验证
  • 通过 Online-Offline Parity 测试
  • 在生产环境执行 feast apply
  • 确认 Materialization 正常完成
  • 观察服务延迟与错误率 10 分钟
  • 完成回滚流程的文档化与测试

参考资料