首页 / 资讯中心 / 文章详情

用Redis手写高并发计数器:从缓存原理到原子操作实战

用Redis手写高并发计数器:从缓存原理到原子操作实战 ★ FEATURED ARTICLE
第一次接触“缓存”这个词的时候我脑子里冒出来的画面就是一个仓库先把东西存进去用的时候直接拿比现做现找快得多。后来真正把 Redis 用在生产环境里才发现这个类比太粗糙了——一个合格的缓存系统要考虑的远不止“存和取”它还得扛得住高并发、防得住数据丢失、管得好过期时间。这篇东西我就用最直白的方式从“缓存到底是什么”讲起一路讲到怎么用 Redis 手写一个能应对高并发场景的计数器。适合谁看没碰过 Redis 的初学者、被面试官问过“高并发怎么处理”的朋友、以及想自己动手验证一下理论的开发者。我不太喜欢那种把概念讲得云里雾里的教程所以全文会尽量用生活化的例子配合可以直接抄走的命令和代码。你跟着走一遍不仅能跑通还能明白每一步背后的原因。1. 先搞清楚缓存到底在解决什么问题1.1 缓存的本质把“慢操作”变成“快操作”很多人把缓存理解成“临时存数据的地方”这话没错但没说到根子上。缓存的本质是让高频访问的数据离使用者更近用空间换时间。打个比方。你每天中午都要去一家很远的面馆吃饭来回一小时。如果面馆在你公司楼下开个分店你花五分钟就能吃上。这个分店就是缓存。它不会改变面馆总店的菜品只是让最常被点的几道菜在离你更近的地方准备好。放到计算机系统里最典型的速度差距是内存和磁盘。内存的读写速度是纳秒级到微秒级磁盘是毫秒级差距有上千倍。如果你的业务数据每次都从数据库里查而数据库又得从磁盘上捞数据那么在高并发访问下数据库很快就会成为瓶颈。Redis 把数据放在内存里读写速度自然快几个数量级。但缓存也不是万能的。它解决的是“读多写少”的场景如果数据每秒都在变缓存的价值就会大打折扣。所以设计缓存方案时第一步永远是判断业务场景适不适合用缓存。1.2 为什么偏偏是 Redis市面上带缓存能力的中间件不少Memcached、LocalCache、Ehcache 都有各自的位置。但 Redis 能成为事实标准靠的是几样硬实力。首先是数据结构丰富。普通的缓存系统往往只能存字符串value 就是一大串文本。Redis 不一样它有五种基础数据类型String、Hash、List、Set、ZSet有序集合。这意味着你可以直接用它实现排行榜、好友关系、消息队列、去重统计这类业务逻辑而不只是当个 KV 存储。其次是原子操作。Redis 是单线程执行命令的这个特性看起来像个劣势实际上在计数器这种场景下是个大优势——多个客户端同时执行 INCR 命令时Redis 会天然串行化处理不会出现并发覆盖的问题。后面写高并发计数器靠的就是这个特性。再有就是持久化。很多人以为 Redis 只是“内存数据库”重启就丢数据。其实 Redis 提供了 RDB 快照和 AOF 日志两种持久化机制可以按业务需求配置。生产环境里Redis 完全可以做到既不丢失数据又保持高性能。1.3 你身边的缓存场景说几个生活中能对上的例子你就知道缓存有多普遍。你刷短视频首页推荐列表为什么每次都加载那么快因为热门的视频数据提前被缓存到了 Redis 里不需要每时每刻都去查数据库。你网购下单秒杀商品库存为什么能扛住几万人的同时点击因为库存数量就是放在 Redis 里的计数器靠原子操作保证不超卖。你的登录状态为什么能跨接口共享因为 session 被存在 Redis 里而不是存在每一台应用服务器的本地内存中。这些场景背后Redis 都在扮演那个“离使用者更近的分店”角色。2. 零基础装好 Redis 并跑通第一个命令2.1 安装与启动三种操作系统一次说清动手之前先把环境装好。我按最常见的三种环境分别说一下。Windows 用户需要注意Redis 官方并不提供 Windows 版本但微软和第三方社区维护过移植版本。你可以在 Redis 的 GitHub 仓库里找到适合 Windows 的发行包下载解压后直接运行redis-server.exe就能启动服务。现在也有不少开发者选择在 Windows 上用 Docker 跑 Redis这样更接近生产环境。具体做法后面会提到。macOS 用户最简单用 Homebrew 一条命令搞定brew install redis启动服务redis-serverLinux 用户也一样Ubuntu 和 Debian 系用 aptsudo apt update sudo apt install redis-server装完后执行redis-cli ping如果返回PONG说明服务已经正常运行。这个PONG就是 Redis 在告诉你我在线随时能干活。生产环境不建议直接用默认配置裸奔。至少要设置密码、绑定地址、调整持久化策略这三个地方我在后面的排查章节里会展开讲。2.2 五种数据类型看一遍就能记住Redis 的核心是它的数据类型。我不打算抄文档用一句话外加一个例子说明每种类型适合干什么。String 是字符串也是计数器的基础。SET page_view 100然后INCR page_view数字就变成了 101。这个INCR是原子操作后面写高并发计数器时它就是主角。Hash 是键值对集合适合存对象。比如一个用户信息HSET user:1001 name 张三 age 18。读取某个字段用HGET user:1001 name整个对象用HGETALL。List 是列表从两端插入弹出。适合做消息队列的简单版本。LPUSH task_queue task1往左边塞任务RPOP task_queue从右边取任务。先进先出队列模式就出来了。Set 是无序集合不允许重复元素。适合做去重。比如统计某篇文章的独立访客每次访问用SADD article:123 visitor_002然后用SCARD article:123看有多少人访问过。ZSet 是有序集合每个元素带一个分数按分数排序。排行榜就是它的典型场景ZADD leaderboard 999 user_01然后ZREVRANGE leaderboard 0 9取出前十名。记住选类型不是凭喜好而是看你要做什么操作。要排序选 ZSet要去重选 Set要存对象选 Hash要计数器选 String。2.3 可视化工具连接工具怎么选纯命令行的redis-cli能完成所有操作但可视化工具在排查数据时方便得多。我用过的工具里Another Redis Desktop Manager 和 RedisInsight 是两个主流的免费选项。前者界面简洁跨平台支持好能直接看到所有 key、查看 value 类型、执行命令行还能批量删除 key。后者是 Redis 官方出品的工具带内存分析、性能监控这些高级功能适合想深入分析的人。连接配置很简单填上 Redis 的 IP、端口默认 6379如果设置了密码就在“认证”区域填上密码。第一次连接时如果提示失败先检查 redis.conf 里的bind配置默认只监听 127.0.0.1远程访问需要改成实际 IP 或注释掉该配置项。3. 高并发计数器的设计思路3.1 计数器业务场景分析回来看我们的目标自己动手写一个高并发计数器。要设计它先得明确业务场景。最典型的场景是商品浏览量统计。一个电商网站用户每点一次商品详情页浏览量加一。平时一天几千次访问Redis 完全没压力。但如果是秒杀活动瞬间涌入几万人每个请求都要“读-加一-写回”这一步就变成了整个系统的瓶颈。还有一个更严苛的场景是限流。比如你写一个接口要求每个用户每分钟最多调用 30 次。这种情况下计数器的写入频率是极高的而且不能出错——多计数一次就可能误杀正常用户少计数一次又达不到限流目的。这两种场景的共同点是写入频繁、对实时性要求高、不能丢失数据。它们都不适合直接操作数据库。3.2 为什么不用数据库做计数器有同学可能会问我直接在 MySQL 里建一张表每次访问UPDATE count SET value value 1 WHERE id 1不也能实现计数器吗能但在高并发下会出问题。先看加锁问题。数据库的UPDATE语句会锁行多个请求同时更新同一行时事务就得串行排队。秒杀场景下一万个人同时访问这一万次更新全排着队数据库的响应时间会直线上升。再看性能问题。每一次计数都需要走一遍“网络连接—SQL 解析—事务提交”的完整流程单机 MySQL 一般只能承受几千 QPS。而 Redis 的INCR命令是纯内存操作单实例轻松就能跑到几万甚至十万 QPS差距非常明显。最后看连接资源。数据库连接数是有限且昂贵的。每个数据库进程能维持的连接通常只有几百到几千个在高并发下连接池很容易被打满后面的请求只能排队等连接释放。Redis 的连接非常轻量哪怕临时扛不住的请求也会快速失败不会像数据库那样把整条链路堵死。3.3 计数器的技术方案选型对比我列一个对比表方便你直观感受不同方案的差异。方案实现方式性能瓶颈适用场景数据库计数器UPDATE 语句行锁 磁盘IO低频写入适合做最终台账本地内存计数JVM 变量 ConcurrentHashMap单机内存写竞争单实例应用重启丢失Redis 计数器INCR 原子操作网络IO高频写入适合分布式场景本地内存的方案看起来性能最高因为它连网络开销都没有。但问题在于如果应用部署了多个实例每个实例的计数器是独立的数据加不到一起去。而且服务重启后本地变量清零根本无法做持久化。Redis 把计数这件事集中到一处所有应用实例共享同一个计数器天然就是分布式的。加上INCR是原子操作多个客户端同时执行也不会出现“写覆盖”问题。这正是选择题的标准答案。4. 动手实现从准备环境到完成一个高并发计数器4.1 开发环境准备Java Spring Boot Redis我选择用 Java Spring Boot 来做演示原因是这套组合在面试和实际工作中出现频率最高。你如果更喜欢其他语言思路完全一样Redis 协议是跨语言的。先在项目里引入依赖。用 Maven 的话在pom.xml中添加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后在application.yml中配置 Redis 连接信息spring: data: redis: host: 127.0.0.1 port: 6379 password: lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4这里用的是 Spring Boot 3.x 的配置路径如果还在用 2.x前缀是spring.redis注意区分。连接池配置里max-active是最大连接数建议根据应用并发线程数调整不是越大越好连接数过多反而会浪费资源。4.2 核心代码RedisTemplate 实现计数器Spring Boot 里操作 Redis最常用的是RedisTemplate。先写一个简单的控制器来测试计数器。RestController RequestMapping(/counter) public class CounterController { Autowired private RedisTemplateString, String redisTemplate; private static final String COUNTER_KEY article:view_count; GetMapping(/increment) public Long increment() { return redisTemplate.opsForValue().increment(COUNTER_KEY); } GetMapping(/get) public String get() { return String.valueOf(redisTemplate.opsForValue().get(COUNTER_KEY)); } }启动项目后请求/counter/increment一次返回 1再请求一次返回 2请求/counter/get查看当前值。到这里一个最基础的计数器已经能跑了。opsForValue().increment()对应的是 Redis 的INCR命令。这个命令在执行时是对 key 做原子自增整个过程不可分割。也就是说哪怕有一万个请求同时打到服务器上Redis 也会一个一个处理最终拿到多少就是多少。4.3 INCR 原子操作的原理拆解原子性是整个计数器设计的核心值得展开讲一下。Redis 的命令执行是单线程模型。所谓单线程指的并不是 Redis 进程只有一个线程而是处理客户端命令的那个事件循环只有一个线程。所有命令进入 Redis 后会按顺序排队执行同一时间只会有一个命令正在执行。这意味着INCR key这条命令在执行期间不会有其他命令插入进来。无论是读当前值、加一、写回这三步是被打包在一起连续完成的。对照一下并发环境下的普通逻辑读取当前值、计算新值、写回新值三步之间如果被别的线程插入操作就会发生丢失更新。Redis 的原子操作直接避免了这个问题。这个特性带来的好处非常明显你不用自己加锁不用担心多实例竞争Redis 天然帮你把并发安全做掉了。4.4 持久化配置重启不丢计数如果计数器重启就清零很多场景就不可用了。 Redis 提供两种持久化方案。RDB 是快照方式默认开启。它会在指定时间点生成整个数据集的快照保存到磁盘。配置在redis.conf里save 900 1 save 300 10 save 60 10000意思是 900 秒内至少有 1 次写操作就保存一次快照300 秒内 10 次写操作保存60 秒内 10000 次保存。RDB 的优点是恢复速度快、文件体积小缺点是快照之间的数据可能丢失。AOF 是日志追加方式每次写命令都会追加到日志文件。配置成appendfsync everysec时最多丢失一秒的数据适合计数器这种需要精确度的场景。生产环境的最佳实践是两者同时开启RDB 负责快速恢复AOF 负责把数据丢失窗口压到最小。4.5 性能测试用压测工具验证效果写完代码得验证指标。我用 JMeter 简单压测过这个接口。方式是创建线程组设置 100 个线程循环 100 次总共 10000 个请求打向/counter/increment。请求完成后访问/counter/get返回值正好是 10000。这个结果说明两个问题第一Redis 的原子操作保证了计数不丢第二吞吐量在本地环境轻松达到数千 QPS如果优化网络和连接池配置单实例还能更高。有一点需要提醒在压测时不要把应用和 Redis 装在同一台机器的同一块 SSD 上否则磁盘 IO 会互相干扰测试结果不客观。生产环境的 Redis 单独部署是常识。5. 常见问题与排查技巧实录5.1 缓存与数据库的一致性问题计数器只是 Redis 的入门场景实际项目中更常见的是“Redis 缓存 MySQL 数据”的模式。这就会引入一个经典问题缓存和数据库的数据不一致怎么办。我经历过一次真实的业务事故。业务方要求更新用户昵称后立即生效开发人员只更新了数据库没有更新缓存。结果用户刷新了几次页面看到的还是老昵称。排查大半天最后发现是缓存 key 没有主动删除或更新。正确的做法有两类。更新数据库之前先淘汰缓存删除旧 key更新完数据库之后再把新值写入缓存。另一个思路是延迟双删先删缓存再更新数据库短暂等待几百毫秒后再删一次缓存避免并发读请求把旧数据写回缓存。方案没有绝对的对错要根据业务对一致性的容忍度来选。如果容忍度极低就需要引入消息队列配合或者直接读写数据库不走缓存。5.2 缓存雪崩、击穿、穿透这三个概念经常在面试里出现实际排查时也容易混。缓存雪崩说的是大量 key 同时过期导致请求全部打到数据库上。解决办法是给过期时间加随机值避免同一时间集体失效。比如基础过期时间一小时每个 key 额外加 0 到 300 秒的随机时间。缓存击穿是某一个热点 key 在过期瞬间大量并发请求一起打到数据库。解决办法是用互斥锁让请求到 Redis 发现数据不存在时先加锁等第一个请求把缓存重建好后面的请求直接走缓存。缓存穿透是请求的数据在数据库里根本不存在导致缓存永远没有对应数据每一次请求都打到数据库。解决办法是布隆过滤器把存在的 key 提前存入过滤器请求来了先查过滤器不存在的直接返回空结果不查数据库。还可以对不存在的 key 也设置一个短暂的过期缓存比如值设为空字符串过期时间设成 2 分钟也能挡住大量无效请求。5.3 用 Redis 做分布式锁要注意什么高并发场景绕不开分布式锁。Redis 的实现方式通常用SET key value NX PX expireTime命令设置一个带过期时间的键谁能设置成功谁就拿到锁。需要注意几个坑。第一个坑是锁的过期时间要合理。过期时间太短业务还没执行完锁就自动释放了会造成并发安全问题过期时间太长如果持有锁的节点崩溃了其他节点会一直拿不到锁。实践中要给锁设置合理的过期时间同时可以引入看门狗机制自动续期。第二个坑是锁的持有者判断。释放锁时要用 Lua 脚本先检查 value 是不是自己设置的那个确认是才能删除防止误删别人的锁。第二个坑是锁的持有者判断。释放锁时要用 Lua 脚本先检查 value 是不是自己设置的那个确认是才能删除防止误删别人的锁。if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本把检查和删除放在一个原子操作里避免竞态条件。这也是 Redis 分布式锁的正确打开方式。5.4 排查 Redis 连接超时的经验谈”Redis command timed out“ 这个问题我在工作中遇到过很多次原因各不相同。最常见的是连接池耗尽。所有连接都在被占用新请求只能等待等不到就报超时。看到这类报错先看压测时的线程数和连接池配置。连接池max-active太小、timeout太短都会触发。其次是慢命令阻塞。如果有人在生产环境执行了KEYS *或超大体积的HGETALLRedis 单线程会被卡住所有命令排队等待客户端自然超时。这也是我反复强调生产环境永远不要用KEYS *的原因。要查看线上有哪些 key可以用SCAN命令配合游标遍历。排查命令redis-cli --latency redis-cli --bigkeys--latency可以测试 Redis 的响应延迟--bigkeys能找出大 key这两种工具都是排查性能问题的入门利器。5.5 线上 Redis 的监控要点如果计数器要做上线监控不能省。我用的最少三组指标是内存使用量、命中率、慢查询数量。内存使用量用info memory查看环比持续上涨说明数据没有合理过期或者有大 key 堆积。命中率是指请求在缓存中命中的比例太低则说明缓存设计不合理。慢查询用SLOWLOG GET 10查看如果有大量慢查询优先检查是不是批量操作或者大 value 读写导致的。有条件的话把 Redis 的INFO信息接入监控系统设置好阈值报警。不要等到线上出事故了再登录服务器执行命令看现象那样太被动。6. 最后分享一点实操中的个人体会写完这个计数器你其实已经打通了一条完整的链路理解缓存原理、装好 Redis、选择数据类型、用原子操作解决并发问题、配置持久化防止数据丢失、再了解生产环境中的常见坑。这套知识不仅够你回答面试题也足够你在真实项目中启动第一个 Redis 功能。我在实际项目中踩过的最大一个坑是过于迷信 Redis 的性能把不适合放缓存的业务也硬塞进去。比如订单状态这种强一致性的数据直接用 Redis 做存储后端导致数据丢失后业务对不上账。缓存是加速器不是万能存储你越早认识到这一点线上翻车概率越低。如果你还想继续扩展可以试试给计数器加上过期时间实现限流功能或者用 ZSet 做一个排行榜。把基础的数据结构玩透再去看分布式锁、布隆过滤器、Cluster 集群这些进阶内容会顺利得多。动手写几行命令比看一百篇教程都有用你现在可以去试试了。
阅读完成 · 觉得有帮助?
咨询建站