Bakkac's Blog

读与写:case-post-counter

2026/5/28

本文目录

上级:后端学习 from zero

读和写是分析 workload 的常见角度。计数逻辑尤其适合用这个角度看。设计复杂度应该由业务规模、一致性要求和访问模式决定,而不是默认按最高并发场景设计。

一个公开的、可以形成多对一写操作的接口,需要考虑并发;上了锁又要考虑性能;引入 Redis 后又要考虑异常处理和一致性。复杂度会一层层展开,所以懂业务是设计的前提。

读写特征

绝大多数业务是读远大于写,比如帖子正文、用户资料、配置、商品详情。

读多写少的优化重点:

数据库作为主存储
缓存整块业务对象
写入时更新数据库,再失效 / 更新缓存

计数比较特殊,比如点赞数、评论数、收藏数、浏览数、未读数。这类数据读写都频繁。

读多写多的优化重点:

  • 读要快,写也要快。
  • 高频写不能打爆数据库。
  • 热点对象不能被同一行锁拖死。

常见方案:

读:从 Redis 读最新计数
写:Redis 原子自增 / 自减
落库:异步、定时、批量同步到 DB
一致性:按场景选择强一致或最终一致

为什么要解耦 counter

如果帖子表长这样:

post(id, title, content, author_id, like_count, comment_count, collect_count)

点赞、评论、收藏这些高频写入都会打到 post 这个核心业务模型上。

问题包括:

  1. 帖子内容适合长缓存,但计数频繁变化,会导致帖子缓存经常失效。
  2. 热门帖子每次点赞都更新同一条 post 记录,形成热点行。
  3. 新增计数类型会不断污染帖子模型。

counter 模型

可以抽象成:

targetType: 目标类型,比如帖子、评论、用户
targetId: 目标 ID
counterType: 计数类型,比如 like/comment/collect
value: 数值

Redis key 可以类似:

counter:{targetType}:{targetId}

hash 里存:

like -> 10
comment -> 5
collect -> 3

批量查询也走 counter 模块,比如帖子列表展示 20 条帖子的点赞数,就用 batchGetCounter,避免每个帖子单独查。

同步与一致性

Redis 和数据库同步有几种方式:

  1. Redis 和数据库立刻双写。
  2. Redis 更新后,后台异步落库。
  3. Redis 更新后,通过定时任务同步数据库。

取舍:

一致性 / 可靠性:1 > 2 > 3
性能 / 简单性:3 > 2 > 1

强一致:Redis 做缓存

强一致要求事务化设计。此时 Redis 最多做缓存,不适合作为唯一权威数据源。

适合强一致的例子:

  • 用户余额。
  • 库存只剩 1 件。
  • 扣款流水。

强一致写入优化的重点不是把 Redis 当主存储,而是:

  1. 缩短事务时间。
  2. 减少锁范围。
  3. 用乐观锁减少锁等待。
  4. 用 Redis 做预筛,不做最终裁决。
  5. 用幂等设计防止重试导致重复生效。
  6. 把流程拆成状态机,例如先冻结、后确认。

幂等可以理解成:

给每一次业务操作发一个身份证,数据库用唯一约束保证同一身份证只能生效一次。

弱一致:Redis 做数据源

如果 Redis 做主存储,一致性难点在于:

Redis 已成功,但 DB 还没成功

经典方案是 Redis + MQ + DB:

请求
-> Redis 原子操作
-> 事务消息 / reliable MQ
-> 异步落库

关键注意点:

  1. 事件最好存 delta,而不是只存最终值。
  2. set 最终值需要版本号防乱序。
  3. Redis 成功、发 MQ 失败要有兜底。
  4. 消息可能重复消费,所以消费者要幂等。
  5. 需要失败重试、死信队列、消费进度、监控堆积告警。
  6. 即使做了 MQ,也要默认会有偏差,所以需要对账。

一次计数变化在 Redis、消息队列和数据库之间流转,任何一步都可能失败、重复、乱序或延迟。事件最好记录 delta 而不是最终值:增量可以聚合,最终值则需要版本号避免旧值覆盖新值。

写请求
-> Lua:HINCRBY counter:{targetType}:{targetId} field delta
-> 同一脚本写入 eventId 和 delta 到 Redis Stream
-> 立即返回

消费者
-> 拉取事件并按目标聚合
-> 批量 UPDATE DB
-> 成功 ack,失败重试或进入死信

兜底任务
-> 定时对比 Redis 与 DB
-> 修正长期不一致

Redis 成功但消息投递失败时,需要可靠消息、Outbox、Redis Stream 或定时扫描等兜底;消费者默认会重复消费,因此要用 eventId 或业务操作流水做幂等。

强一致与弱一致

余额、扣款和只剩一件的库存需要强一致,Redis 最多作为预筛或读缓存,最终裁决必须在事务和数据库约束中完成。同一资源的冲突修改不可能无限并发,最终必须在某个地方串行化。

未读数、点赞数这类允许短暂延迟但不能长期丢失的数据,可以接受最终一致;普通浏览数通常可以接受更弱的一致性。选择模型时先问“短暂显示旧值能否接受”和“少一次是否可修复”,再决定是否引入 MQ、对账和持久化。

强一致写入的优化方向是缩短事务、减少锁范围、用乐观锁减少无效等待、拆分热点资源、用操作 ID 做幂等。事务内不要做远程调用、慢日志和复杂计算,通知、发消息和刷新缓存尽量放到提交之后。

一个简化链路:

写请求
-> Lua 脚本:
   HINCRBY counter:{targetType}:{targetId} {counterType} delta
   XADD counter_events eventId targetType targetId counterType delta
-> 立即返回

消费者
-> 从 Redis Stream / MQ 拉事件
-> 按 targetType + targetId + counterType 聚合 delta
-> 每隔 N 秒批量 UPDATE DB
-> 成功后 ack
-> 失败则重试 / 进死信

兜底任务
-> 定时对比 Redis 和 DB
-> 修正不一致数据

是否过度设计

拆 counter 不是必然正确,它是用复杂度换可扩展性和性能空间。

可以分几个层次:

  1. post 表里直接放 like_count/comment_count。
  2. 单独 counter 表 + counter 模块。
  3. 独立 counter 服务 / 中间件化计数系统。

普通后端项目、流量不大、计数类型也不多时,做到第 2 层已经可能偏提前设计;第 3 层通常过度。

如果是社交或内容流系统,帖子列表、评论、点赞、收藏都会频繁发生,并且未来多个对象都需要计数,抽一个通用 counter 模块就是合理的,因为它能复用缓存、增量、批量查询、同步和对账逻辑。否则单独 counter 可能已经是提前设计,独立 counter 服务通常更是过度。