引言
在分布式系统的演进长河中,读写分离与缓存优先始终是性能优化的核心命题。近日,智柴论坛完成了两轮重要的架构升级——API Token 使用统计与话题阅读基数的存储迁移。这不仅是技术实现的调整,更是一次对"最终一致性"与"实时性能"平衡的艺术实践。
---
一、改动全景
1.1 API Token 统计系统重构
涉及的统计字段:
use_count—— Token 使用次数last_used_at—— 最后使用时间
SQLite (异步队列写入)
↓
Redis Hash (实时读写)
核心代码演进:
// 改动前:异步队列写入 SQLite
$this->asyncWriter->enqueue('UPDATE', 'api_tokens', [
'last_used_at' => date('Y-m-d H:i:s'),
'use_count' => ['raw' => 'use_count + 1']
], ['token' => $token]);
// 改动后:直接操作 Redis Hash
$this->redis->hset($statsKey, 'last_used_at', $now);
$this->redis->hincrby($statsKey, 'use_count', 1);
$this->redis->expire($statsKey, self::CACHE_TTL);
Pipeline 优化: 对于管理后台的批量查询,采用 Redis Pipeline 将 N 次往返压缩为 1 次:
$pipe = $this->redis->multi(\Redis::PIPELINE);
foreach ($tokens as $token) {
$pipe->hgetall($this->getTokenStatsKey($token['token']));
}
$statsResults = $pipe->exec();
---
1.2 话题统计系统重构
涉及的统计字段:
view_count—— 话题浏览次数reply_count—— 话题回复数量
SQLite (view_count 字段 + 异步 INCREMENT)
↓
Redis Hash (topic:{id}:stats)
读写路径重构:
| 操作 | 改动前 | 改动后 |
|---|---|---|
| 增加浏览 | asyncWriter->enqueue('INCREMENT', 'topics', ...) | $this->redis->hincrby($statsKey, 'view_count', 1) |
| 创建回复 | 异步更新 + 缓存失效 | 同步 updateReplyCount(+1) |
| 删除回复 | 仅缓存失效 | 同步 updateReplyCount(-1) |
| 读取统计 | DataService → SQLite | $this->redis->hgetall($statsKey) |
// 7 天过期,避免冷数据占用内存
$this->redis->expire($statsKey, 604800); // 60 * 60 * 24 * 7
---
二、架构思考
2.1 为什么选择 Redis?
业务特性分析:
- 高频写入:话题浏览是典型的"写多读少"场景,每次页面访问都触发计数
- 允许短暂不一致:用户看到的浏览数延迟几秒更新,不影响业务逻辑
- 可容忍丢失:统计数据丢失后可以从 0 重新累计,不破坏核心功能
┌─────────────────────────────────────────────────────────┐
│ Redis Hash 的 O(1) 复杂度 vs SQLite 的磁盘 I/O │
├─────────────────────────────────────────────────────────┤
│ • hincrby: 内存操作,微秒级延迟 │
│ • UPDATE: 磁盘操作,毫秒级延迟(含队列等待) │
│ • 异步队列: 最终一致性窗口可能达秒级甚至分钟级 │
└─────────────────────────────────────────────────────────┘
2.2 一致性模型的权衡
改动前:最终一致性
用户浏览 → 入队 sqlite_write_queue → 后台进程消费 → SQLite 更新
↑ ↓
队列堆积时延迟增大 DataService 缓存回填
改动后:缓存一致性
用户浏览 → Redis hincrby → 立即生效
↓
7天后自动过期释放内存
---
三、性能收益分析
3.1 队列压力缓解
量化估算: 假设论坛日均 PV 为 10 万,话题页占 60%:
改动前:
- 每日入队操作:60,000 次 INCREMENT 任务
- 队列长度峰值:~500(高峰期堆积)
改动后:
- 每日入队操作:0 次(统计类任务完全移除)
- 队列仅保留核心数据写入(话题、回复内容等)
3.2 延迟降低
| 指标 | SQLite 异步 | Redis 实时 | 提升 |
|---|---|---|---|
| 统计更新延迟 | ~100ms(含队列) | ~1ms | 100x |
| 批量查询 (50条) | ~250ms | ~5ms (Pipeline) | 50x |
| 系统调用次数 | 2 次(Redis+SQLite) | 1 次(仅 Redis) | 50% |
3.3 过期策略的巧思
// Token 统计:24 小时过期(与 Token 缓存一致)
self::CACHE_TTL = 86400;
// 话题统计:7 天过期(平衡内存与冷启动)
604800 = 60 * 60 * 24 * 7;
设计考量:
- Token 统计:Token 本身 24 小时缓存,统计数据跟随过期
- 话题统计:7 天确保活跃话题统计持续,冷门话题自然释放
四、工程实践细节
4.1 键名设计规范
// ApiTokenService
'api_token:stats:' . $token
// TopicService
'topic:' . $topicId . ':stats'
统一遵循智柴论坛的键名规范:
- 逻辑键名不含前缀(由 RedisManager 的 OPT_PREFIX 统一管理)
- 使用冒号分隔层级,便于监控与排查
4.2 降级与容错
public function getTopicStats($topicId)
{
try {
$stats = $this->redis->hgetall($statsKey);
return [
'view_count' => (int)($stats['view_count'] ?? 0),
'reply_count' => (int)($stats['reply_count'] ?? 0)
];
} catch (\Exception $e) {
// Redis 故障时返回 0,不阻断业务
error_log("[TopicService] 获取统计失败: " . $e->getMessage());
return ['view_count' => 0, 'reply_count' => 0];
}
}
4.3 数据持久化的取舍
接受的数据丢失场景:
- Redis 重启:统计数据归零,重新累计
- 内存不足:LRU 淘汰冷门话题统计
- 主从切换:短暂数据不一致
- Token 基础信息(创建时间、所属用户)
- 话题内容、回复内容
- 用户账号信息
五、总结与展望
这两次改动代表了智柴论坛架构演进的一个方向:让高频次、可容忍丢失的统计数据回归内存,释放 SQLite 的写入带宽给核心数据。
核心收益: 1. 队列减负:sqlite_write_queue 的负载显著降低 2. 实时性提升:统计数据的延迟从"秒级"降至"毫秒级" 3. 资源优化:Redis 的内存使用通过 TTL 得到控制
未来的可能性:
- 更多统计字段的 Redis 化(如点赞数、分享数)
- Redis Stream 实现实时统计看板
- 基于 Redis 的滑动窗口限流
---
相关 Commit:
1702cferefactor(ApiTokenService): 优化 Token 使用统计存储方式689dd3drefactor(TopicService): 将话题阅读基数改为仅 Redis 存储