如何用Redis给系统加上访问计数?—— 从入门到高并发实战
📖 文章导读
- 为什么选择Redis来计数? —— 传统数据库 vs Redis的对比
- Redis计数的核心原理 —— INCR命令与原子性
- 从零实现访问计数器 —— 基础代码示例(Python / Node.js)
- 高并发下的进阶方案 —— 避免热点Key、Pipeline、Lua脚本
- 常见问题与陷阱 —— 数据丢失、过期策略、一致性
- 问答环节 —— 解决你最关心的5个问题
为什么选择Redis来计数?
在系统设计中,访问计数是最常见的需求之一,你可能需要统计:页面PV(Page View)、用户点赞数、文章阅读量、API调用次数。

传统做法是使用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:计数器被刷怎么办?
- 限制频率:结合
INCR和EXPIRE,限制同一个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)。