同一个赞只能算一次:点赞系统为什么要把状态和计数拆开

点赞看起来只是把页面上的数字加一,后端却同时维护着两种性质完全不同的数据:谁赞了谁这条必须精确、可去重的关系,以及一个可以从关系重算出来的总数。把两者混在一条 UPDATE 里,双击、重试和消息重投都会让数字多加一次,热点内容还会把同一行锁排队。本文从唯一键、原子增减与缓存分层讲起,说明幂等到底由什么保证,为什么 Redis 计数不能当账本,以及分桶、异步落库和对账各自该在什么时候上场。读完你能判断一次点赞该写哪张表、计数放在哪一层,也能说清 HyperLogLog 为什么不能拿来当点赞数。

是什么

点赞是用户对某个目标的一次表态。工程上它同时产生两份数据:一份是"谁赞了谁"的关系,一份是"这个目标一共被赞了多少次"的计数。前者是事实,后者是事实的报表。把这句话当成设计起点,很多问题会立刻变得清楚:事实必须精确、可去重、可追溯;报表可以从事实重算,因此允许缓存、允许异步、允许短暂偏差。

一句话直觉:点赞像电影院检票口的花名册,一个人只能登记一次;门口的计数器只是把人数显示出来,坏了可以照花名册重数,但花名册不能反过来靠计数器修补。

典型场景包括文章与视频点赞、评论点赞、收藏、投票、关注数、未读红点。它们的共同点是读多写多、同一目标容易形成热点、客户端会重试、用户会连点,运营还会关心刷量。这些特征决定了点赞不是一个简单的字段自增,而是一次"状态写入 + 聚合计数的派生"。

核心原理

幂等来自约束,不来自判断

最直觉的写法是先查"这个用户赞过没有",没有就插入并把计数加一。这个顺序在并发下会失效:两个请求同时查到"没赞过",于是都插入、都加一。把判断和写入拆成两步,中间就有一个可以被插队的窗口。

正确的做法是把"同一对 (user_id, target_id) 只能有一条记录"交给数据库的唯一约束,再根据这次写入是否真的改变了行数来决定要不要动计数。关系表是账本,计数只是账本的汇总。

def like(conn, rds, uid, tid):
    with conn.cursor() as cur:
        cur.execute(
            "INSERT INTO likes(user_id, target_id) VALUES(%s, %s) "
            "ON CONFLICT (user_id, target_id) DO NOTHING", (uid, tid))
        fresh = cur.rowcount == 1          # 唯一键保证同一对只成功一次
        if fresh:
            cur.execute(
                "UPDATE targets SET like_count = like_count + 1 WHERE id = %s", (tid,))
    conn.commit()
    if fresh:
        try:
            rds.hincrby("like:count", str(tid), 1)   # 缓存失败可由关系表重建
        except Exception:
            pass
    return fresh

这段代码适合单库、写入量还可控的阶段:关系与计数在同一个事务里,简单且强一致。它有两个关键点。第一,fresh 由数据库的受影响行数给出,重试、双击、消息重投都不会让它变成两次。第二,like_count = like_count + 1 本身是原子的,但原子不等于幂等:同一条 UPDATE 执行两次,计数就是加二。取消点赞同理,只有 DELETE 真的删掉一行,才允许减一;重复取消如果照样 DECR,计数会一路漂到负数。等到热门目标的计数行开始排队,就要把计数从这条事务里挪出去。

热点在计数行上,不在关系表上

关系表的写入是分散的:不同用户赞同一目标,插入的是不同主键,冲突判断走唯一索引,成本可控。真正的问题是计数:热门内容的所有点赞都要更新同一行。MySQL 的 InnoDB 会拿行锁让这些更新排队,PostgreSQL 的行级锁也是同一回事;每一次更新还要写日志、改数据页。QPS 一高,这一行就是串行瓶颈,而不是"数据库慢"这么笼统。

常见的分层做法是:

  1. 在线计数放 Redis:HINCRBY 对单个 key 是原子操作,O(1),把绝大多数读请求挡在数据库外。读总数直接读 Redis,miss 时回源基线并回填。
  2. 数据库存基线,变更走 outbox:点赞事务只写关系表和一条变更事件,由消费者把 +1/-1 合并进 targets.like_count。事件带唯一 ID,重复投递不会重复加。
  3. 定期对账:用 SELECT target_id, COUNT(*) FROM likes GROUP BY target_id 重算,和缓存、基线比对,超出阈值就修正并告警。

必须强调:Redis 是加速层,不是账本。默认配置下,它的持久化不是每次写完都落盘,主从切换也可能丢掉最后一段增量;一旦把 Redis 当成唯一真相,计数就会在故障后永久偏差。只要关系表还在,计数永远可以重算,这才是这套设计能成立的前提。

单 key 再快,也只是一个分片

Redis Cluster 里一个 key 只落在一个槽位、一个节点上。热门目标的计数 key 再原子,吞吐上限也受那一个节点约束。解决思路是分桶:把同一个目标的计数拆成 N 份,按 hash(user_id) % N 选桶,写入分散到 N 个 key,读取时把这 N 个值求和。

代价同样明显:读一次计数要发 N 次请求,通常用 pipeline 合并;桶数确定后扩容要迁移;求和结果在并发写入下会有短暂的不一致。所以桶数不是越大越好,按峰值写入和可接受的读放大来定,常见是 8 到 64 这个量级。

缓存与数据库的顺序

计数缓存适合 Cache-Aside:读时先查缓存,miss 回源数据库并回填;写时先落数据库,再删除缓存,而不是更新缓存。删除失败就靠 TTL 和下一次回源兜底,不要为了"强一致"在业务事务里同时改两套存储,那会把数据库事务的失败域扩大到缓存。

对点赞这个场景,更实用的分工是:关系强一致,计数最终一致。用户点完赞,自己的"已赞"状态必须立刻正确,因为它决定按钮长什么样;总数晚几百毫秒、甚至晚几秒,产品上通常可以接受。把这两件事混成同一个一致性要求,才会逼出各种昂贵的分布式事务。

常见考点与误区

  • 误区一:"用了 Redis 的 INCR 就幂等":INCR 只保证一次命令原子执行,不保证同一个业务动作只执行一次。重试两次就是加两次,幂等必须靠唯一键、去重表或幂等 token。
  • 误区二:"先查再插,加个分布式锁就行":锁能挡住一部分并发,但成本高、失效场景多;唯一索引是数据库免费的、跨进程的、故障后仍然成立的约束,应该优先用它。
  • 误区三:"计数就该跟点赞在同一个事务里":同一事务确实最一致,但热点行会把事务拖长,锁持有时间变大,吞吐骤降。更常见的是事务内写关系和 outbox 事件,计数异步合并,用对账兜底。
  • 误区四:"Redis 丢了,从 AOF 恢复就行":AOF 的刷盘策略和主从切换都可能丢最后一段写入,恢复出来的是"接近正确"而不是"精确"。计数必须能从关系表重算。
  • 考点五:"HyperLogLog 能不能统计点赞数":不能。它用固定约 12 KB 估算基数,标准误差约 0.81%,只能给出近似值,不能精确到个位、不能删除单个元素、也无法回答"某人赞过没有"。它适合 UV 这类可容忍误差的去重计数。
  • 考点六:"点赞列表和总数为什么对不上":两者读的是不同存储、不同时刻。列表按 (created_at, user_id) 游标分页查关系表,总数读缓存,短暂差异正常;长期差异说明对账或消费链路有问题,不是分页写错了。

实践建议

  • 表结构先定对:likes 用 (user_id, target_id) 做联合主键或唯一索引,再建 (target_id, created_at) 支撑"谁赞了这条"的倒序分页;取消赞用物理删除,需要保留历史就加状态位,把唯一约束改成只对有效行生效的部分唯一索引,别在应用层做软删除判断。
  • 计数分层,别只留一层:Redis 承担在线读与原子增减,数据库存基线,变更日志保证不丢,日对账保证不漂。四者缺一个,故障时就得靠人工修数。
  • 热点单独治理:大 V 或爆款目标的计数走分桶或独立实例,本地缓存加几百毫秒到几秒的 TTL,回源用 singleflight 合并,避免缓存失效瞬间打穿。
  • Redis 内存策略要当心:计数 key 不能被 allkeys-lru 随意淘汰,否则读到的"总数"会凭空变小;给计数单独实例或至少单独库,并设置告警而不是静默驱逐。
  • 防刷和幂等分开做:唯一键解决重复提交,限流和风控解决恶意刷量;两者不能互相替代,前者保证账本不重,后者保证账本不被灌水。
  • 观测四个数:点赞请求量、唯一插入率、计数更新失败率、对账差异。唯一插入率突然大幅下降,往往是客户端重试或脚本刷量在制造重复请求;差异持续扩大则说明异步链路堵了。

一句话总结:点赞是只能发生一次的事实,计数是可以重算的报表;把两者分开,幂等、热点和一致性才各有各的解法。

相关阅读

← 返回文章志