短内容系统实现:从数据模型到 API 设计

深入短内容系统的工程实现细节,覆盖内容存储模型、拼接式 Feed 生成、互动计数一致性、全文搜索索引与 RESTful API 设计。

在微型博客架构设计确定之后,工程实现阶段的每一步决策都会直接影响系统的性能边界和扩展上限。本文将从数据库 Schema 设计出发,深入到 Feed 生成的具体算法、互动计数的最终一致性保障、全文检索的索引策略,以及面向客户端的 RESTful API 设计规范,为短内容系统提供一份可直接参考的实现蓝图。

一、数据模型精化

1.1 内容的分层存储

短内容看似只有几百字,但伴随的元数据却极其丰富。为了兼顾读写性能,通常将内容拆分为「热数据」和「冷数据」两层存储。

热数据(高频访问):存储在 Redis / MongoDB 中,包含近 7-30 天的内容和活跃用户的资料。
冷数据(低频归档):压缩后存入对象存储(S3 / COS),按时间分片归档,查询时按需加载。

// 热数据层 — posts collection (MongoDB)
const hotPostSchema = {
  _id: ObjectId,
  
  // 基础信息
  author: {
    user_id: ObjectId,
    username: String,
    display_name: String,
    avatar_version: Number  // 头像 CDN 版本,避免缓存穿透
  },
  
  // 内容核心(不可变)
  content: {
    text: String,           // 纯文本,不超过 500 字
    parsed_text: String,    // 解析后的 HTML(@用户、#话题、链接自动渲染)
    lang: String            // 语言检测,用于内容审核和推荐
  },
  
  // 多媒体(引用 CDN URL,非嵌入)
  media: [{
    type: String,           // image | video | audio | poll
    url: String,            // 原图/视频 URL
    variants: [{            // 响应式尺寸
      width: Number,
      height: Number,
      url: String
    }],
    meta: {                 // 媒体元信息
      duration: Number,     // 视频时长(秒)
      size_bytes: Number    // 文件大小
    }
  }],
  
  // 社交上下文
  interaction: {
    reply_to: ObjectId,     // 回复的目标帖子
    root_post: ObjectId,    // 话题根帖
    mentions: [{            // 提及的用户
      user_id: ObjectId,
      username: String,
      indices: [Number, Number]  // 在文本中的起止位置
    }],
    hashtags: [{            // 话题标签
      tag: String,
      indices: [Number, Number]
    }],
    links: [{               // 外链解析
      url: String,
      title: String,
      description: String,
      image: String,
      display_url: String
    }]
  },
  
  // 可见性控制
  visibility: {
    type: String,           // public | followers | mutuals | listed | direct
    reply_policy: String,   // everyone | followers | mentioned | nobody
    quote_policy: String    // allow | deny
  },
  
  // 计数(最终一致,由异步 Worker 维护)
  counters: {
    replies: Number,
    reposts: Number,
    quotes: Number,         // 引用转发(带评论的转发)
    likes: Number,
    bookmarks: Number,
    views: Number
  },
  
  // 时间戳
  created_at: Date,
  edited_at: Date,          // null 表示未编辑
  delete_scheduled_at: Date // 定时删除(阅后即焚模式)
};

1.2 用户资料的版本化缓存

用户修改昵称或头像后,旧帖子中显示的作者信息需要同步更新。直接批量更新所有历史帖子成本极高,应采用「版本化引用」策略:

// 用户资料变更时,更新 Redis 中的作者缓存
async function updateAuthorCache(userId, updates) {
  const newVersion = await incrAuthorVersion(userId);
  
  const cacheKey = `author:${userId}:v${newVersion}`;
  await redis.setex(cacheKey, 86400 * 30, JSON.stringify({
    user_id: userId,
    version: newVersion,
    ...updates
  }));
  
  // 旧版本保留 7 天后自动过期
  const oldVersion = newVersion - 1;
  if (oldVersion > 0) {
    await redis.expire(`author:${userId}:v${oldVersion}`, 86400 * 7);
  }
}

// 渲染 Feed 时,实时查询最新作者信息
async function resolveAuthors(posts) {
  const userIds = [...new Set(posts.map(p => p.author.user_id))];
  const versionMap = await redis.mget(
    userIds.map(id => `author:${id}:version`)
  );
  
  // 批量获取缓存
  const authorCache = await redis.mget(
    userIds.map((id, i) => `author:${id}:v${versionMap[i] || 1}`)
  );
  
  // 缓存缺失时回源数据库并回填
  // ...
  
  return posts.map(post => ({
    ...post,
    author: authorCache[post.author.user_id] || post.author
  }));
}

二、Feed 生成引擎

2.1 拼接式 Feed 生成

生产环境的 Feed 不是单一来源,而是多个子 Feed 的拼接:

Feed = 关注时间线(60%) + 趋势推荐(20%) + 兴趣探索(15%) + 广告主内容(5%)
class FeedGenerator:
    def __init__(self):
        self.timeline_source = TimelineSource()
        self.trending_source = TrendingSource()
        self.interest_source = InterestSource()
        self.ads_source = AdsSource()
    
    async def generate(self, user_id: str, cursor: str = None) -> FeedResult:
        # 并行获取各来源
        results = await asyncio.gather(
            self.timeline_source.fetch(user_id, cursor),
            self.trending_source.fetch(user_id, limit=5),
            self.interest_source.fetch(user_id, limit=3),
            self.ads_source.fetch(user_id, limit=1)
        )
        
        timeline_posts, trending_posts, interest_posts, ads_posts = results
        
        # 按策略拼接
        feed = self._blend_posts(
            timeline_posts, trending_posts, interest_posts, ads_posts
        )
        
        # 去重(同一帖子可能出现在多个来源)
        feed = self._deduplicate(feed)
        
        # 个性化排序
        feed = self._personalize_rank(user_id, feed)
        
        # 生成下一页游标
        next_cursor = self._build_cursor(feed[-1]) if feed else None
        
        return FeedResult(posts=feed, cursor=next_cursor)
    
    def _blend_posts(self, timeline, trending, interest, ads) -> List[Post]:
        """按固定间隔拼接多个来源"""
        blended = []
        t_idx, tr_idx, i_idx, a_idx = 0, 0, 0, 0
        
        for position in range(50):  # 最多 50 条
            # 每 10 位插入 1 条广告
            if position > 0 and position % 10 == 0 and a_idx < len(ads):
                blended.append(ads[a_idx])
                a_idx += 1
                continue
            
            # 每 5 位插入 1 条趋势
            if position > 0 and position % 5 == 0 and tr_idx < len(trending):
                blended.append(trending[tr_idx])
                tr_idx += 1
                continue
            
            # 每 8 位插入 1 条兴趣探索
            if position > 0 and position % 8 == 0 and i_idx < len(interest):
                blended.append(interest[i_idx])
                i_idx += 1
                continue
            
            # 主要填充关注时间线
            if t_idx < len(timeline):
                blended.append(timeline[t_idx])
                t_idx += 1
            else:
                break
        
        return blended

2.2 游标分页设计

Feed 分页不应使用 OFFSET/LIMIT,因为并发修改会导致数据跳跃。应使用时间戳游标:

import base64
import json

class CursorEncoder:
    @staticmethod
    def encode(post_id: str, score: float) -> str:
        """将游标编码为 URL-safe 字符串"""
        payload = json.dumps({"id": post_id, "s": score})
        return base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
    
    @staticmethod
    def decode(cursor: str) -> dict:
        """解码游标"""
        padding = 4 - len(cursor) % 4
        if padding != 4:
            cursor += "=" * padding
        payload = base64.urlsafe_b64decode(cursor.encode())
        return json.loads(payload)

# 使用示例
# 下一页:/api/feed?cursor=eyJpZCI6InBvc3RfMTIzIiwicyI6MTcyNjk5ODQwMH0
# 游标包含最后一条帖子的 ID 和排序分数,确保分页边界精确

三、互动计数一致性

3.1 计数的最终一致性模型

点赞、转发等计数的精确值不要求实时强一致,但「我是否点赞过」必须强一致。采用「精确个人状态 + 近似全局计数」的分层策略:

-- 个人互动状态(强一致)
CREATE TABLE user_interactions (
    user_id BIGINT NOT NULL,
    post_id BIGINT NOT NULL,
    liked BOOLEAN DEFAULT FALSE,
    reposted BOOLEAN DEFAULT FALSE,
    bookmarked BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (user_id, post_id)
);

-- 全局计数(最终一致,异步聚合)
CREATE TABLE post_counters (
    post_id BIGINT PRIMARY KEY,
    likes INT DEFAULT 0,
    replies INT DEFAULT 0,
    reposts INT DEFAULT 0,
    version INT DEFAULT 0  -- 乐观锁
);
class InteractionService:
    async def toggle_like(self, user_id: str, post_id: str) -> LikeResult:
        # 1. 写入个人互动状态(主库,强一致)
        async with self.db.transaction() as tx:
            result = await tx.fetchrow("""
                INSERT INTO user_interactions (user_id, post_id, liked)
                VALUES ($1, $2, TRUE)
                ON CONFLICT (user_id, post_id)
                DO UPDATE SET liked = NOT user_interactions.liked
                RETURNING liked
            """, user_id, post_id)
            
            is_liked = result['liked']
        
        # 2. 发送计数变更事件到 Kafka(异步)
        delta = 1 if is_liked else -1
        await self.kafka_producer.send('counter_events', {
            'post_id': post_id,
            'type': 'like',
            'delta': delta,
            'timestamp': time.time()
        })
        
        # 3. 乐观更新 Redis 缓存中的计数
        cache_key = f"post:{post_id}:counters"
        await redis.hincrby(cache_key, 'likes', delta)
        
        return LikeResult(liked=is_liked)
    
    async def get_user_interaction_state(self, user_id: str, post_ids: List[str]) -> dict:
        """批量查询用户对多条帖子的互动状态"""
        # 优先查 Redis 缓存
        keys = [f"ui:{user_id}:{pid}" for pid in post_ids]
        cached = await redis.mget(keys)
        
        missing = []
        for i, val in enumerate(cached):
            if val is None:
                missing.append(post_ids[i])
        
        # 缓存缺失时回源数据库
        if missing:
            rows = await self.db.fetch("""
                SELECT post_id, liked, reposted, bookmarked
                FROM user_interactions
                WHERE user_id = $1 AND post_id = ANY($2)
            """, user_id, missing)
            
            for row in rows:
                cache_key = f"ui:{user_id}:{row['post_id']}"
                state = json.dumps({
                    'liked': row['liked'],
                    'reposted': row['reposted'],
                    'bookmarked': row['bookmarked']
                })
                await redis.setex(cache_key, 86400, state)
        
        return {pid: json.loads(cached[i] or '{}') for i, pid in enumerate(post_ids)}

3.2 计数聚合 Worker

# counter_aggregator.py — Kafka 消费端
from kafka import KafkaConsumer
import asyncio

class CounterAggregator:
    def __init__(self):
        self.consumer = KafkaConsumer(
            'counter_events',
            group_id='counter-agg-v1',
            enable_auto_commit=False
        )
        self.buffer = defaultdict(lambda: defaultdict(int))
        self.last_flush = time.time()
    
    async def run(self):
        for message in self.consumer:
            event = json.loads(message.value)
            post_id = event['post_id']
            event_type = event['type']
            delta = event['delta']
            
            self.buffer[post_id][event_type] += delta
            
            # 定时或定量刷盘
            if (time.time() - self.last_flush > 5 or 
                sum(len(v) for v in self.buffer.values()) > 1000):
                await self._flush()
    
    async def _flush(self):
        for post_id, counters in self.buffer.items():
            # 使用乐观锁更新数据库
            while True:
                row = await self.db.fetchrow(
                    "SELECT likes, replies, reposts, version FROM post_counters WHERE post_id = $1",
                    post_id
                )
                
                if not row:
                    # 插入新记录
                    await self.db.execute("""
                        INSERT INTO post_counters (post_id, likes, replies, reposts, version)
                        VALUES ($1, $2, $3, $4, 1)
                        ON CONFLICT (post_id) DO NOTHING
                    """, post_id, counters.get('likes', 0), counters.get('replies', 0), counters.get('reposts', 0))
                    break
                
                new_likes = row['likes'] + counters.get('likes', 0)
                new_replies = row['replies'] + counters.get('replies', 0)
                new_reposts = row['reposts'] + counters.get('reposts', 0)
                new_version = row['version'] + 1
                
                result = await self.db.execute("""
                    UPDATE post_counters
                    SET likes = $1, replies = $2, reposts = $3, version = $4
                    WHERE post_id = $5 AND version = $6
                """, new_likes, new_replies, new_reposts, new_version, post_id, row['version'])
                
                if result == "UPDATE 1":
                    break  # 乐观锁成功
                # 失败则重试
        
        self.buffer.clear()
        self.last_flush = time.time()

四、全文搜索与发现

4.1 Elasticsearch 索引设计

{
  "mappings": {
    "properties": {
      "post_id": { "type": "keyword" },
      "content": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "chinese": {
            "type": "text",
            "analyzer": "ik_max_word"
          }
        }
      },
      "hashtags": { "type": "keyword" },
      "mentions": { "type": "keyword" },
      "author_id": { "type": "keyword" },
      "author_username": { "type": "keyword" },
      "created_at": { "type": "date" },
      "likes": { "type": "integer" },
      "reposts": { "type": "integer" },
      "visibility": { "type": "keyword" },
      
      // 地理位置(可选)
      "location": { "type": "geo_point" }
    }
  },
  "settings": {
    "index": {
      "number_of_shards": 5,
      "number_of_replicas": 1,
      "refresh_interval": "5s"  // 近实时搜索
    }
  }
}

4.2 搜索 API 实现

@app.get("/api/search")
async def search_posts(
    q: str,
    sort: str = "relevance",  # relevance | latest | popular
    filter: str = None,       # media | links | people
    since: datetime = None,
    until: datetime = None,
    cursor: str = None,
    limit: int = 20
):
    """全文搜索接口"""
    
    # 构建查询
    must_conditions = [
        {"multi_match": {
            "query": q,
            "fields": ["content^3", "content.chinese^3", "hashtags^2", "author_username"]
        }}
    ]
    
    if filter == "media":
        must_conditions.append({"exists": {"field": "media"}})
    elif filter == "links":
        must_conditions.append({"exists": {"field": "links"}})
    
    filter_conditions = [{"term": {"visibility": "public"}}]
    
    if since:
        filter_conditions.append({"range": {"created_at": {"gte": since}}})
    if until:
        filter_conditions.append({"range": {"created_at": {"lte": until}}})
    
    # 排序策略
    if sort == "latest":
        sort_clause = [{"created_at": "desc"}]
    elif sort == "popular":
        sort_clause = [
            {"likes": "desc"},
            {"reposts": "desc"}
        ]
    else:
        sort_clause = ["_score", {"created_at": "desc"}]
    
    # 游标分页
    search_after = None
    if cursor:
        search_after = json.loads(base64.b64decode(cursor))
    
    query = {
        "query": {
            "bool": {
                "must": must_conditions,
                "filter": filter_conditions
            }
        },
        "sort": sort_clause,
        "size": limit
    }
    
    if search_after:
        query["search_after"] = search_after
    
    response = await es.search(index="posts", body=query)
    
    hits = response["hits"]["hits"]
    posts = [hit["_source"] for hit in hits]
    
    # 生成下一页游标
    next_cursor = None
    if len(hits) == limit:
        last_sort = hits[-1]["sort"]
        next_cursor = base64.b64encode(json.dumps(last_sort).encode()).decode()
    
    return {
        "posts": posts,
        "total": response["hits"]["total"]["value"],
        "next_cursor": next_cursor
    }

五、RESTful API 设计

5.1 API 规格

端点方法说明
/api/v1/feedGET获取个人时间线
/api/v1/postsPOST发布短文
/api/v1/posts/:idGET获取单条短文
/api/v1/posts/:idDELETE删除短文
/api/v1/posts/:id/likePOST/DELETE点赞/取消点赞
/api/v1/posts/:id/repostPOST转发
/api/v1/posts/:id/repliesGET获取回复列表
/api/v1/users/:id/postsGET获取用户发文
/api/v1/users/:id/followPOST/DELETE关注/取消关注
/api/v1/searchGET全文搜索
/api/v1/trendsGET趋势话题

5.2 响应格式规范

// 成功响应
{
  "success": true,
  "data": { ... },           // 业务数据
  "meta": {
    "cursor": "...",         // 分页游标
    "has_more": true,
    "total_count": 1523
  }
}

// 错误响应
{
  "success": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests, please try again later",
    "details": {
      "retry_after": 60       // 秒
    }
  }
}

5.3 字段选择与展开

# GET /api/v1/posts/123?fields=id,content,author(username,avatar),created_at
# GET /api/v1/feed?include=author,media,interaction.counters

class FieldSelector:
    def __init__(self, requested_fields: str):
        self.fields = self._parse(requested_fields) if requested_fields else None
    
    def filter(self, obj: dict) -> dict:
        if self.fields is None:
            return obj
        return {k: v for k, v in obj.items() if k in self.fields}
    
    def _parse(self, fields_str: str) -> set:
        """解析类似 GraphQL 的字段选择语法"""
        result = set()
        # 简单实现:逗号分隔顶层字段
        for field in fields_str.split(','):
            result.add(field.strip().split('.')[0])
        return result

六、总结

短内容系统的工程实现需要在存储分层、Feed 拼接、计数一致性和搜索索引等多个层面做出精细设计。内容存储采用热冷分离以平衡读写性能和成本;Feed 生成通过多源拼接实现丰富的内容多样性;互动计数通过「精确个人状态 + 近似全局计数」的分层模型保证可用性和一致性的平衡;全文搜索基于 Elasticsearch 提供近实时检索能力;API 设计遵循 RESTful 规范并支持字段选择和游标分页。

这些工程决策最终服务于一个核心目标:在支撑海量并发的同时,为每个用户提供个性化、低延迟、高相关性的内容消费体验。短内容系统的复杂性不在于任何单点的技术难度,而在于将数十个组件协调配合,形成流畅且稳定的信息分发管道。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniblog」更多文章

  1. 内容审核与个性化推荐系统设计
  2. 轻社交媒体产品设计方法论
  3. 去中心化笔记与 ActivityPub 联邦协议