本文目录
上级:后端学习 from zero
读和写是分析 workload 的常见角度。计数逻辑尤其适合用这个角度看。设计复杂度应该由业务规模、一致性要求和访问模式决定,而不是默认按最高并发场景设计。
一个公开的、可以形成多对一写操作的接口,需要考虑并发;上了锁又要考虑性能;引入 Redis 后又要考虑异常处理和一致性。复杂度会一层层展开,所以懂业务是设计的前提。
读写特征
绝大多数业务是读远大于写,比如帖子正文、用户资料、配置、商品详情。
读多写少的优化重点:
数据库作为主存储
缓存整块业务对象
写入时更新数据库,再失效 / 更新缓存
计数比较特殊,比如点赞数、评论数、收藏数、浏览数、未读数。这类数据读写都频繁。
读多写多的优化重点:
- 读要快,写也要快。
- 高频写不能打爆数据库。
- 热点对象不能被同一行锁拖死。
常见方案:
读:从 Redis 读最新计数
写:Redis 原子自增 / 自减
落库:异步、定时、批量同步到 DB
一致性:按场景选择强一致或最终一致
为什么要解耦 counter
如果帖子表长这样:
post(id, title, content, author_id, like_count, comment_count, collect_count)
点赞、评论、收藏这些高频写入都会打到 post 这个核心业务模型上。
问题包括:
- 帖子内容适合长缓存,但计数频繁变化,会导致帖子缓存经常失效。
- 热门帖子每次点赞都更新同一条
post记录,形成热点行。 - 新增计数类型会不断污染帖子模型。
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 和数据库同步有几种方式:
- Redis 和数据库立刻双写。
- Redis 更新后,后台异步落库。
- Redis 更新后,通过定时任务同步数据库。
取舍:
一致性 / 可靠性:1 > 2 > 3
性能 / 简单性:3 > 2 > 1
强一致:Redis 做缓存
强一致要求事务化设计。此时 Redis 最多做缓存,不适合作为唯一权威数据源。
适合强一致的例子:
- 用户余额。
- 库存只剩 1 件。
- 扣款流水。
强一致写入优化的重点不是把 Redis 当主存储,而是:
- 缩短事务时间。
- 减少锁范围。
- 用乐观锁减少锁等待。
- 用 Redis 做预筛,不做最终裁决。
- 用幂等设计防止重试导致重复生效。
- 把流程拆成状态机,例如先冻结、后确认。
幂等可以理解成:
给每一次业务操作发一个身份证,数据库用唯一约束保证同一身份证只能生效一次。
弱一致:Redis 做数据源
如果 Redis 做主存储,一致性难点在于:
Redis 已成功,但 DB 还没成功
经典方案是 Redis + MQ + DB:
请求
-> Redis 原子操作
-> 事务消息 / reliable MQ
-> 异步落库
关键注意点:
- 事件最好存 delta,而不是只存最终值。
- set 最终值需要版本号防乱序。
- Redis 成功、发 MQ 失败要有兜底。
- 消息可能重复消费,所以消费者要幂等。
- 需要失败重试、死信队列、消费进度、监控堆积告警。
- 即使做了 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 不是必然正确,它是用复杂度换可扩展性和性能空间。
可以分几个层次:
post表里直接放like_count/comment_count。- 单独 counter 表 + counter 模块。
- 独立 counter 服务 / 中间件化计数系统。
普通后端项目、流量不大、计数类型也不多时,做到第 2 层已经可能偏提前设计;第 3 层通常过度。
如果是社交或内容流系统,帖子列表、评论、点赞、收藏都会频繁发生,并且未来多个对象都需要计数,抽一个通用 counter 模块就是合理的,因为它能复用缓存、增量、批量查询、同步和对账逻辑。否则单独 counter 可能已经是提前设计,独立 counter 服务通常更是过度。