4. 数据模型设计:评分模型数据结构与存储优化
评分接口的核心挑战在于:写入频繁(用户每次行为都可能触发评分变更)、读取要求极低延迟(通常 < 5ms)、数据一致性要求高(不能丢分、不能重复计分)。因此,数据模型设计必须围绕“写链路轻、读链路快、缓存友好”三个原则展开。
设计原则:评分模型应优先采用“预聚合 + 增量更新”模式,避免在查询时实时计算全量数据。将“评分记录”与“评分汇总”分离存储,是低延迟架构的基石。
4.1 评分模型核心数据结构
我们将评分系统抽象为三个核心实体:评分事件(ScoreEvent)、评分汇总(ScoreSummary)、评分规则(ScoreRule)。三者之间的关系如下:
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 数据模型设计检查清单
- 是否分离了事件表和汇总表? —— 是,score_events 存原始记录,score_summary 存预聚合结果。
- 汇总表是否使用原子更新(UPSERT)? —— 是,避免并发写入导致数据不一致。
- 索引是否覆盖了所有查询路径? —— 是,用户维度、对象维度、时间维度均已覆盖。
- 缓存键是否包含类型和ID? —— 是,避免不同对象类型之间的键冲突。
- 是否有缓存穿透防护? —— 是,空值缓存 + 短 TTL。
- 评分规则是否独立成表? —— 是,便于动态调整而不影响核心链路。