如何用Redis给系统加上访问计数?

wen java案例 74

如何用Redis给系统加上访问计数?—— 从入门到高并发实战

📖 文章导读

  • 为什么选择Redis来计数? —— 传统数据库 vs Redis的对比
  • Redis计数的核心原理 —— INCR命令与原子性
  • 从零实现访问计数器 —— 基础代码示例(Python / Node.js)
  • 高并发下的进阶方案 —— 避免热点Key、Pipeline、Lua脚本
  • 常见问题与陷阱 —— 数据丢失、过期策略、一致性
  • 问答环节 —— 解决你最关心的5个问题

为什么选择Redis来计数?

在系统设计中,访问计数是最常见的需求之一,你可能需要统计:页面PV(Page View)、用户点赞数、文章阅读量、API调用次数。

如何用Redis给系统加上访问计数?

传统做法是使用MySQL等关系型数据库,但问题很明显:

  • 写入频繁:每次访问都要写一次数据库,高并发下磁盘I/O成为瓶颈。
  • 行锁竞争:多用户同时更新同一行,会导致锁等待甚至死锁。
  • 响应变慢:计数操作本身不应阻塞主业务,但数据库写入容易拖慢接口。

而Redis基于内存,单线程模型配合原子命令(INCR),每秒可处理数十万次写入,天然适合高频计数场景。

核心差异:数据库写入是“持久化+事务”,Redis计数器是“内存+原子操作”。


Redis计数的核心原理

Redis提供了一个非常简单的命令:INCR key

  • 如果key不存在,则初始化为0后再加1。
  • 每次执行INCR,返回值就是加1后的结果。
  • 该操作是原子性的,不会有并发冲突。
# 示例
> SET article:1:views 0
OK
> INCR article:1:views
(integer) 1
> INCR article:1:views
(integer) 2

为什么INCR是线程安全的?

Redis是单线程处理命令,即使是成千上万个客户端同时发送INCR,也会在队列中顺序执行,不会出现“读取-修改-写入”的race condition。


从零实现访问计数器

下面我们用Python(使用redis-py库)和Node.js(使用ioredis)演示一个文章阅读计数器。

Python 示例

import redis
r = redis.Redis(host='localhost', port=6379, db=0)
def increment_view(article_id):
    key = f"article:{article_id}:views"
    views = r.incr(key)
    # 可选:设置过期时间(例如7天后重置)
    r.expire(key, 60 * 60 * 24 * 7)
    return views
# 使用
print(increment_view(1001))  # 输出 1, 2, 3...

Node.js 示例

const Redis = require('ioredis');
const redis = new Redis();
async function incrementView(articleId) {
    const key = `article:${articleId}:views`;
    const views = await redis.incr(key);
    // 设置过期时间
    await redis.expire(key, 60 * 60 * 24 * 7);
    return views;
}
incrementView(1001).then(console.log);

关键点

  • Key命名规范:建议使用对象:ID:属性的格式,便于管理和排查。
  • 过期时间:如果计数无需永久保留,设置过期时间可以防止内存无限增长。
  • 读与写分离:计数写入通过INCR,读取直接用GET,不要在每次显示时再DB查询。

高并发下的进阶方案

当系统流量巨大(例如秒杀、直播、热点文章),简单的INCR就可能遇到以下问题:

1 热点Key问题

某个文章的阅读量突然爆炸,导致单个Redis节点CPU飙升。

解决方案

  • 本地计数+异步写入:在应用内存中先累计100次,再批量INCR 100到Redis。
  • Redis集群分片:对Key做hash,分散到不同节点。

2 Pipeline批量操作

如果一次需要更新多个计数器,不要用多次网络请求:

pipe = r.pipeline()
pipe.incr('article:1:views')
pipe.incr('article:2:views')
pipe.incr('article:3:views')
pipe.execute()

Pipeline将多次命令打包一次发送,减少RTT(往返时间)。

3 Lua脚本实现原子读-改-写

如果计数逻辑更复杂(先查是否超过上限,再+1),直接使用Lua脚本保证原子性:

-- Lua脚本:仅当当前值<10000时,才+1
local cur = redis.call('GET', KEYS[1])
if not cur or tonumber(cur) < 10000 then
    return redis.call('INCR', KEYS[1])
else
    return -1
end

在Python中调用:

script = """
local cur = redis.call('GET', KEYS[1])
if not cur or tonumber(cur) < 10000 then
    return redis.call('INCR', KEYS[1])
else
    return -1
end
"""
result = r.eval(script, 1, 'article:1:views')

常见问题与陷阱

❓ Q1:Redis宕机了,计数丢失怎么办?

  • 方案A:Redis开启AOF持久化(每秒钟同步一次),最多丢失1秒数据。
  • 方案B:结合MySQL做最终一致性,先Redis计数,后台定时将Redis数据同步到MySQL(例如每5分钟批量写一次)。
  • 注意:纯内存计数永远不是100%可靠,需要根据业务容忍度选择方案。

❓ Q2:Key过期后,计数归零?

默认INCR对不存在的Key会自动创建并赋值为0,1,如果你希望过期后依然保留最后一次值,可以考虑:

  • 设置一个永不过期的“总计数Key”
  • 配合一个过期时间较短的“今日计数Key”做日报。

❓ Q3:计数器被刷怎么办?

  • 限制频率:结合INCREXPIRE,限制同一个IP或用户每秒最多计数一次。
  • 引入CAPTCHA:对可疑的刷量行为进行验证。

❓ Q4:如何实时获取所有文章的排行?

  • 使用Redis有序集合(Sorted Set):ZINCRBY article:rank 1 article:1001
  • 查询Top N:ZREVRANGE article:rank 0 9 WITHSCORES

问答环节

Q:为什么不用Memcached来计数?

A:Memcached也支持incr,但它不支持持久化、数据结构更少(如没有Sorted Set),且原子操作能力小于Redis,Redis在计数场景下功能更丰富。

Q:计数操作应该放在业务代码中还是中间件中?

A:建议放在业务代码中,通过封装一个独立的计数服务(例如CounterService),方便后续切换存储或做限流。

Q:如何确保计数不超卖(如点赞)?

A:先用Redis INCR判断返回值是否超过上限,若超值则回滚,更严格的做法是用Lua脚本或分布式锁保证。

Q:大量Key的过期策略如何影响性能?

A:Redis每秒会随机检查20个过期Key,如果Key数量巨大且同时过期,可能导致CPU波动,建议设置随机过期时间(基础时间+随机偏移)。

Q:计数器的数据最终要落盘到数据库吗?

A:视场景而定,纯展示型计数(如文章阅读量)直接读Redis即可;需要持久化分析时,可以每小时用DUMP或脚本批量同步到MySQL。


用Redis实现访问计数,核心就是 原子INCR + 合理的数据结构 + 持久化策略,从单机INCR到高可用的集群+异步写入,每一步都是根据系统压力逐步迭代的,没有最好的方案,只有最合适的方案。

如果你正在搭建一个新的服务,我推荐:先用简单的INCR+AOF持久化走通流程,等流量上来后再引入Pipeline、Lua脚本和集群分片,千万不要一开始就追求完美,过度设计反而拖慢开发速度。

最后提醒:计数器Key的命名避免使用空格或特殊符号,建议统一采用业务:ID:属性格式,并定期清理过期Key(可使用Redis的SCAN命令配合UNLINK)。

抱歉,评论功能暂时关闭!