
1. 环境准备与依赖安装1.1 Node.js安装与npm脚本权限问题先说环境。如果你在Windows上安装了Node.js然后发现npm命令一执行就报“无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本”——这个报错我见过太多次了群里隔三差五就有人截图问。这其实是PowerShell默认的执行策略在拦截不是Node.js本身坏了。解决办法很简单以管理员身份打开PowerShell执行下面这条命令Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser执行完输入Y确认然后npm就正常了。RemoteSigned的意思是本地创建的脚本可以运行从网上下载的脚本需要签名。这是个相对安全的策略不用直接改成Unrestricted。如果你不想改系统策略也可以按Windows键搜索“PowerShell”时按住Shift右键直接在项目目录里打开用命令行工具代替PowerShell来跑npm同样绕开这个问题。Node.js版本建议选14.17.0以上我实测下来14、16、18、20都有应用在跑但尽量用LTS版本别追奇奇怪怪的最新版。装的时候记得勾选“Add to PATH”否则后续在命令行里找不到node命令又得手动配环境变量纯属给自己找事。想在多个Node版本之间切换的话可以装个nvm-windows管理版本比手动下载安装包舒服得多。1.2 Redis的四种安装方式对比接下来是Redis。很多Windows新手第一反应就是去官网下载Windows版本结果发现官网只提供Linux和macOS的包Windows用户一脸懵。这里我直接给你列几种实际可行的方式选一种对号入座就行。安装方式适用场景难度备注Docker Desktop运行Redis容器本地开发线上环境一致低推荐干净不留系统残留WSL2安装RedisWindows下体验原生Linux环境中需要安装WSL2子系统MemuraiWindows原生不折腾直接装服务低兼容Redis命令支持Redis 7tporadowski/redis老项目、包管理器装中Redis版本偏旧建议用来临时测试我个人的倾向是如果你电脑上已经有Docker Desktop那直接用Docker跑Redis是最省心的。命令就一句docker run -d --name redis-local -p 6379:6379 redis:7.2-alpinedocker pull依赖网络如果你这边拉镜像特别慢就配一下国内的镜像加速器别死磕默认源。还有个小坑值得单独提一下有朋友遇到过“docker search redis request returned 500 internal server error for api route and version”这个报错出现时Docker Desktop的API版本对不上通常升级Docker Desktop到最新版或者把Docker引擎Restart一下就能解决跟Redis本身没任何关系。安装完成后务必确认Redis真的在运行。命令行里执行redis-cli ping返回PONG说明服务起来了。如果你用Docker就执行docker exec -it redis-local redis-cli ping结果一样。服务没起来就去排查日志Docker里直接看docker logs redis-localWSL里看redis-server的运行日志。1.3 设置Redis密码与基本配置Redis默认是没有密码的只允许本机访问还好一旦你的服务部署到远程服务器端口又对外开放那redis-cli连上去就是裸奔状态等于把数据白送给别人。热词里就有“windows设置redis密码”说明大家确实关心这个事。设置密码最直接的方式是在配置文件redis.conf里加一行requirepass yourpassword如果是Docker容器启动时加参数docker run -d --name redis-local -p 6379:6379 redis:7.2-alpine --requirepass yourpassword密码设置完之后redis-cli里所有命令都必须先执行AUTHredis-cli -a yourpasswordNode.js这边连接时也要带上密码参数否则连接成功后执行命令会报NOAUTH Authentication required.2. 驱动选型与基础连接实战2.1 node-redis与ioredis怎么选Node.js连接Redis常用的驱动有两个node-redis和ioredis。新手最容易问的问题就是“我该用哪个”我的建议是看你的具体场景。特性node-redisioredis维护方Redis官方社区维护性能高高集群支持Redis Cluster支持较完善非常完善哨兵模式支持支持配置简洁Lua脚本支持支持API更友好断线重连自动自动可自定义策略项目活跃度活跃曾经停更又恢复生态稳定如果你需要过硬的官方保证或者项目里Redis版本比较新就选node-redis。如果你项目跑在集群架构上或者喜欢更简洁的APIioredis用起来更顺手。我这里接下来的代码示例以ioredis为主毕竟它把很多底层细节都处理好了对新手更友好。不过两者核心概念相通你看完代码再切node-redis也不难。2.2 创建Redis连接实例ioredis安装非常简单npm install ioredis然后新建一个redis.js文件创建连接const Redis require(ioredis); const redis new Redis({ host: 127.0.0.1, port: 6379, password: yourpassword, db: 0, retryStrategy: (times) { return Math.min(times * 50, 2000); }, maxRetriesPerRequest: 3, });host填Redis所在机器的IP本机就是127.0.0.1线上换成对应的内网地址。db参数值得单独解释一下它表示选择Redis的逻辑数据库。Redis默认有16个库编号0到15。每个库里的数据是隔离的key可以重名。我见过有些项目把不同业务的数据放到不同db比如db0存用户缓存、db1存商品缓存这种做法能让key的命名空间清晰不少但同时也带来一个问题——不同应用连同一个Redis实例时万一一个应用写错了db容易污染另一个应用的数据。真正的线上服务我更推荐一个应用单独用一个Redis实例而不是共用实例分db维护边界清楚得多。retryStrategy是ioredis的特色配置它控制断线后重连的间隔。上面的代码意思是第一次重试间隔50ms之后逐步增加最大间隔2秒。这个配置能有效避免网络抖动时客户端疯狂重连打爆Redis。接下来验证连接状态监听事件redis.on(connect, () { console.log(Redis连接成功); }); redis.on(error, (err) { console.error(Redis异常, err); });connect事件在TCP连接建立时触发error事件负责捕获连接异常。我建议把error事件单独监听出来把日志打到存储系统里别只console.error就完事不然后期线上排查问题的时候连报错都没留痕找原因像大海捞针。2.3 第一个读写操作连接成功之后写一个最简单的读和写(async () { await redis.set(name, zhangsan); const name await redis.get(name); console.log(name); // zhangsan await redis.set(user:1:info, JSON.stringify({ id: 1, name: zhangsan })); const userStr await redis.get(user:1:info); const user JSON.parse(userStr); console.log(user.name); redis.disconnect(); })();这里有个非常容易踩的坑Redis只能存字符串你在Node.js里存一个对象必须先JSON.stringify转成字符串读取之后再JSON.parse转回来。很多刚接触Redis的人直接redis.set(key, {name: abc})结果set出来的值是[object Object]一取出来就懵了。说到底把Redis理解成一个巨大的、带过期时间的“字符串键值字典”很多问题就迎刃而解。另一个要注意的是所有操作都是异步的别忘了用await。我见过好几个人在循环里忘了await结果代码一跑Redis里的值要么没写入要么顺序乱掉。如果你要在循环里批量操作不要一个一个await应该用pipeline或者multi批量提交后面我会细讲。3. 核心数据类型与典型业务场景热词里专门有“redis数据类型”说明大家对这个概念有需求。Redis的数据类型不只是面试题日常开发里选不对类型性能和代码复杂度天差地别。我结合实际场景逐一过一遍。3.1 String类型与计数陷阱String是最基础的类型通常用来存缓存、计数器、Session、限流信息。用它做计数器时务必使用原子自增命令const count await redis.incr(post:123:likeCount);incr是原子操作多个请求同时执行也不会丢数据。热词里有“redis incr不准”多半是有人用了非原子的“先get后set”在并发条件下两个请求读到同一个值各自加1再写回去结果只加了一次。如果一定要手动读写要记得用watch配合事务或者干脆用incr。一句话能在服务端原子完成的计数永远不要拿到客户端来做。String的另一个典型用法是setex设置值的同时指定过期时间await redis.setex(sms:phone:185xxxx1234, 60, 123456);这条命令的意思是存一个key为sms:phone:185xxxx1234的值12345660秒后自动过期。登录验证码、限流窗口这类场景用setex非常合适。如果单独用set再expire万一set成功expire失败这条key就永久留在Redis里内存白白浪费。3.2 Hash类型优化对象存储Hash类型适合存对象字段。比如用户信息await redis.hset(user:1001, { name: zhangsan, age: 25, email: zhangsanexample.com, }); const email await redis.hget(user:1001, email); const all await redis.hgetall(user:1001);用Hash存对象的好处是你可以只读取或更新其中一个字段而不需要像String那样把整个JSON字符串取出来解析再塞回去。特别是用户信息里某些字段频繁更新比如“最后登录时间”用Hash做局部更新非常高效。hgetall取出来的对象所有字段都是字符串不会自动帮你转数字或布尔值。如果要按数字比较大小记得parseInt或Number。这是Node.js里最常见的隐性问题很多坑在校验数据时爆发建议封装一个统一的解析函数。3.3 List类型实现简单的消息队列List是双向链表支持从左边或者右边push、pop。最典型的场景是做简单的消息队列生产者lpush消息消费者rpop取消息// 生产者 await redis.lpush(queue:order, JSON.stringify(orderId)); // 消费者 const task await redis.rpop(queue:order); if (task) { const orderId JSON.parse(task); // 处理订单逻辑 }这种用法简单直接但也有一个痛点消费者轮询会让CPU闲置空转阻塞式读取brpop能解决这个问题const result await redis.brpop(queue:order, 10);brpop会阻塞等待队列里有消息最多等10秒超时返回null。这样消费者线程不会被无效请求打满实现也简洁。需要说明的是这个方案虽然能做队列但消息可靠性、持久化、消费确认这些都没有生产环境要求高的话还是用专业的消息队列比如RabbitMQ或KafkaRedis自带的List队列更适合轻量级场景。3.4 Set与Zset的实战用法Set是无序字符串集合元素不能重复适合做去重、标签、共同关注类需求await redis.sadd(user:1:follows, user2, user3, user4); const follows await redis.smembers(user:1:follows); const isFollow await redis.sismember(user:1:follows, user2);Set之间还支持交集、并集、差集运算一句话就能算“共同关注”const common await redis.sinter(user:1:follows, user:2:follows);数据库里做这种交集可能要写一堆SQLRedis这边一个命令搞定响应时间从几百毫秒降到几毫秒用户体验直接起飞。Zset是有序集合每个元素绑定一个分数Redis按分数排序。最典型的例子是排行榜await redis.zadd(rank:hot, 100, article1); await redis.zincrby(rank:hot, 5, article1); const top10 await redis.zrevrange(rank:hot, 0, 9, WITHSCORES);zincrby可以给某篇文章热度加5分zrevrange取出排行榜前10名。这里的核心技巧是让分数承载业务逻辑比如“热度阅读量 点赞数×2 评论数×3”你只需要在写入时算好总分排序的事全部交给Redis。哪怕数据量几十万zrevrange取前10条也就是毫秒级别。Zset还能拿时间戳当分数实现简单的延时任务队列不过没有可靠的重试和确认机制只适合自用的轻量场景。4. 缓存场景的工具函数封装与治理4.1 JSON序列化基础封装上一节讲的是数据类型这一节把直接能用的工具函数送给你。我自己项目里的缓存服务最开始就是几个散落的set、get调用后来发现重复代码越来越多就封装了一个cache.js。核心逻辑很简单const Redis require(ioredis); const redis new Redis({ host: 127.0.0.1, port: 6379 }); const prefix app:; function buildKey(key) { return ${prefix}${key}; } async function cacheGet(key) { const val await redis.get(buildKey(key)); if (!val) return null; return JSON.parse(val); } async function cacheSet(key, value, ttl 3600) { await redis.set(buildKey(key), JSON.stringify(value), EX, ttl); } async function cacheDel(key) { await redis.del(buildKey(key)); } module.exports { cacheGet, cacheSet, cacheDel };使用这个封装的代码示例const { cacheGet, cacheSet } require(./cache); async function getUserInfo(userId) { const cached await cacheGet(user:${userId}); if (cached) return cached; const userFromDb await queryUserFromDb(userId); await cacheSet(user:${userId}, userFromDb, 300); return userFromDb; }前缀prefix很有讲究它能区分不同业务线的key避免冲突。后面排查问题的时候打开Redis可视化工具一眼就能看清当前库里的数据归属。线上项目还经常再加一层前缀区分环境比如app:prod:user:1001和app:test:user:1001防止测试环境数据污染生产缓存。4.2 缓存击穿、穿透与雪崩的应对方案热词里有“redis缓存治理”这个概念通常是指缓存穿透、缓存击穿、缓存雪崩这三类“经典事故”。这里不堆概念直接讲怎么处理。缓存穿透指的是查询一个根本不存在的数据缓存里没有数据库里也没有请求每次都打到数据库。解决办法有两个方向一是对查询到的空结果也做缓存比如设置60秒的过期时间让下一次同样的查询直接走缓存二是用布隆过滤器把所有可能存在的键值预先加载到一个过滤器里查询前先判断这个key在不在如果过滤器说没有就直接返回空不再查询数据库。第二种方案在高并发场景下更可靠但布隆过滤器本身有误判率使用起来需要权衡。缓存击穿指的是一条热点数据缓存过期同一瞬间大量请求涌向数据库。最直接的方案是加互斥锁缓存失效后只有一个请求能拿到锁去数据库查询其他请求等待锁释放后从缓存里拿结果。另一个方案是逻辑过期——缓存里存的值人为加上一个过期时间后台脚本检测到逻辑过期后再去更新缓存这样可以保证用户始终能拿到旧数据不会打到数据库。逻辑过期实现稍复杂但体验更好适合读多写少的场景。缓存雪崩是指大量key集中在同一时间过期请求一起落到数据库。解决办法是给每个key的过期时间加一个随机值避免“同一时刻集体失效”。比如原来统一过期时间300秒现在改成300random(0,60)秒。另外服务端限流、多级缓存本地Caffeine/Map Redis也能缓解雪崩的影响。下面是集成基本防护的缓存读取函数async function getWithProtection(key, queryFn, ttl 300) { const cached await cacheGet(key); if (cached ! null) return cached; const lockKey lock:${key}; const ok await redis.set(lockKey, 1, NX, EX, 5); if (ok null) { // 拿不到锁说明其他进程正在查库短暂等待后重试 await sleep(80); return getWithProtection(key, queryFn, ttl); } try { const data await queryFn(); await cacheSet(key, data, ttl Math.floor(Math.random() * 60)); return data; } finally { await redis.del(lockKey); } }sleep实现function sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)); }注意这里对锁的释放用了del如果业务逻辑执行时间超过锁过期时间可能误删其他请求加的锁更稳的解法我在下一节分布式锁里面展开。数据从数据库查出来后过期时间适当加随机数避免同一时间集体失效。4.3 Pipeline与事务的取舍前面提到循环里不要逐个await正确的姿势是用pipeline。例如批量写入用户数据const pipeline redis.pipeline(); for (let i 0; i 1000; i) { pipeline.set(batch:${i}, i); } const results await pipeline.exec();pipeline会把所有命令一次性发给Redis服务端减少网络往返性能提升非常明显。我实测过1000条命令逐条await耗时大约在几十毫秒量级要用pipeline后基本在1毫秒左右完成。但要注意pipeline只是批量提交不保证原子性。如果你想“多条命令要么全成功要么全失败”用multiconst multi redis.multi(); multi.set(key1, val1); multi.incr(counter); await multi.exec();在multi执行期间其他客户端的命令不会插入进来从执行命令到exec结束是原子性的。这个特性在扣减库存、转移金额等场景里非常关键。Redis事务支不支持回滚不支持但它保证中间不会被其他命令干扰。所以使用时你要自己通过返回值判断每条命令是否成功。5. 分布式锁与高并发场景进阶5.1 基于SET NX EX的分布式锁实现热词里有“redis分布式锁”这也是用Redis做分布式协调最经典的场景。设想一个秒杀活动多个Node.js进程同时执行扣库存操作如果大家先get再set库存一定会被扣超。分布式锁的目的就是保证同一时刻只有一个进程能执行关键代码。Redis官方推荐的加锁方式是SET key value NX EX一条命令同时完成“不存在才设置”和“设置过期时间”两个动作const LOCK_KEY lock:stock; const LOCK_TTL 10; const result await redis.set(LOCK_KEY, token, NX, EX, LOCK_TTL); if (result null) { throw new Error(尝试获取分布式锁失败); }这里token是一串随机字符串它的作用是“锁的持有者标识”。释放锁时不能直接del了事因为可能会出现这种情况进程A拿到锁执行太久锁过期自动释放了进程B拿到锁开始执行A执行完之后del把B的锁误删了。用token就能避免const releaseScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end ; await redis.eval(releaseScript, 1, LOCK_KEY, token);eval执行这段Lua脚本先校验锁值是不是自己的token是才删除。Lua脚本是原子执行的所以判断和删除两个动作之间不会被其他命令插队。这个技巧就是面试里常说的“Redis分布式锁误删问题”的标准解法。加锁完整代码const crypto require(crypto); async function acquireLock(key, ttlSeconds) { const token crypto.randomUUID(); const result await redis.set(key, token, NX, EX, ttlSeconds); if (result null) return null; return token; } async function releaseLock(key, token) { await redis.eval(releaseScript, 1, key, token); }修改业务代码const token await acquireLock(lock:stock:1024, 10); if (!token) { return { success: false, msg: 操作太频繁请重试 }; } try { // 检查库存、扣减库存 } finally { await releaseLock(lock:stock:1024, token); }5.2 锁续约与Redlock的取舍为了锁不提前过期业务执行时间又不好预估常给锁加一个自动续约机制。ioredis有现成的扩展redis-lock库或者自己起一个定时器在锁还剩一定时间时用expire刷新过期时间。自己实现的简单方式是const timer setInterval(async () { const result await redis.eval( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(expire, KEYS[1], ARGV[2]) else return 0 end, 1, lockKey, token, LOCK_TTL ); if (result 0) clearInterval(timer); }, 3000);这只是基础版的续约。Redis官方在分布式锁领域还有个Redlock算法它要求向多个Redis主节点请求锁过半数成功才代表加锁成功主要是为了解决“单个Redis节点故障导致锁丢失”的问题。Redlock实现复杂而且一直有人质疑它的安全性一般来说业务场景没到“必须扛住主节点宕机”的级别单机RedisPipelineLua已经够用了。如果用Redlock记得调研好它适用的边界条件别盲目搬进项目。5.3 限流器与幂等性设计分布式锁之外Redis还常用来做限流器和幂等控制。限流的最简方案是给每个用户每秒钟维护一个计数const key rate:${userId}:${Math.floor(Date.now() / 1000)}; const count await redis.incr(key); if (count 1) { await redis.expire(key, 2); } if (count 10) { return { code: 429, msg: 请求过于频繁 }; }incr之后第一次设置过期时间这个写法是标准做法。为什么第二次以后不expire因为第一次已经设置过期了继续设置反而覆盖。还有一种更严谨的限流算法是滑动窗口把每个请求时间戳放进Zset再用zcard和zremrangeByScore清理过期窗口内记录。Zset做滑动窗口比固定窗口更平滑但存储空间会大一些适合需要精确限流的场景。幂等性控制可以用来防止重复提交。比如订单创建接口客户端生成一个唯一的orderToken服务端先用set nx把token存进去能写入的就继续处理已经存在就拒绝const key idempotent:${orderToken}; const ok await redis.set(key, 1, EX, 3600, NX); if (ok null) { return { code: 400, msg: 重复提交 }; }这个模式在很多需要防重的场景里都能用比如支付回调、表单提交、客服消息触发。6. 常见问题与排查技巧实录6.1 连接类问题排查连接不上Redis最常见的报错是ECONNREFUSED说明端口没有服务在监听。依次排查这几项Redis进程是否在运行绑定的IP是不是127.0.0.1还是0.0.0.0防火墙有没有放行6379端口云服务器安全组是否允许访问。我有个朋友线上Redis连不上最后发现是安全组没放行纯环境配置问题代码写得再对也没用。还有一种是redis.conf里设置了bind 127.0.0.1导致其他机器根本连不进来。如果你部署在同一台机器上就无所谓如果Redis和Node.js服务不在同一台机器必须把bind改成0.0.0.0同时设置requirepass保护不然等于裸奔。报错ERR AUTH called without any password configured for the default user说明Redis服务端没有配置密码但客户端连接时传了password参数。解决办法是把客户端连接配置里的password去掉或者在Redis配置文件里把requirepass加上。还有一个连带问题Redis 6之后默认开启了ACL如果你在配置了ACL的实例上用老账号密码连接也可能出现权限不足的报错需要确认当前用户是否有权限执行命令。6.2 数据类型操作报错运行时报错WRONGTYPE Operation against a key holding the wrong kind of value意思是key已经存在但类型不是当前命令期望的类型。场景是这样的某处代码先把user:1001用set存成了字符串后面另一处代码用hget去读这个key就爆出这个错。排查方式很简单redis-cli里执行type user:1001能立刻看出key当前的类型。解决方法是给key加前缀区分业务比如user:1001:info是Hashuser:1001:lastLogin是String不同含义的key不要共用一个命名。我踩过一次这种坑后果是线上一个抢红包的活动Redis里全是错乱数据加班排查到凌晨两点才定位从那以后我在Redis连接层就强制校验常用key的前缀规范。6.3 内存与淘汰策略问题热词里有“redis日志”日志里最常见的是内存不足告警或者查询时出现maxmemory相关的报错。Redis内存被打满时通过淘汰策略来处理新写入。线上一般配置allkeys-lru意思是内存不够时优先淘汰最久没使用的key。如果你的业务不允许缓存数据被随意淘汰那就要分实例部署缓存实例用allkeys-lru数据一致性要求高的实例用noeviction禁止淘汰满了直接报错避免业务数据被LRU误伤。查看内存状况redis-cli info memory重点看used_memory和maxmemory这两个值。再配合redis-cli monitor实时监控命令可以快速定位是哪些key在疯狂写入。注意生产环境别长时间开着monitor它会把所有命令都输出性能影响很大只建议临时排查用。另外就是热词里提到的“redis日志”应用层还可以开启Redis的慢查询日志在配置里设置slowlog-log-slower-than 10000单位微秒把执行时间超过10毫秒的命令记录下来。排查慢命令用SLOWLOG GET 50哪些命令容易慢keys *这种全库扫描一定要杜绝生产环境直接会卡住服务。建议用scan命令分批次遍历或者干脆改业务结构给key做索引按需查询。大数据量的HGETALL、LRANGE 0 -1也容易慢取值时尽量指定范围。6.4 断线重连与超时问题Node.js进程和Redis之间的长连接在网络抖动时会断开。ioredis的retryStrategy做得比较完善但如果业务代码没处理好断线期间所有操作会一直抛错甚至把请求积压成雪崩。建议对关键操作加超时控制const result await redis.get(key, (err, value) { if (err) { // 记录日志快速失败避免拖垮异步队列 } });ioredis也支持调用时传timeout参数或者用Promise.race统一控制超时时间。还有一个细节如果你的Node.js进程开启了多进程模式每个进程都会创建独立的Redis连接进程多了Redis连接数会涨得很快。可以封装一个连接池或者共享一个Redis实例减少不必要的连接开销。7. 运维监控与后续扩展思路Redis不是装完就用上线之后还得关注这几个指标内存使用率、连接数、命中率。命中率可以用info stats里的keyspace_hits和keyspace_misses计算redis-cli info stats命中率低说明缓存设计不合理很多请求打到数据库上缓存形同虚设。我通常要求项目里的缓存命中率保持在85%以上低于这个值就回头检查key的设计和数据预热。数据预热的意思是服务启动或者在低峰期手动把热点数据提前写入缓存避免冷启动时大量请求直接落库。连接数方面用info clients查看connected_clients如果一直涨说明Node.js这边有连接泄漏检查是不是创建了Redis实例之后没有复用。这股涨势一定要重视我之前见过一个项目连接数飙到理论上限Redis直接拒绝新连接服务瞬间不可用。排查到最后就是代码里每次请求都new了一个Redis实例连接从没关过。最后再分享一个我觉得很有用的资源规划习惯给Redis单独配一份监控告警内存占用超过80%就告警连接数超过峰值的70%也告警慢查询超过阈值就发通知。很多问题在刚冒头的时候就被干掉服务可以安稳很多。我自己的项目后来还做了缓存灰度同一个key先灰度5%的流量验证结果确认没问题再全量放开这个做法对高风险缓存改造很有帮助。Redis这款工具本身不复杂真正的复杂度在于你围绕它设计的那些工程细节把这些细节处理好了Node.js和Redis的组合用起来会非常顺手。