首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
74
篇与
» admin
的结果
2026-05-15
微信小程序订阅消息实战:模板申请、一次性订阅与下发全链路
微信小程序订阅消息实战:模板申请、一次性订阅与长期订阅全链路模板消息下线后,订阅消息成了小程序触达用户的唯一官方通道。但一次性订阅"发一条扣一次"的机制、模板类目的限制、下发条件的坑,让不少后端同学在第一次接入时栽跟头。这篇从模板申请到后端下发,把完整链路和真实踩坑记录下来。一、订阅消息的两种类型类型规则适用场景一次性订阅用户订阅一次,只能下发一条订单发货、审核结果、活动提醒长期订阅订阅一次可反复下发仅限公共服务类目(政务、医疗、交通等)残酷现实:长期订阅模板的类目卡得非常严,普通电商/内容类小程序基本申请不到。所以对大多数团队来说,玩转"一次性订阅"才是重点。一次性订阅还有个经典场景化玩法:每次用户触发关键操作时都弹订阅授权(勾选"总是保持以上选择"后不再弹窗),积攒下发次数。二、申请模板的正确姿势进入 mp.weixin.qq.com → 功能 → 订阅消息 → 我的模板,从公共模板库挑选或申请新模板。关键词选择的原则:变量字段(thing、time、phrase 等)数量够用就好,越多下发时越容易出错thing 类型长度限制 20 个字符(以内),超了直接下发失败模板类目必须和小程序服务类目匹配,否则审核不过一旦模板审核通过,字段不能改,只能重新申请新模板——所以先把业务字段想清楚三、前端:请求订阅授权3.1 基础调用// 请求订阅授权 wx.requestSubscribeMessage({ tmplIds: ['tmpl_123456789'], // 最多3个模板 success(res) { console.log(res['tmpl_123456789']) // 'accept' 用户同意 // 'reject' 用户拒绝 // 'ban' 已被后台封禁/关闭 // 'filter' 该模板在 async 调用下被过滤 }, fail(err) { console.error(err.errMsg) // 例如 "requestSubscribeMessage:fail can only be invoked by user TAP gesture" } })核心限制:必须由用户点击行为直接触发。你不能在 onLoad 里静默调用,也不能 setTimeout 延迟调用——否则报 fail can only be invoked by user TAP gesture。3.2 引导订阅的时机设计一次性订阅的本质是"攒次数",所以要在用户动机最强的时刻请求:下单成功页 → 订单进度通知提交表单后 → 审核结果通知预约成功后 → 预约提醒// 提交订单成功后的订阅引导 async function submitOrder() { const order = await createOrder() // 订单创建成功后,在用户点击"提交"的同一个手势链路里请求订阅 try { const res = await wx.requestSubscribeMessage({ tmplIds: ['tmpl_order_status'] }) if (res['tmpl_order_status'] === 'accept') { // 上报后端:这个用户此订单的通知已授权 api.reportSubscribe({ orderId: order.id, templateId: 'tmpl_order_status', status: 'accept' }) } } catch (e) { // 用户拒绝或勾选了不再询问,不影响主流程 } }3.3 检测"总是保持以上选择"用户勾选"总是保持以上选择,不再询问"后,后续调用 wx.requestSubscribeMessage 不再弹窗,直接静默返回结果。可以调用 wx.getSetting 里的 subscriptionsSetting 判断:wx.getSetting({ withSubscriptions: true, success(res) { const mainSwitch = res.subscriptionsSetting.mainSwitch // 订阅消息总开关 const itemSettings = res.subscriptionsSetting.itemSettings // 各模板的勾选状态 if (mainSwitch && itemSettings?.['tmpl_123'] === 'accept') { // 用户已选"总是允许",直接攒次数成功 } } })如果用户关了总开关,只能引导去设置页:wx.openSetting 或提示文案引导。四、后端:下发订阅消息4.1 获取 access_token// Node.js 示例:access_token 有效期 7200 秒,务必缓存 const redis = require('redis') const client = redis.createClient() async function getAccessToken() { const cached = await client.get('wx:access_token') if (cached) return cached const res = await axios.get( 'https://api.weixin.qq.com/cgi-bin/token', { params: { grant_type: 'client_credential', appid: process.env.WX_APPID, secret: process.env.WX_SECRET } }) // {"access_token":"xxx","expires_in":7200} await client.set('wx:access_token', res.data.access_token, 'EX', 7000) return res.data.access_token }高频坑:access_token 有每日调用限额,且重复获取会让旧 token 失效。多实例部署时一定要用集中式缓存(Redis),不要每个实例自己刷。4.2 下发消息async function sendSubscribeMessage(openid, orderId) { const token = await getAccessToken() const res = await axios.post( `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, { touser: openid, template_id: 'tmpl_order_status', page: `pages/order/detail?id=${orderId}`, // 点击消息跳转的页面 miniprogram_state: 'formal', // developer/trial/formal lang: 'zh_CN', data: { character_string1: { value: 'DD202601150042' }, thing2: { value: '您的订单已发货' }, // thing 最长20字 time3: { value: '2026-01-15 14:30' } } }) // 常见错误码 // 43101: 用户拒绝接收消息(订阅次数用完了) // 47003: 模板参数不准确(字段类型/长度不符) // 40003: openid 不属于这个 appid return res.data }4.3 错误码 43101 的处理策略43101 是日常最高频的错误——下发时用户没有剩余订阅次数。这不是异常,是业务常态,处理策略:下发前先查剩余次数(自己记账):前端每次 accept 时上报,后端维护 openid + template_id 的剩余次数次数为 0 时跳过下发,不要反复重试浪费调用额度在用户下次打开小程序时,用运营位引导再次订阅CREATE TABLE subscribe_quota ( id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL, template_id VARCHAR(64) NOT NULL, quota INT NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_openid_tmpl (openid, template_id) );前端 accept 一次 quota + 1,下发成功 quota - 1,43101 时归零。五、测试环境与体验版miniprogram_state 参数决定用户点击消息后跳转的版本:developer:跳开发版(需要 wx.openSetting 相关的开发者权限)trial:跳体验版formal:跳正式版本地联调流程:开发者工具里模拟订阅 → 后端用 trial 状态下发 → 真机(体验版二维码)收消息点击验证跳转。注意:开发者工具收不到订阅消息,只能真机收。六、完整时序图用户 小程序 后端 微信服务器 │ 点击"提交订单" │ │ │ │────────────────>│ │ │ │ │ wx.requestSubscribe │ │ │ │──────────────────────────────────────────> │ │ 弹出授权弹窗 │ │ │ │<─────────────────────────────────────────────────────────────│ │ 点击"允许" │ │ │ │────────────────>│ accept 回调 │ │ │ │──上报订阅成功────────>│ quota+1 │ │ │ │ │ │ ......订单状态变更...... │ │ │ │ │ sendSubscribeMsg │ │ │ │────────────────────>│ │ 收到订阅消息 │ │ │ │<──────────────────────────────────────────────────────────────│ │ 点击消息跳详情页 │ │ │七、避坑清单坑现象解法非用户手势调用fail: can only be invoked by user TAP gesture只能在 tap 回调链路里调用thing 字段超长47003 模板参数不准确下发前做长度裁剪(≤20字)43101 频繁出现用户没攒够订阅次数记账管理 quota,关键动作多攒access_token 互相顶掉线上偶发 40001Redis 集中缓存,提前 200s 刷新开发工具收不到消息联调两眼一抹黑用真机体验版 + trial 状态模板字段想改无解,模板不可改申请新模板,旧模板下线time 字段格式47003用 yyyy年M月d日 HH:mm 或 yyyy-MM-dd HH:mm写在最后订阅消息的设计哲学是"用户授权一次,你触达一次",这在产品层面倒逼你把通知做得更有价值——没有价值的消息,用户不会给你攒次数。技术上记住三件事:手势触发、quota 记账、token 集中缓存,剩下的都是模板字段的体力活。
2026年05月15日
107 阅读
0 评论
0 点赞
2026-05-12
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式原生 tabBar 配置简单,但样式自由度太低——中间凸起按钮、自定义字体图标、深色模式适配、消息红点动画,这些需求 app.json 里的 tabBar 都做不到。好在微信提供了 custom-tab-bar 方案。这篇把完整的落地方案和那些官方文档没写的坑都过一遍。一、为什么要自定义 tabBar先看原生 tabBar 的三个硬伤:图标只能是本地图片,不支持 iconfont,多色图标要切两套(普通/选中)中间凸起样式做不了,电商类 App 常见的"发布"大按钮没法实现红点/角标能力弱,wx.setTabBarBadge 只能显示数字,做不了小红点+动画而 custom-tab-bar 的原理是:app.json 里开启 "custom": true 后,每个 tabBar 页面底部会渲染一个独立的组件实例,完全由你自己实现。二、基础搭建2.1 开启配置// app.json { "tabBar": { "custom": true, "color": "#666666", "selectedColor": "#07C160", "backgroundColor": "#ffffff", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/category/category", "text": "分类" }, { "pagePath": "pages/publish/publish", "text": "发布" }, { "pagePath": "pages/message/message", "text": "消息" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] } }注意:即使全部自定义,list 字段仍然要完整声明——微信靠它识别哪些页面是 tabBar 页面。color 等字段也建议保留,作为兜底(自定义组件加载失败时会降级显示原生 tabBar)。2.2 创建组件目录目录名固定为 custom-tab-bar,位置在项目根目录(与 pages 平级,不是组件目录):├── custom-tab-bar/ │ ├── index.js │ ├── index.json │ ├── index.wxml │ └── index.wxss ├── pages/ └── app.json// custom-tab-bar/index.json { "component": true }2.3 组件实现// custom-tab-bar/index.js Component({ data: { selected: 0, color: '#666666', selectedColor: '#07C160', list: [ { pagePath: '/pages/index/index', text: '首页', icon: 'home' }, { pagePath: '/pages/category/category', text: '分类', icon: 'category' }, { pagePath: '/pages/publish/publish', text: '发布', icon: 'publish', center: true }, { pagePath: '/pages/message/message', text: '消息', icon: 'message', badge: true }, { pagePath: '/pages/mine/mine', text: '我的', icon: 'mine' } ] }, methods: { switchTab(e) { const url = '/' + e.currentTarget.dataset.path wx.switchTab({ url }) } } })<!-- custom-tab-bar/index.wxml --> <view class="tab-bar"> <view wx:for="{{list}}" wx:key="pagePath" class="tab-item {{item.center ? 'center' : ''}}" data-path="{{item.pagePath}}" data-index="{{index}}" bindtap="switchTab" > <!-- 中间凸起按钮 --> <view wx:if="{{item.center}}" class="center-btn"> <text class="iconfont icon-{{item.icon}}"></text> </view> <!-- 普通按钮 --> <block wx:else> <view class="icon-wrap"> <text class="iconfont icon-{{item.icon}}"></text> <view wx:if="{{item.badge && unreadCount > 0}}" class="badge">{{unreadCount}}</view> </view> <view class="text" style="color: {{selected === index ? selectedColor : color}}"> {{item.text}} </view> </block> </view> </view>三、最大的坑:每个页面一个独立实例这是 custom-tab-bar 最反直觉的地方:每个 tabBar 页面都有自己独立的一份 tabBar 组件实例。表现出来的 bug 是:从首页切到"消息"页,tabBar 上的选中态不更新,还停留在"首页"。3.1 解决方案:页面 onShow 同步选中态// pages/message/message.js Page({ onShow() { if (typeof this.getTabBar === 'function' && this.getTabBar()) { this.getTabBar().setData({ selected: 3 // 当前页在 list 中的索引 }) } } })每个 tabBar 页面的 onShow 都要写这段。可以封装一个高阶函数减少重复:// utils/tab-bar.js function withTabBar(pageConfig, tabIndex) { const originalOnShow = pageConfig.onShow pageConfig.onShow = function () { if (typeof this.getTabBar === 'function' && this.getTabBar()) { this.getTabBar().setData({ selected: tabIndex }) } originalOnShow && originalOnShow.call(this) } return pageConfig } module.exports = { withTabBar } // 页面使用 Page(withTabBar({ // 原有配置 }, 3))3.2 实例隔离带来的另一个问题:状态不同步消息红点数量存在全局状态里,但 tabBar 是多个实例——首页实例更新了 unreadCount,消息页的实例还是旧值。解法是把未读数等共享状态放到全局 store 或本地缓存,每个实例在 attached 生命周期里读取:// custom-tab-bar/index.js Component({ lifetimes: { attached() { const app = getApp() this.setData({ unreadCount: app.globalData.unreadCount }) } } })配合一个极简的发布订阅,让所有实例响应式更新:// custom-tab-bar/index.js Component({ lifetimes: { attached() { const app = getApp() this._onUnreadChange = (count) => this.setData({ unreadCount: count }) app.on('unreadChange', this._onUnreadChange) }, detached() { getApp().off('unreadChange', this._onUnreadChange) } } })四、胶囊按钮对齐自定义 tabBar 后,页面内容区的高度计算会变复杂,尤其要处理和右上角胶囊按钮的对齐关系。获取胶囊位置信息:// custom-tab-bar/index.js Component({ lifetimes: { attached() { const menuButton = wx.getMenuButtonBoundingClientRect() const systemInfo = wx.getSystemInfoSync() // tabBar 整体高度 = 胶囊底部 + 上间距 + 内容高度 const tabBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height + 50 this.setData({ tabBarHeight }) } } })自定义导航栏页面同样需要这段逻辑,把 tabBarHeight 存到全局,页面 onLoad 时读取,保证自定义导航栏和 tabBar 视觉上同一套高度体系。五、深色模式适配app.json 开启 "darkmode": true 后,自定义 tabBar 不会自动变色,需要手动监听:// app.json { "darkmode": true, "themeLocation": "theme.json" }// custom-tab-bar/index.js Component({ data: { theme: 'light' }, lifetimes: { attached() { const app = getApp() this.setData({ theme: app.globalData.theme || 'light' }) // 监听系统主题切换 this._onThemeChange = ({ theme }) => this.setData({ theme }) wx.onThemeChange(this._onThemeChange) }, detached() { wx.offThemeChange(this._onThemeChange) } } })WXSS 里用 CSS 变量切换:/* custom-tab-bar/index.wxss */ .tab-bar { --bg: #ffffff; --text: #666666; background: var(--bg); } .tab-bar.dark { --bg: #1f1f1f; --text: #999999; background: var(--bg); }坑:微信开发者工具模拟深色模式在部分版本有 bug,真机预览才准。另外 iOS 上 wx.onThemeChange 回调时机比页面 onShow 晚,首次进入深色模式下的页面会闪一下白色——可以在 app.js 的 onLaunch 里提前用 wx.getSystemInfoSync().theme 初始化一次。六、性能与体验细节tabBar 组件不要放业务请求。它是每个 tab 页都要实例化的组件,接口请求放这里会导致切换 tab 重复请求。只做状态展示。切页动画的"延迟感"。wx.switchTab 本身有页面切换开销,如果再在 tabBar 的 tap 回调里做动画,会显得卡。建议 tap 时立即更新本组件的选中态,不要等页面 onShow 回来再切:switchTab(e) { const { path, index } = e.currentTarget.dataset this.setData({ selected: index }) // 立即切换,不等 onShow wx.switchTab({ url: '/' + path }) }页面 onShow 里的同步逻辑保留作为兜底(覆盖 wx.switchTab API 直接调用、其他页面跳转回来的场景)。中间凸起按钮的点击区域。凸出的部分超出了 tabBar 容器,注意 overflow: hidden 别加在外层,同时用 padding 扩大热区到 88rpx 以上。七、避坑清单问题原因解法选中态不更新每个 tab 页独立实例各页面 onShow 里 getTabBar().setData红点数不同步实例间状态隔离全局发布订阅 or 本地缓存首次进入闪白主题初始化晚app.js onLaunch 提前读 theme真机不显示 tabBar目录名/位置错误必须是根目录 custom-tab-bar切 tab 闪烁setData 时序tap 时先本地切选中态图片资源路径失效组件内相对路径用绝对路径 /images/xxx写在最后custom-tab-bar 的本质是"把 tabBar 当成一个跨页面共享的组件来管理"。理解了多实例这个核心设定,选中态同步、状态共享、主题响应这些问题的方案就都顺理成章了。如果你的项目 tab 样式并不复杂,原生 tabBar + wx.setTabBarItem 动态改文案其实也够用,不要为了自定义而自定义。
2026年05月12日
255 阅读
0 评论
0 点赞
2026-05-10
Redis 缓存实战:穿透、击穿、雪崩三大问题解决方案
前言Redis 作为缓存使用时,会遇到三大经典问题:缓存穿透、缓存击穿和缓存雪崩。这三个问题在实际生产中频繁出现,处理不当可能导致数据库被压垮甚至服务不可用。本文将深入分析每个问题的成因并给出完整解决方案。一、缓存穿透1.1 问题描述用户请求的数据在缓存和数据库中都不存在,每次请求都直接打到数据库。常见于恶意攻击(用不存在的 ID 大量请求)或业务异常数据。请求 → 缓存MISS → 数据库MISS → 返回空 ↑ │ └──── 不断重复 ──────────────────────┘1.2 危害数据库承受大量无效查询如果是恶意攻击,可能打垮数据库浪费服务器资源1.3 解决方案方案一:缓存空值function getUser($id) { $cacheKey = "user:{$id}"; $data = $redis->get($cacheKey); if ($data !== false) { // 命中缓存(包括空值缓存) return $data === 'NULL' ? null : json_decode($data, true); } // 查数据库 $data = $db->query("SELECT * FROM users WHERE id = ?", [$id]); if (empty($data)) { // 缓存空值,设置较短过期时间(60秒) $redis->setex($cacheKey, 60, 'NULL'); return null; } $redis->setex($cacheKey, 3600, json_encode($data)); return $data; }优点:实现简单缺点:可能缓存大量空值,占用内存;数据后来新增了也不会被感知(除非过期)方案二:布隆过滤器(Bloom Filter)// 系统初始化时,将所有存在的ID加入布隆过滤器 function initBloomFilter() { $redis->del('bloom:user_ids'); $userIds = $db->query("SELECT id FROM users"); foreach ($userIds as $id) { // 使用多个哈希函数 bloomAdd('bloom:user_ids', $id); } } // 检查ID是否可能存在 function bloomAdd($key, $value) { $hashes = multiHash($value); foreach ($hashes as $h) { $redis->setBit($key, $h, 1); } } function bloomExists($key, $value) { $hashes = multiHash($value); foreach ($hashes as $h) { if ($redis->getBit($key, $h) == 0) { return false; // 一定不存在 } } return true; // 可能存在(有误判率) } // 请求处理 function getUser($id) { // 先过布隆过滤器 if (!bloomExists('bloom:user_ids', $id)) { return null; // 一定不存在,直接返回 } // 再查缓存和数据库 // ... }方案三:接口层参数校验// 在入口处拦截非法参数 function getUser($id) { // ID 必须是正整数 if (!is_numeric($id) || $id <= 0 || $id > PHP_INT_MAX) { throw new InvalidArgumentException('Invalid user ID'); } // 其他业务规则校验 if (strlen($id) > 10) { throw new InvalidArgumentException('ID too long'); } // 正常流程... }二、缓存击穿2.1 问题描述某个热点 key 突然过期(或被删除),大量并发请求同时访问这个 key,全部穿透到数据库。热点key过期 │ ├──→ 请求1 → 缓存MISS → 查DB ──┐ ├──→ 请求2 → 缓存MISS → 查DB ──┤ ├──→ 请求3 → 缓存MISS → 查DB ──┤ ├──→ 请求N → 缓存MISS → 查DB ──┤ │ │ └──────── 数据库被压垮 ◄────────┘2.2 解决方案方案一:互斥锁(推荐)function getHotData($key) { $data = $redis->get($key); if ($data !== false) { return json_decode($data, true); } // 获取互斥锁 $lockKey = "lock:{$key}"; $lockValue = uniqid('', true); // SET NX EX 尝试加锁 $locked = $redis->set($lockKey, $lockValue, ['NX', 'EX' => 10]); if ($locked) { try { // 再次检查缓存(双重检查) $data = $redis->get($key); if ($data !== false) { return json_decode($data, true); } // 查数据库 $data = $db->query("SELECT * FROM hot_table WHERE key_name = ?", [$key]); // 写入缓存 $redis->setex($key, 3600, json_encode($data)); return $data; } finally { // 释放锁(Lua脚本保证原子性) $lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; $redis->eval($lua, [$lockKey, $lockValue], 1); } } else { // 没拿到锁,短暂等待后重试 usleep(100000); // 100ms return getHotData($key); // 递归重试 } }方案二:逻辑过期不给 key 设置 TTL,而是在 value 中存储逻辑过期时间。后台异步刷新:function getWithLogicalExpire($key, $expireSeconds) { $data = $redis->get($key); if ($data === false) { return null; // 首次加载,需要手动初始化 } $cached = json_decode($data, true); $now = time(); // 未逻辑过期,直接返回 if ($cached['expire_at'] > $now) { return $cached['data']; } // 已逻辑过期,尝试获取锁刷新 $lockKey = "lock:refresh:{$key}"; $locked = $redis->set($lockKey, '1', ['NX', 'EX' => 10]); if ($locked) { // 后台异步刷新(非阻塞) // 实际项目中可以用队列或协程 go(function() use ($key, $expireSeconds) { $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]); $redis->set($key, json_encode([ 'data' => $data, 'expire_at' => time() + $expireSeconds, ])); $redis->del("lock:refresh:{$key}"); }); } // 无论是否刷新,先返回旧数据(不阻塞用户) return $cached['data']; }方案三:永不过期 + 主动更新// 热点数据不设置过期时间 $redis->set('hot:ranking', json_encode($data)); // 不设置 TTL // 数据变更时主动更新缓存 function updateRanking() { $data = $db->query("SELECT * FROM ranking ORDER BY score DESC LIMIT 100"); $redis->set('hot:ranking', json_encode($data)); // 仍然不设置 TTL } // 定时任务刷新 // crontab: */10 * * * * php artisan ranking:refresh三、缓存雪崩3.1 问题描述大量 key 在同一时间集中过期,或者 Redis 宕机,导致大量请求同时打到数据库。大量key同时过期 │ ├──→ 请求1 → MISS → 查DB ──┐ ├──→ 请求2 → MISS → 查DB ──┤ ├──→ 请求3 → MISS → 查DB ──┤ ├──→ 请求N → MISS → 查DB ──┤ │ │ └──── 数据库被压垮 ◄────────┘3.2 与缓存击穿的区别对比缓存击穿缓存雪崩影响范围单个热点 key大量 key原因热点 key 过期大量 key 同时过期 / Redis 宕机量级小大3.3 解决方案方案一:随机过期时间function cacheData($key, $data, $baseTTL = 3600) { // 在基础TTL上加随机偏移,避免同时过期 $randomTTL = $baseTTL + mt_rand(0, 600); // 0~10分钟随机 $redis->setex($key, $randomTTL, json_encode($data)); } // 批量缓存时尤其重要 function batchCache($items) { foreach ($items as $key => $value) { $ttl = 3600 + mt_rand(0, 1800); // 1~1.5小时随机 $redis->setex($key, $ttl, json_encode($value)); } }方案二:多级缓存function getWithMultiLevel($key) { // L1: 本地缓存(进程内) $data = $localCache->get($key); if ($data !== null) { return $data; } // L2: Redis 缓存 $data = $redis->get($key); if ($data !== false) { $localCache->set($key, $data, 60); // 本地缓存60秒 return json_decode($data, true); } // L3: 数据库 $data = $db->query("SELECT * FROM table WHERE key_name = ?", [$key]); if ($data) { $redis->setex($key, 3600 + mt_rand(0, 600), json_encode($data)); $localCache->set($key, $data, 60); } return $data; }方案三:熔断降级function getProductList($categoryId) { try { $data = $redis->get("products:{$categoryId}"); if ($data !== false) { return json_decode($data, true); } // 数据库查询带超时和限流 $data = $db->query("SELECT * FROM products WHERE category_id = ?", [$categoryId], ['timeout' => 1]); $redis->setex("products:{$categoryId}", 3600 + mt_rand(0, 600), json_encode($data)); return $data; } catch (Exception $e) { // 降级:返回默认数据或静态数据 return getDefaultProductList(); } }方案四:Redis 高可用# Redis Sentinel 哨兵模式自动故障转移 sentinel monitor mymaster 192.168.1.10 6379 2 sentinel down-after-milliseconds mymaster 30000 sentinel failover-timeout mymaster 180000 # Redis Cluster 集群模式 # 数据分散在多个节点,单节点宕机不影响整体四、综合对比问题本质核心方案推荐组合缓存穿透查询不存在数据布隆过滤器 + 空值缓存布隆过滤器 + 短TTL空值缓存击穿热点key过期互斥锁 + 双重检查互斥锁 + 逻辑过期缓存雪崩大量key同时过期随机TTL + 多级缓存随机TTL + 熔断降级五、完整防护代码示例class CacheService { private $redis; private $db; private $localCache = []; /** * 安全缓存读取(防穿透+击穿+雪崩) */ public function get($key, $dbQuery, $ttl = 3600) { // 1. 本地缓存 if (isset($this->localCache[$key])) { return $this->localCache[$key]; } // 2. Redis 缓存 $data = $this->redis->get($key); if ($data !== false) { if ($data === 'NULL') return null; $this->localCache[$key] = json_decode($data, true); return $this->localCache[$key]; } // 3. 互斥锁防击穿 $lockKey = "lock:{$key}"; $locked = $this->redis->set($lockKey, '1', ['NX', 'EX' => 10]); if (!$locked) { usleep(100000); // 等待100ms return $this->get($key, $dbQuery, $ttl); // 重试 } try { // 双重检查 $data = $this->redis->get($key); if ($data !== false) { return $data === 'NULL' ? null : json_decode($data, true); } // 4. 查数据库 $data = $dbQuery(); // 5. 缓存(随机TTL防雪崩) $randomTTL = $ttl + mt_rand(0, 300); if ($data === null) { // 空值缓存(短TTL防穿透) $this->redis->setex($key, 60, 'NULL'); } else { $this->redis->setex($key, $randomTTL, json_encode($data)); $this->localCache[$key] = $data; } return $data; } finally { $this->redis->del($lockKey); } } } // 使用 $cache = new CacheService(); $user = $cache->get("user:1001", function() use ($db) { return $db->query("SELECT * FROM users WHERE id = 1001"); }, 3600);总结缓存三大问题的核心防护策略:穿透:布隆过滤器拦截 + 空值缓存兜底击穿:互斥锁保证只有一个请求查DB + 双重检查雪崩:随机TTL打散过期时间 + 多级缓存 + 熔断降级生产环境建议三者组合使用,形成完整的缓存防护体系。
2026年05月10日
6 阅读
0 评论
0 点赞
2026-05-09
Redis 数据类型与核心命令详解:从 String 到 ZSet
前言Redis 是目前最流行的内存数据库,凭借极高的读写性能和丰富的数据结构,被广泛应用于缓存、消息队列、分布式锁等场景。本文将系统讲解 Redis 的五大核心数据类型及其常用命令,帮助你快速上手。一、Redis 数据类型总览数据类型底层结构典型应用场景StringSDS(简单动态字符串)缓存、计数器、分布式锁Hashziplist / hashtable对象存储、用户信息Listquicklist(ziplist + 链表)消息队列、文章列表Setintset / hashtable标签、共同好友、去重ZSetziplist / skiplist + hashtable排行榜、延迟队列二、String(字符串)String 是 Redis 最基础的类型,一个 key 对应一个 value,最大支持 512MB。2.1 基本命令# 设置值 SET name "redis" SET name "redis" EX 30 # 30秒过期 SET name "redis" PX 30000 # 30000毫秒过期 SET name "redis" NX # 仅当key不存在时设置 SET name "redis" XX # 仅当key存在时设置 # 获取值 GET name # "redis" # 批量操作 MSET k1 "v1" k2 "v2" k3 "v3" MGET k1 k2 k3 # 1) "v1" 2) "v2" 3) "v3" # 追加 APPEND name " tutorial" # "redis tutorial" # 长度 STRLEN name # 14 # 自增自减(值必须是数字) SET counter 100 INCR counter # 101 INCRBY counter 10 # 111 DECR counter # 110 DECRBY counter 20 # 902.2 实战:页面访问计数器# 每次访问页面自增 INCR page:home:views # 1 INCR page:home:views # 2 INCR page:home:views # 3 # 获取今日访问量(按日期分key) INCR page:home:views:20260724 # 1 # 设置过期时间,自动清理历史数据 EXPIRE page:home:views:20260724 864002.3 实战:分布式锁(简单版)# 加锁:SET NX 保证原子性 SET lock:order:1001 "uuid-xxx" NX EX 30 # 释放锁(需要Lua脚本保证原子性) # eval "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:order:1001 uuid-xxx三、Hash(哈希)Hash 是一个键值对集合,特别适合存储对象。3.1 基本命令# 设置字段 HSET user:1001 name "张三" HSET user:1001 age 25 HSET user:1001 email "zhangsan@example.com" # 批量设置 HMSET user:1002 name "李四" age 30 email "lisi@example.com" # 获取字段 HGET user:1001 name # "张三" HMGET user:1001 name age # 1) "张三" 2) "25" # 获取所有字段 HGETALL user:1001 # 1) "name" # 2) "张三" # 3) "age" # 4) "25" # 5) "email" # 6) "zhangsan@example.com" # 获取所有字段名/值 HKEYS user:1001 # name, age, email HVALS user:1001 # 张三, 25, zhangsan@example.com # 字段数量 HLEN user:1001 # 3 # 删除字段 HDEL user:1001 email # 字段自增 HINCRBY user:1001 age 1 # 263.2 实战:购物车# 添加商品到购物车(user:1001 的购物车) HSET cart:1001 product:2001 2 # 商品2001,数量2 HSET cart:1001 product:2002 1 # 商品2002,数量1 # 修改数量 HINCRBY cart:1001 product:2001 1 # 数量+1 → 3 # 查看购物车 HGETALL cart:1001 # 移除商品 HDEL cart:1001 product:2002 # 清空购物车 DEL cart:1001四、List(列表)List 是有序的字符串列表,支持在头部或尾部插入/弹出元素。4.1 基本命令# 左/右推入 LPUSH messages "msg1" "msg2" # 列表: msg2, msg1 RPUSH messages "msg3" # 列表: msg2, msg1, msg3 # 左/右弹出 LPOP messages # "msg2" RPOP messages # "msg3" # 查看范围 LRANGE messages 0 -1 # 所有元素 LRANGE messages 0 2 # 前3个 # 长度 LLEN messages # 按索引获取 LINDEX messages 0 # 第一个元素 # 按索引设置 LSET messages 0 "new_msg" # 阻塞弹出(消息队列核心命令) BLPOP queue:task 30 # 阻塞最多30秒 BRPOP queue:task 304.2 实战:简单消息队列# 生产者:推入任务 LPUSH queue:email '{"to":"a@b.com","subject":"Hello"}' # 消费者:阻塞式弹出任务 BRPOP queue:email 0 # 0表示永久阻塞 # 优点:简单、原子性 # 缺点:无ACK机制,消息丢失需自行处理五、Set(集合)Set 是无序的、不重复的字符串集合。5.1 基本命令# 添加成员 SADD tags:article:1 "redis" "database" "cache" # 查看所有成员 SMEMBERS tags:article:1 # redis, database, cache # 是否是成员 SISMEMBER tags:article:1 "redis" # 1(true) # 成员数量 SCARD tags:article:1 # 3 # 移除成员 SREM tags:article:1 "cache" # 随机弹出 SPOP tags:article:1 # 随机移除并返回一个 SRANDMEMBER tags:article:1 2 # 随机返回2个(不移除)5.2 集合运算SADD set:a "1" "2" "3" "4" SADD set:b "3" "4" "5" "6" # 交集 SINTER set:a set:b # 3, 4 # 并集 SUNION set:a set:b # 1, 2, 3, 4, 5, 6 # 差集(a有但b没有) SDIFF set:a set:b # 1, 25.3 实战:共同关注# 用户A关注了 SADD follows:user:A "redis" "mysql" "docker" # 用户B关注了 SADD follows:user:B "redis" "nginx" "docker" # 共同关注 SINTER follows:user:A follows:user:B # redis, docker # A关注但B没关注 SDIFF follows:user:A follows:user:B # mysql # 推荐关注(B关注但A没关注) SDIFF follows:user:B follows:user:A # nginx六、ZSet(有序集合)ZSet 每个成员关联一个分数(score),按分数排序,是 Redis 最强大的数据结构之一。6.1 基本命令# 添加成员(带分数) ZADD ranking 100 "player1" ZADD ranking 200 "player2" ZADD ranking 150 "player3" ZADD ranking 300 "player4" # 按分数升序排名(0开始) ZRANGE ranking 0 -1 # player1, player3, player2, player4 ZRANGE ranking 0 -1 WITHSCORES # 带分数 # 按分数降序排名 ZREVRANGE ranking 0 -1 # player4, player2, player3, player1 ZREVRANGE ranking 0 -1 WITHSCORES # 按分数范围查询 ZRANGEBYSCORE ranking 100 200 # 100~200分之间 ZRANGEBYSCORE ranking "(100" 200 # 不包含100 # 获取成员分数 ZSCORE ranking "player2" # 200 # 获取成员排名 ZRANK ranking "player2" # 2(升序第3名,0开始) ZREVRANK ranking "player2" # 1(降序第2名) # 增加分数 ZINCRBY ranking 50 "player1" # player1分数变为150 # 成员数量 ZCARD ranking # 4 # 分数范围内的成员数 ZCOUNT ranking 100 200 # 36.2 实战:游戏排行榜# 玩家得分 ZADD game_rank 1500 "Alice" ZADD game_rank 2300 "Bob" ZADD game_rank 1800 "Charlie" ZADD game_rank 2100 "David" ZADD game_rank 1900 "Eve" # Top 3 ZREVRANGE game_rank 0 2 WITHSCORES # 1) "Bob" 2) "2300" # 3) "David" 4) "2100" # 5) "Eve" 6) "1900" # 查看某玩家排名 ZREVRANK game_rank "Alice" # 4(第5名) # 玩家加分 ZINCRBY game_rank 100 "Alice" # Alice: 1600 # 分数段统计 ZCOUNT game_rank 1500 2000 # 3人(Alice, Charlie, Eve)6.3 实战:延迟队列# 将任务加入延迟队列(score为执行时间戳) ZADD delay_queue 1721800500 "task1" # 5秒后执行 ZADD delay_queue 1721800600 "task2" # 15秒后执行 ZADD delay_queue 1721800700 "task3" # 25秒后执行 # 消费者轮询:取出已到期的任务 # 用 Lua 脚本保证原子性 # eval "local items = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1]) # if #items > 0 then # redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) # end # return items" 1 delay_queue <当前时间戳>七、其他常用命令7.1 Key 管理# 查找key(生产环境慎用 KEYS,用 SCAN) KEYS user:* # 所有以user:开头的key SCAN 0 MATCH user:* COUNT 100 # 渐进式扫描 # 过期时间 EXPIRE name 30 # 30秒后过期 EXPIREAT name 1721800000 # 在指定时间戳过期 TTL name # 查看剩余秒数(-1永久,-2已过期) PERSIST name # 移除过期时间 # 类型判断 TYPE name # string TYPE user:1001 # hash7.2 通用操作# 删除 DEL name # 删除单个 DEL name1 name2 # 删除多个 # 判断存在 EXISTS name # 1(存在) # 重命名 RENAME name newname总结类型核心特点一句话记忆String一对一万能缓存Hash一对多对象存储List有序可重复消息队列Set无序不重复去重与运算ZSet有序不重复排行榜掌握这五大类型和核心命令,就能应对 90% 以上的 Redis 使用场景。后续文章将深入持久化、缓存策略和集群部署。
2026年05月09日
17 阅读
0 评论
0 点赞
2026-04-29
Redis 持久化机制深度对比:RDB、AOF 与混合持久化
前言Redis 作为内存数据库,数据存储在 RAM 中,一旦服务器重启数据就会丢失。为了解决这个问题,Redis 提供了两种持久化机制:RDB(快照)和 AOF(追加日志)。本文将深入对比这两种机制的原理、配置和选择策略。一、RDB(Redis Database)1.1 工作原理RDB 是将 Redis 内存中的数据以二进制快照的形式写入磁盘。触发方式有三种:┌─────────────────────────────────────┐ │ Redis 内存数据 │ │ │ │ ┌───────┐ ┌───────┐ │ │ │ Key1 │ │ Key2 │ ... │ │ └───────┘ └───────┘ │ │ │ │ ▼ fork() 子进程 │ │ │ │ ┌─────────────────────┐ │ │ │ BGSAVE 子进程 │ │ │ │ 写入 dump.rdb │ │ │ └─────────────────────┘ │ └─────────────────────────────────────┘1.2 触发方式手动触发:# 阻塞式(生产环境禁用) SAVE # 后台异步执行(推荐) BGSAVE自动触发(配置文件):# redis.conf # 900秒内至少1个key变化 → 触发RDB save 900 1 # 300秒内至少10个key变化 → 触发RDB save 300 10 # 60秒内至少10000个key变化 → 触发RDB save 60 10000 # 禁用自动RDB # save ""其他触发场景:主从复制时,主节点自动触发 BGSAVE执行 SHUTDOWN 命令时执行 FLUSHALL 命令时(生成空快照)1.3 RDB 文件配置# redis.conf # RDB 文件名 dbfilename dump.rdb # 存储目录 dir /var/lib/redis # 压缩(默认开启,用 LZF 压缩) rdbcompression yes # 校验和(默认开启) rdbchecksum yes # BGSAVE 出错时停止写入(默认yes,推荐开启) stop-writes-on-bgsave-error yes1.4 RDB 优缺点优点:文件紧凑,体积小,适合备份和传输恢复速度快(直接加载二进制数据)对性能影响小(子进程异步执行)适合做冷备和灾难恢复缺点:宕机时会丢失最后一次快照后的所有数据fork 子进程时,如果数据量大,可能造成短暂阻塞不适合对数据完整性要求高的场景二、AOF(Append Only File)2.1 工作原理AOF 以追加的方式将每条写命令记录到日志文件中,重启时按顺序回放命令恢复数据。┌─────────────────────────────────────┐ │ Redis 内存数据 │ │ │ │ 客户端写入命令 │ │ │ │ │ ▼ │ │ ┌──────────┐ ┌──────────────┐ │ │ │ Redis执行 │ → │ 追加到AOF缓冲 │ │ │ │ 命令 │ │ 区 │ │ │ └──────────┘ └──────┬───────┘ │ │ │ │ │ ▼ fsync │ │ ┌──────┴───────┐ │ │ │ appendonly.aof│ │ │ └──────────────┘ │ └─────────────────────────────────────┘2.2 开启 AOF# redis.conf appendonly yes appendfilename "appendonly.aof"2.3 刷盘策略# 三种 fsync 策略 # always: 每条命令都fsync(最安全,性能最差) # everysec: 每秒fsync一次(推荐,最多丢1秒数据) # no: 由OS决定何时fsync(性能最好,可能丢较多数据) appendfsync everysec策略数据安全性性能适用场景always最高(不丢数据)最差对数据极其敏感everysec高(最多丢1秒)好推荐默认no低最好纯缓存场景2.4 AOF 重写随着时间推移,AOF 文件会越来越大。Redis 通过"重写"机制压缩文件:分析当前内存数据,用最少的命令重新生成 AOF 文件。# 手动触发重写 BGREWRITEAOF# redis.conf # AOF 文件大小比上次重写后增长 100% 时自动重写 auto-aof-rewrite-percentage 100 # AOF 文件最小重写体积(64MB) auto-aof-rewrite-min-size 64mb # 重写期间不执行 fsync(减少磁盘 IO) no-appendfsync-on-rewrite yes重写过程:原始 AOF: SET key1 "v1" SET key1 "v2" ← 覆盖 DEL key1 ← 删除 SET key1 "v3" ← 最终值 重写后: SET key1 "v3" ← 只保留最终状态2.5 AOF 优缺点优点:数据安全性高(最多丢 1 秒数据)日志格式可读,可手动修复支持实时持久化,适合对数据完整性要求高的场景缺点:文件体积比 RDB 大恢复速度比 RDB 慢(需要回放命令)重写时会 fork 子进程,大数据量时可能阻塞三、RDB + AOF 混合持久化Redis 4.0 引入混合持久化,结合两者的优点:# redis.conf aof-use-rdb-preamble yes工作原理:AOF 重写时,先以 RDB 格式写入当前内存快照重写期间的增量写命令以 AOF 格式追加在 RDB 数据之后┌────────────────────────────────────┐ │ 混合 AOF 文件 │ │ │ │ ┌──────────────────────────────┐ │ │ │ RDB 二进制数据(全量快照) │ │ │ │ - 恢复速度快 │ │ │ │ - 文件体积小 │ │ │ └──────────────────────────────┘ │ │ ┌──────────────────────────────┐ │ │ │ AOF 增量命令(重写期间) │ │ │ │ - 保证数据不丢 │ │ │ └──────────────────────────────┘ │ └────────────────────────────────────┘恢复流程:先加载 RDB 部分(快速恢复大部分数据)再回放 AOF 增量命令(补全重写期间的数据)四、完整配置示例# redis.conf 生产环境推荐配置 # ===== RDB 配置 ===== save 900 1 save 300 10 save 60 10000 stop-writes-on-bgsave-error yes rdbcompression yes rdbchecksum yes dbfilename dump.rdb dir /var/lib/redis # ===== AOF 配置 ===== appendonly yes appendfilename "appendonly.aof" appendfsync everysec # 混合持久化(Redis 4.0+) aof-use-rdb-preamble yes # AOF 重写 auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb no-appendfsync-on-rewrite yes五、持久化方案选择场景推荐方案理由纯缓存,可接受数据丢失仅 RDB性能最优,恢复快数据库,不可丢数据仅 AOF数据安全性高综合场景(推荐)混合持久化兼顾性能和安全灾备备份RDB + 定时备份文件小,传输快备份策略建议#!/bin/bash # 每日凌晨2点备份 RDB 文件 BACKUP_DIR="/backup/redis/$(date +%Y%m%d)" mkdir -p $BACKUP_DIR # 触发 BGSAVE redis-cli BGSAVE # 等待完成 while [ $(redis-cli INFO persistence | grep rdb_bgsave_in_progress | tr -d '\r') != "0" ]; do sleep 1 done # 复制文件 cp /var/lib/redis/dump.rdb $BACKUP_DIR/ # 保留最近7天 find /backup/redis/ -type d -mtime +7 -exec rm -rf {} \; echo "Backup completed: $BACKUP_DIR/dump.rdb"六、故障恢复AOF 文件损坏修复# 如果 AOF 文件损坏,Redis 无法启动 redis-check-aof --fix appendonly.aof # 检查 RDB 文件 redis-check-rdb dump.rdb从备份恢复# 1. 停止 Redis systemctl stop redis # 2. 替换 RDB 文件 cp /backup/redis/20260724/dump.rdb /var/lib/redis/ # 3. 如果不想用 AOF,临时关闭 # 修改 redis.conf: appendonly no # 4. 启动 Redis systemctl start redis # 5. 验证数据 redis-cli DBSIZE总结对比项RDBAOF混合持久化数据安全可能丢分钟级数据最多丢1秒最多丢1秒文件大小小大中恢复速度快慢快性能影响小(fork时短暂)中(每秒fsync)中推荐场景备份/缓存数据库生产推荐生产环境推荐开启混合持久化,同时定期备份 RDB 文件做异地灾备。
2026年04月29日
7 阅读
0 评论
0 点赞
1
...
7
8
9
...
15
0:00