4. 数据模型设计:评分模型数据结构与存储优化

评分接口的核心挑战在于:写入频繁(用户每次行为都可能触发评分变更)、读取要求极低延迟(通常 < 5ms)、数据一致性要求高(不能丢分、不能重复计分)。因此,数据模型设计必须围绕“写链路轻、读链路快、缓存友好”三个原则展开。

设计原则:评分模型应优先采用“预聚合 + 增量更新”模式,避免在查询时实时计算全量数据。将“评分记录”与“评分汇总”分离存储,是低延迟架构的基石。

4.1 评分模型核心数据结构

我们将评分系统抽象为三个核心实体:评分事件(ScoreEvent)、评分汇总(ScoreSummary)、评分规则(ScoreRule)。三者之间的关系如下:

评分系统核心实体关系图 评分事件 ScoreEvent 用户ID | 对象ID | 分值 评分汇总 ScoreSummary 对象ID | 总分 | 评分人数 评分规则 ScoreRule 规则ID | 权重 | 上限 数据流转说明 1. 用户评分 → 写入 ScoreEvent(原始记录,用于审计和重算) 2. 异步或同步更新 ScoreSummary(预聚合结果,用于快速查询) 3. ScoreRule 控制评分行为(如:每人每天限评1次、分值范围1-5)

4.2 数据库表设计(以 PostgreSQL 为例)

以下设计同时适用于 MySQL 8.0+ 和 PostgreSQL 15+,但 PostgreSQL 的 JSONB 类型和部分索引特性在评分场景中更具优势。我们以 PostgreSQL 为例进行说明。

4.2.1 评分事件表(score_events)

CREATE TABLE score_events (
    id              BIGSERIAL       PRIMARY KEY,
    user_id         VARCHAR(64)     NOT NULL,       -- 评分用户
    target_type     VARCHAR(32)     NOT NULL,       -- 评分对象类型(article/video/product)
    target_id       VARCHAR(64)     NOT NULL,       -- 评分对象ID
    score           SMALLINT        NOT NULL CHECK (score >= 1 AND score <= 5),  -- 分值
    rule_id         INT             NOT NULL,       -- 关联的评分规则
    extra           JSONB           DEFAULT '{}',   -- 扩展字段(如评分来源、设备信息)
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    -- 唯一约束:防止同一用户对同一对象重复评分(根据业务决定是否启用)
    UNIQUE (user_id, target_type, target_id)
);

-- 分区建议:按 created_at 按月分区(PostgreSQL 原生支持)
CREATE TABLE score_events_y2025m01 PARTITION OF score_events
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

4.2.2 评分汇总表(score_summary)

CREATE TABLE score_summary (
    target_type     VARCHAR(32)     NOT NULL,
    target_id       VARCHAR(64)     NOT NULL,
    total_score     BIGINT          NOT NULL DEFAULT 0,   -- 总分
    count           INT             NOT NULL DEFAULT 0,   -- 评分人数
    avg_score       DECIMAL(5,2)    GENERATED ALWAYS AS (total_score::DECIMAL / NULLIF(count, 0)) STORED,
    last_updated    TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    PRIMARY KEY (target_type, target_id)
);
设计要点:
  • avg_score 使用 生成列(Generated Column),避免应用层计算,且保证数据一致性。
  • 评分汇总表不存储用户维度数据,只存储对象维度的聚合结果,写入时使用 INSERT ... ON CONFLICT DO UPDATE 实现原子更新。
  • 如果业务需要“用户评分历史”,则依赖 score_events 表;如果仅需“当前评分”,则 score_summary 已足够。

4.2.3 评分规则表(score_rules)

CREATE TABLE score_rules (
    id              SERIAL          PRIMARY KEY,
    target_type     VARCHAR(32)     NOT NULL UNIQUE,  -- 每种对象类型一套规则
    min_score       SMALLINT        NOT NULL DEFAULT 1,
    max_score       SMALLINT        NOT NULL DEFAULT 5,
    daily_limit     INT             NOT NULL DEFAULT 10,  -- 每人每天最多评几次
    weight          DECIMAL(3,2)    NOT NULL DEFAULT 1.00, -- 权重系数
    enabled         BOOLEAN         NOT NULL DEFAULT TRUE,
    updated_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

4.3 索引优化策略

评分接口的查询模式高度集中:按对象查评分、按用户查评分历史、按时间范围查事件。索引设计必须精准覆盖这些路径。

查询场景 SQL 示例 推荐索引 说明
查询对象评分 SELECT * FROM score_summary WHERE target_type='article' AND target_id='123' 主键索引(已自动创建) PK 即 (target_type, target_id),无需额外索引
查询用户评分历史 SELECT * FROM score_events WHERE user_id='u001' ORDER BY created_at DESC LIMIT 20 CREATE INDEX idx_events_user_time ON score_events(user_id, created_at DESC); 覆盖用户维度的排序查询,避免文件排序
查询对象的所有评分事件 SELECT * FROM score_events WHERE target_type='article' AND target_id='123' CREATE INDEX idx_events_target ON score_events(target_type, target_id); 用于后台审计或重算汇总数据
按时间范围统计 SELECT target_id, COUNT(*) FROM score_events WHERE created_at >= '2025-01-01' GROUP BY target_id 分区裁剪 + CREATE INDEX idx_events_time ON score_events(created_at); 分区表配合时间索引,大幅减少扫描量
防重复评分检查 SELECT 1 FROM score_events WHERE user_id='u001' AND target_type='article' AND target_id='123' 唯一约束(已自动创建索引) UNIQUE 约束自带 B-Tree 索引,直接命中
索引避坑:
  • 不要在 score_events 的 extra(JSONB)字段上建 GIN 索引,除非你确实需要按 JSON 内部字段过滤——评分查询几乎不会用到。
  • 不要为 score_summary 的 avg_score 建索引,它是生成列,且查询时通常按对象ID精确匹配,不会走范围扫描。
  • 如果使用 MySQL,注意 GENERATED ALWAYS ... STORED 在 MySQL 5.7+ 也支持,但语法略有差异。

4.4 缓存键设计

评分数据是典型的“读多写少(相对)”场景,但写入时要求秒级可见。缓存策略采用 Cache-Aside + 主动失效 模式。

4.4.1 缓存键命名规范

缓存对象 键格式 TTL 说明
对象评分汇总 score:summary:{target_type}:{target_id} 5 分钟 核心缓存,每次评分写入后主动删除
用户评分记录 score:user:{user_id}:{target_type}:{target_id} 10 分钟 用于快速判断用户是否已评分,防重复提交
评分规则 score:rule:{target_type} 30 分钟 规则变更频率极低,可设置较长 TTL
热门对象评分排行 score:ranking:{target_type} 1 分钟 排行榜场景,允许短暂延迟

4.4.2 缓存更新策略

// 伪代码:评分写入时的缓存处理
public ScoreSummary submitScore(String userId, String targetType, String targetId, int score) {
    // 1. 写入评分事件表(异步批量写入,降低主链路延迟)
    asyncWriteScoreEvent(userId, targetType, targetId, score);

    // 2. 原子更新评分汇总表
    ScoreSummary summary = scoreSummaryRepository.upsert(targetType, targetId, score);

    // 3. 主动失效缓存
    String cacheKey = "score:summary:" + targetType + ":" + targetId;
    cacheClient.delete(cacheKey);

    // 4. 可选:预热缓存(如果后续大概率会读)
    cacheClient.set(cacheKey, summary, 300);

    return summary;
}

// 伪代码:查询评分时的缓存穿透防护
public ScoreSummary getScoreSummary(String targetType, String targetId) {
    String cacheKey = "score:summary:" + targetType + ":" + targetId;
    ScoreSummary summary = cacheClient.get(cacheKey);
    if (summary != null) return summary;

    // 缓存未命中,查数据库
    summary = scoreSummaryRepository.findByTarget(targetType, targetId);
    if (summary == null) {
        // 空值缓存,防止缓存穿透
        cacheClient.set(cacheKey, EMPTY_MARKER, 30);
        return null;
    }
    cacheClient.set(cacheKey, summary, 300);
    return summary;
}
缓存键设计最佳实践:
  • 键名使用 冒号分隔,符合 Redis 层级命名习惯,便于按模式批量删除(如 score:summary:*)。
  • TTL 设置遵循“业务容忍度 + 数据一致性”原则:评分汇总 5 分钟,用户记录 10 分钟,规则 30 分钟。
  • 写入时 先更新数据库,再删除缓存(而非先删缓存再写库),避免并发读写导致脏数据。
  • 对于排行榜等聚合查询,使用 Redis Sorted Set 替代数据库实时计算,可将延迟从 50ms 降至 1ms 以内。

4.5 数据模型设计检查清单

  1. 是否分离了事件表和汇总表? —— 是,score_events 存原始记录,score_summary 存预聚合结果。
  2. 汇总表是否使用原子更新(UPSERT)? —— 是,避免并发写入导致数据不一致。
  3. 索引是否覆盖了所有查询路径? —— 是,用户维度、对象维度、时间维度均已覆盖。
  4. 缓存键是否包含类型和ID? —— 是,避免不同对象类型之间的键冲突。
  5. 是否有缓存穿透防护? —— 是,空值缓存 + 短 TTL。
  6. 评分规则是否独立成表? —— 是,便于动态调整而不影响核心链路。