ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PHP操作Redis实战:五大数据类型、缓存穿透与分布式锁全解析

PHP操作Redis实战:五大数据类型、缓存穿透与分布式锁全解析 做PHP开发的朋友迟早会碰上性能这堵墙——数据库CPU飙升、接口响应慢、同一个页面反复查表。我自己的经验是很多项目从文件缓存切换到Redis之后性能都有一波肉眼可见的提升。PHP操作Redis说白了就是把高频数据从MySQL搬到内存里但真正落地时比想象中要多踩几个坑。这篇文章不打算讲太多理论就从我实际调试过的环境、代码和事故出发把PHP连接Redis、操作五大数据类型、处理序列化乱码、解决缓存穿透与分布式锁这些事一次性讲透。不管你是刚入门的小白还是被线上问题折磨过的老手这篇应该都能给你点实在的东西。1. 先把环境跑通PHP连上Redis之前的准备功夫很多人在安装扩展这一步就卡了很久。尤其是Windows环境PHP版本、线程安全模式、扩展DLL版本三者必须完全对上否则phpinfo里死活看不到redis扩展。1.1 服务端装好客户端驱动选哪个phpredis还是Predis先说结论能用C扩展就用C扩展。phpredis是C语言写的PHP扩展性能和内存占用都远比纯PHP实现的Predis好。Predis虽然不需要编译只需用Composer引入即可适合虚拟主机这类无法安装扩展的环境但在高并发场景下纯PHP实现的客户端光解析协议就能吃掉不少CPU资源。我在自己的服务器上做过简单对比同样的数组写入和读取phpredis的耗时大约只有Predis的四分之一到三分之一。所以如果你能控制服务器环境我强烈建议直接上phpredis扩展。1.2 Windows和Linux下的phpredis安装Linux环境没什么好说的直接pecl install redis然后往php.ini里加一行extensionredis.so重启php-fpm或Apache即可。Windows环境是重灾区。很多新手下载了redis扩展DLL放在ext目录里但在phpinfo里就是看不到。核心原因在于PHP分TS线程安全和NTS非线程安全两个版本。用Apache跑PHP就选TS版用NginxPHP-FPM就选NTS版。扩展DLL也分VC版本。比如PHP 8.2通常需要VC15或VC16编译的扩展。扩展版本必须和PHP大版本严格匹配比如PHP 8.1必须用8.1对应的扩展包。判断自己的PHP是TS还是NTS最简单的方式是在命令行运行php -v输出中带NTS的就是非线程安全什么都没带或者带TS的就是线程安全。下载对应版本的phpredis DLL时认准php_redis.dll放到PHP安装目录的ext文件夹然后在php.ini中添加extensionredis之后重启Web服务在phpinfo页搜索redis出现Redis支持列表就说明成功了。注意Windows下Redis服务端并不提供官方版本目前常用的是tporadowski/redis这个开源镜像编译的Windows版。下载解压后运行redis-server.exe --service-install redis.windows.conf可以注册为Windows服务并开机自启。1.3 连接Redis的第一步关于connect、pconnect与鉴权PHP连接Redis的入门代码看起来简单但里面有不少容易忽视的细节。$redis new Redis(); $redis-connect(127.0.0.1, 6379, 3.0); // 第三个参数是超时时间单位秒 $redis-auth(yourpassword); // 如果redis.conf里设置了requirepass $redis-select(0); // 选择数据库编号默认0号库这里我遇到过两个典型问题第一不要把超时时间省掉。默认情况下connect连接不上时可能会阻塞很久导致PHP-FPM进程被拖住。显式设置3秒超时能避免大量请求堆积在Redis连接上。第二pconnect持久连接要慎用。PHP-FPM模式下每个worker进程会维持一个长连接理论上减少了重复TCP握手的开销。但问题是如果Redis重启、网络断开这个长连接可能已经失效而PHP-FPM还傻乎乎地复用这个死连接导致请求全部超时。我见过不止一个项目在Redis重启后崩溃就是因为用了pconnect且没有处理连接重连逻辑。除非你的代码里明确处理了断线重连否则默认使用connect更稳妥。鉴权这块也要注意Redis 6.0之后支持多用户和ACL权限控制。如果公司安全规范严格可能不只一个requirepass还有针对不同业务的用户名。连接方式是$redis-auth([username, password]);我记得有个项目因为Redis服务端升级到6.x并启用了ACL老代码里只传一个密码导致线上批量连接失败。升级Redis版本时连接代码这块一定要同步排查。2. 五大数据类型的操作实战从字符串缓存到有序集合Redis之所以能覆盖那么多业务场景靠的就是五种基础数据结构。Python、Go写Redis都离不开它们PHP里用phpredis操作时更是要和业务一一对应。2.1 字符串类型最常用的缓存载体字符串类型是Redis里最基础也最常用的。缓存用户信息、接口返回值、计数器全是它的活。// 写入带过期时间的缓存单位是秒。推荐setex语义清晰 $redis-setex(user:profile:1001, 3600, json_encode($userData)); // 读取 $cached $redis-get(user:profile:1001); if ($cached ! false) { $userData json_decode($cached, true); } // 自增计数器比如统计文章阅读量 $redis-incr(article:views:10086); $redis-incrBy(article:views:10086, 10); // 批量写 $redis-mset([key1 value1, key2 value2]); // 只有在key不存在时才写入——分布式锁的基础 $redis-set(lock:coupon:1001, 1, [NX, EX 10]);这里要提醒一个小坑set方法的第三个参数在不同版本的phpredis里写法不同。老版本习惯用$redis-set($key, $value, 3600)表示过期时间新版本则是$redis-set($key, $value, [EX 3600])或$redis-setex($key, 3600, $value)。混用会导致意外行为建议统一用setex。2.2 哈希类型把PHP数组按字段拆进去哈希类型最适合存对象结构。比如用户信息有昵称、头像、简介等多个字段用字符串存整个JSON更新一个字段就要整体读写用哈希存储则是字段级别的操作。// 写入单个字段 $redis-hSet(user:info:1001, nickname, 老王); $redis-hSet(user:info:1001, avatar, /uploads/a.png); // 批量写入 $redis-hMSet(user:info:1001, [ nickname 老王, avatar /uploads/a.png, level 5 ]); // 获取指定字段 $nickname $redis-hGet(user:info:1001, nickname); // 获取全部字段 $userInfo $redis-hGetAll(user:info:1001); // 字段自增比如积分 $redis-hIncrBy(user:info:1001, points, 100);实际使用中哈希类型比字符串更适合存储不断变化的半结构化数据。比如购物车用户ID作为key商品ID作为field商品数量作为value。更新数量时不用动整个JSON。2.3 列表类型队列场景的标准答案列表的实现是双向链表左进右出、右进左出都很方便。这直接催生了两个经典用法消息队列和最新动态列表。// 生产者把任务从左边推入队列 $redis-lPush(queue:send_email, json_encode([to userexample.com, subject 你好])); // 消费者从右边弹出任务 $task $redis-rPop(queue:send_email);用列表做简单队列比数据库表轮询高效得多而且还能实现类似最新消息的功能// 用户发一条动态写入列表只保留最近100条 $redis-lPush(feed:user: . $userId, $content); $redis-lTrim(feed:user: . $userId, 0, 99); // 分页读取 $feeds $redis-lRange(feed:user: . $userId, 0, 9);这里有个技巧需要只在列表长度小于某个值时才能写入的场景可以配合lLen判断但要注意并发问题。更稳妥的做法是用Lua脚本保证原子性后面分布式锁章节会展开讲。2.4 集合与有序集合去重、排行、标签场景集合的特点是自动去重、无序非常适合存储标签、关注关系这类数据。有序集合在集合基础上给每个元素附加了一个分数天生就是排行榜。// 集合给文章打标签 $redis-sAdd(article:tags:1001, PHP, Redis, 缓存); // 判断是否包含某个标签 $isPhpTag $redis-sIsMember(article:tags:1001, PHP); // 集合交集推荐系统常见的共同关注 $common $redis-sInter(user:follows:1001, user:follows:1002); // 有序集合写入用户积分 $redis-zAdd(rank:points, 1000, user:1001); $redis-zAdd(rank:points, 850, user:1002); // 取出积分前十名withscores参数为true时返回分数 $top10 $redis-zRevRange(rank:points, 0, 9, true); // 给某个用户加分 $redis-zIncrBy(rank:points, 50, user:1001);有一点需要注意zRevRange是从高到低排序zRange是从低到高。做排行榜经常有人搞混这两个方法取出来的数据顺序不对。2.5 过期时间与批量删除缓存场景必须掌握的细节给缓存设置过期时间是好习惯但怎么设怎么删同样有讲究。全部设置为同一过期时间可能在某个时间点集中失效造成缓存雪崩。常见的做法是加上随机偏移$expire 3600 mt_rand(0, 600); $redis-setex(page:home: . $cityId, $expire, $html);批量删除在Redis 4.0之前没有原生命令常规做法是先KEYS匹配再循环删除。但KEYS命令在生产环境是灾难级的操作——它会阻塞Redis单线程执行在大key数量下直接卡死服务。2.8版本之后要使用SCAN命令配合游标迭代$cursor null; $pattern user:profile:*; $count 100; do { $keys $redis-scan($cursor, $pattern, $count); if ($keys) { $redis-del($keys); } } while ($cursor 0);这个scan配合del的写法既能清理缓存又不会长时间阻塞Redis是我在清理历史垃圾key时用得很顺手的方案。3. 序列化乱码与数据一致性PHP和Redis之间的那些坑用PHP写入Redis最常见的报错和诡异现象十有八九出现在序列化上。3.1 存进去好好的取出来怎么变了很多人第一次存数组到Redis时喜欢直接$redis-set(user:1001, $userArray);结果读取时发现返回的是字符串Array。然后改为$redis-set(user:1001, serialize($userArray)); $data unserialize($redis-get(user:1001));这样虽然能工作但有个隐患——如果同一个key被其他语言比如Python、Java写入serialize序列化格式不兼容数据就废了。更重要的是serialize后的字符串在可视化工具里是乱码调试极为痛苦。我现在的习惯是统一使用JSON格式$redis-setex(user:1001, 3600, json_encode($userArray, JSON_UNESCAPED_UNICODE)); $userArray json_decode($redis-get(user:1001), true);PHP从7.4之后就原生支持json_encode处理各种转义问题。加JSON_UNESCAPED_UNICODE是为了避免中文被转成\uXXXX不然后端排查数据和前端展示时都得额外处理一次。如果非要用phpredis自带的序列化选项可以在连接后设置$redis-setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON);但要注意全局统一序列化器之后某些需要直接使用字符串的地方比如存储Redis本身协议相关的数据会受影响因此我还是推荐业务数据显式JSON编码的方式。3.2 中文乱码和编码问题中文乱码分两种场景。一种是用redis-cli查数据时看到\xe4\xbd\xa0\xe5\xa5\xbd这样的十六进制这是正常的Redis在终端下对二进制安全的字符串输出会显示转义。另一种是业务里读取到的中文字段变成乱码这种多半是PHP文件本身编码或者HTTP响应头没有声明UTF-8的问题。排查乱码问题时先用redis-cli手动写入一个中文串再用PHP读取。如果PHP读取正常那就是之前的写入端编码有问题如果PHP读出来乱码则检查default_charset和PDO连接串里的charset配置。Redis不管数据是什么编码它内部只认字节所以编码问题基本都是谁写入谁负责。3.3 key命名规范与数据隔离我接手过一个项目缓存key长得五花八门——有的叫user_1001有的叫user:1001还有的叫1001_user_info。最后导致同一个用户信息被缓存了好几份更新时漏掉其中一个线上出现数据不一致。现在团队统一用业务域:实体:ID:子域的分层命名user:profile:1001 user:orders:1001:list order:detail:20250101:1001 article:page:3好处很明显同业务的数据在可视化工具里能聚集在一起SCAN模糊匹配方便按前缀清理也不会误杀其他业务数据。多业务共用一个Redis实例时更稳妥的做法是用不同的DB编号做隔离甚至直接部署独立的Redis实例。当然这个要结合公司基础设施来看。3.4 缓存和数据库的一致性操作顺序缓存和数据库的一致性是PHP操作Redis时绕不开的经典问题。实际项目中最常用的模式是Cache Aside旁路缓存读请求先读缓存命中则返回未命中则查数据库回填缓存返回。写请求先更新数据库成功后删除缓存或者更新缓存。为什么写操作要先更新数据库再删缓存而不是先删缓存再更新数据库因为后一种顺序在并发场景下有个窗口请求A先删除缓存还没更新数据库时请求B来读缓存——发现缓存空了于是去数据库读到旧数据回填到缓存。此时缓存里就是旧值而数据库随后被更新为新值两边就永久不一致了。而先更新数据库再删缓存即使在删除瞬间有其他请求读到旧值加载进缓存下次读请求时因为缓存里已经有旧值不再回源数据库最多只会在极小的时间窗口内有短暂不一致。如果想进一步加强可以引入延迟双删更新数据库后删除一次缓存隔几百毫秒再删一次把可能回填进来的旧值清掉。这个方案结合业务容忍度来评估即可。此外要注意删除缓存失败的情况。比较实用的做法是在业务代码里做重试或者把删除操作丢进MQ消息队列异步执行。并发量没那么大的系统直接忽略删除失败带来的影响也行毕竟过期时间兜底。4. 项目实战中的三个高频场景缓存治理、分布式锁、轻量队列前面那些算是基本功接下来讲我在实际业务中真正反复使用到的三个场景。每一个都是踩过坑才总结出来的。4.1 缓存穿透、缓存击穿、缓存雪崩的PHP应对面试题里总考这三个缓存杀手现实中它们确实都有对应的事故案例。缓存穿透指查询一个根本不存在的数据。由于缓存和数据库都没有这个key每次请求都会穿透到数据库严重时能把数据库打挂。我以前接过一个活动页面攻击者不断请求不存在的用户ID导致数据库慢查询暴增。应对方案有两个层级。第一层对查不到的数据也缓存一个空值并设置较短的过期时间比如30秒。第二层在缓存和数据库之间加一个Bloom Filter布隆过滤器先把所有合法ID初始化进去请求到来时先判断ID是否在白名单里不在就直接返回空。// 空值缓存 $data $redis-get(user:profile: . $userId); if ($data false) { $userInfo db_get_user($userId); if (empty($userInfo)) { $redis-setex(user:profile: . $userId, 30, ); return null; } $redis-setex(user:profile: . $userId, 3600, json_encode($userInfo)); return $userInfo; } if ($data ) return null; return json_decode($data, true);缓存击穿指一个热点key在过期瞬间发生大量并发请求全部打到数据库。处理思路是互斥重建让同时到达的请求只有一个去查数据库其他请求等待或者短暂重试。$data $redis-get(hot:article: . $id); if ($data false) { // 获取锁模拟SET NX EX $lockKey lock:hot:article: . $id; if ($redis-set($lockKey, 1, [NX, EX 5])) { try { $article db_get_article($id); $redis-setex(hot:article: . $id, 3600, json_encode($article)); } finally { $redis-del($lockKey); } return $article; } // 没拿到锁的请求先等50毫秒再重新读缓存 usleep(50000); $data $redis-get(hot:article: . $id); if ($data ! false) { return json_decode($data, true); } return db_get_article($id); }缓存雪崩和缓存穿透不一样它指的是大量key在同一时间段集体过期或者Redis服务本身挂了导致请求全部涌向数据库。解决思路一是给过期时间加随机值我在2.5节介绍过二是对热点数据设置永不过期由后台异步任务在接近过期时间时主动续期。再往后服务降级、多级缓存等方案可以按团队能力逐步引入。4.2 用SET NX EX实现分布式锁以及为什么释放锁要用Lua多个PHP-FPM进程并发执行某个操作时比如用户同时点击两次下单需要一把分布式锁保证只有一个进程能进入临界区。Redis提供了很优雅的实现$lockKey lock:order:create: . $userId; $token uniqid(, true); // 只有key不存在时才能设置成功且10秒自动过期防止死锁 $locked $redis-set($lockKey, $token, [NX, EX 10]); if (!$locked) { throw new \Exception(操作太频繁请稍后再试); } try { // 执行业务逻辑 create_order($userId); } finally { // 释放锁只有持有者才能释放 $script LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end LUA; $redis-eval($script, [$lockKey, $token], 1); }这段代码里有几个细节是网上很多示例不会讲的NX保证只有锁不存在时才设置成功解决了并发抢锁问题。EX设置过期时间避免进程崩溃后锁永远不释放。这个过期时间要结合业务最大执行时长评估比如业务最慢10秒锁过期时间至少30秒以上防止长任务执行中被自动释放其他进程趁机拿到锁。释放锁时不是简单del而是先比对锁值token再删除。这能避免一个常见的坑进程A持锁超时被自动释放进程B拿到同一个锁A执行完毕后del把B的锁误删了。加token比对谁拿的锁谁来删。eval执行Lua脚本保证判断与删除两个操作的原子性。Redis是单线程执行命令Lua脚本在Redis内部是原子执行的中间不会被其他客户端的命令插入。我第一次写分布式锁时就是偷懒直接del结果线上出现过一个订单支付回调并发时的锁误删问题。排查了很久才意识到锁的值被换过了。希望大家不要再踩这个坑。4.3 用List做任务队列rpoplpush解决消息丢失Redis做轻量级消息队列最常用的就是List的LPUSHBRPOP组合。BRPOP是阻塞读取队列里没有任务时会一直等待可以设置超时时间比RPOP轮询更高效。// 消费者代码阻塞读取队列 while (true) { $task $redis-brPop(queue:send_email, 5); if ($task) { process_task($task[1]); } }但BRPOP弹出即删除如果任务处理中途进程崩溃消息就丢了。要保证至少处理一次可以用RPOPLPUSH命令从主队列右侧弹出任务同时推入一个备份队列左侧处理完后再从备份队列里移除。// 使用PHP的rpoplpush方法 $task $redis-rpoplpush(queue:send_email, queue:send_email:backup); if ($task) { try { process_task($task); // 处理成功后从备份队列删除 $redis-lRem(queue:send_email:backup, $task, 1); } catch (\Exception $e) { // 记录日志稍后重新入队 } }这样即使进程崩溃任务还在备份队列里之后从备份队列恢复即可。这个方案在简单场景下替代RabbitMQ是够用的也是我处理中小项目异步任务的默认选择。5. 排错与工具链可视化客户端、日志和常见异常操作层面谈得差不多了最后聊点保命的内容——出了问题怎么排查。5.1 免费好用的可视化工具盘点redis-cli虽然强大但每次都敲命令查看key结构确实费劲。我在团队里推荐过几款可视化工具按场景选择工具特点适用场景Redis Desktop Manager (RDM)老牌工具新版需要订阅费用习惯老界面、公司有预算Another Redis Desktop ManagerRDM的开源替代版免费跨平台日常调试够用RedisInsightRedis官方出品的GUI工具想体验官方支持、需要分析图表的场景phpRedisAdmin部署在PHP项目里的Web端管理工具不方便安装桌面软件的内网服务器个人日常开发用Another Redis Desktop Manager最多它的key树形浏览、命令执行历史、过期时间编辑都很顺手。线上服务器排查问题则习惯用命令行工具毕竟生产环境装GUI不现实。5.2 排查问题常用的命令与日志开关遇到缓存怎么不生效某个key怎么还在这类问题我一般是按这个顺序排查# 1. 确认连接状态和内存使用 redis-cli info # 2. 确认key是否存在及剩余过期时间 redis-cli ttl user:profile:1001 # 3. 查看key存储的值类型 redis-cli type user:profile:1001 # 4. 查看实际内容注意如果是JSON字符串直接看原始输出 redis-cli get user:profile:1001如果想知道线上某个客户端到底发了哪些命令可以用Redis的MONITOR命令实时监听所有命令redis-cli monitor这个命令在生产上要谨慎使用它会放大Redis的消耗但对于临时定位谁在写这个key这类问题非常有效。Redis服务端日志开启方式# redis.conf loglevel notice logfile /var/log/redis/redis-server.log排查崩溃和重启原因时第一件事就是打开这个日志文件。5.3 连不上、卡顿、超时常见异常的处理经验我整理了一张自己踩过的异常对照表应该能覆盖大部分场景异常现象可能原因排查方向Connection refusedRedis服务没启动、端口不对、防火墙拦截redis-cli ping测试查看6379端口监听状态NOAUTH Authentication required服务端开启了密码客户端没调auth查看redis.conf的requirepass配置READONLY You cant write against a read only replica连接到了从库从库默认不可写确认连接配置是否指向了从节点IP或端口请求超时网络延迟、Redis操作了慢命令如KEYS、大key用slowlog查看慢命令检查是否存在大key内存满了无法写入maxmemory达到上限redis-cli info memory、redis-cli config get maxmemory-policyToo many open files客户端连接数超过系统文件描述符限制调整ulimit、Redis的maxclients配置针对大key的问题想多说一句。一个包含几百万个元素的集合、或者一个几百KB的字符串在Redis单线程模型下读取和删除都会阻塞其他命令。PHP侧读取大key导致接口耗时飙升的情况我踩过不止一次。平时可以定时用redis-cli --bigkeys扫描实例里的大key提前拆散或换成其他数据结构。另外一个关于Redis淘汰策略的小经验maxmemory-policy默认是noeviction也就是内存满了直接拒绝写入。对缓存业务来说通常建议设置为allkeys-lru让Redis在内存不足时自动淘汰最久没用的key。这个配置修改后要注意它不是只淘汰设置了过期时间的key而是所有key都可能被淘汰。如果实例里同时承载了业务计数器等不能丢的数据需要单独规划实例或key的优先级。个人在实际操作中的体会是PHP操作Redis本身并不难真正有价值的经验全在边界情况和故障恢复上。比如扩展版本匹配、序列化格式统一、缓存一致性顺序、锁的原子性释放、大key清理方式——这些细节在官方文档里很难一次性找齐基本都是生产环境教做人后才记住的。希望这篇文章能帮你少走几段弯路尤其是刚接触Redis的PHP开发者先把环境跑通再把五种数据结构用熟最后再到高并发场景里打磨细节这条路径是最稳的。
返回列表