ARTICLE DETAIL

资讯详情

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

微信小程序景点预约系统:ThinkPHP+Laravel双框架与Redis并发扣减实践

微信小程序景点预约系统:ThinkPHP+Laravel双框架与Redis并发扣减实践 去年接手了一个挺典型的项目景区要做基于微信小程序的景点预约系统后端技术栈是PHP并且同时用到了ThinkPHP和Laravel两个框架。起初我觉得这活儿不难——小程序端选日期、选时段、提交预约管理员后台配库存、看报表不就是两张表的事吗。真正上手才发现光是“库存不超卖”一个点就够我折腾好几天。这篇文章不贴全套代码重点把这套系统的架构思路、数据库设计、双框架分工、并发扣减方案、小程序端适配以及上线前后踩过的坑都梳理一遍。如果你正准备做预约类小程序或者你们团队也同时维护 ThinkPHP 和 Laravel 两套 PHP 应用这篇应该能帮你少走不少弯路。文中代码都做了简化核心逻辑可以直接迁移到自己的项目里。1. 项目要解决的问题与整体架构选型1.1 为什么需要一套景点预约系统景区最大的痛点不是“没人来”而是“人来得太集中”。节假日入口排队一小时是常态热门景点瞬时客流过大游客体验差安全管理压力也大。景区管理方需要的是把“大家都挤在上午10点”这个需求平滑分散到不同时段。所以预约系统要做的核心事情就三件让游客提前选择日期和时段、让景区提前知道每个时段有多少人、到现场通过核销码快速验票入园。踩坑点在于“提前知道人数”这个需求背后需要一个灵活到能应付旺季加场、雨天停场、每个景点每天不同开放时段的库存模型而不是简单一个总数字。1.2 技术栈的双框架决定ThinkPHP负责APILaravel负责后台为什么一个项目里同时出现 ThinkPHP 和 Laravel这是很多 PHP 团队的真实状态老系统跑着 ThinkPHP后来新项目逐步切到 Laravel两边都得维护不可能一夜之间全部重写。我在这套系统里的分工是这样ThinkPHP 负责小程序端 API。预约、登录、门票列表、核销这些接口压力大、迭代快用 ThinkPHP 写起来轻团队成员也都熟文档和中文资料多新人有问题能自己搜到答案。Laravel 负责管理后台。景区运营人员要配库存、看报表、管理景点信息Laravel 的 Eloquent ORM、Form Request 验证、队列、Artisan 命令行工具非常成熟做这类偏重后台管理的场景效率明显更高。两个框架共用同一个 MySQL 数据库和同一个 Redis 集群但进程完全隔离通过 Nginx 按路径分发。这样的双框架设计不是最优解但它是迁移成本最低、团队上手最快的方案。如果你的团队本来就有两套技术栈与其强行统一不如先把边界划清楚。1.3 系统模块划分与请求链路整个系统的模块大致分为四块用户端小程序微信登录、浏览景点、选择日期时段、提交预约、查看预约凭证。ThinkPHP API 服务处理小程序的全部请求负责用户鉴权、库存扣减、订单生成、核销验证。Laravel 管理后台景点信息管理、时段库存配置、预约记录查询、核销流水报表、操作员权限管理。核销端景区门口工作人员在小程序或后台里通过扫用户核销码完成验票。请求链路用文字描述就是用户小程序 - ThinkPHP API - MySQL / Redis 核销员小程序 - ThinkPHP API - MySQL / Redis 浏览器后台 - Laravel - MySQL / Redis两条主链路都走 ThinkPHPLaravel 只服务管理后台。这里最需要注意的是不要让用户请求穿透到 Laravel管理后台和用户端完全隔离出了问题互不影响。2. 数据库设计把“预约”这件事拆成可落地的模型2.1 核心数据表及其关系预约系统的核心表其实不多但每张表都不能想当然。我最终梳理出来的核心表如下表名作用关键字段users小程序用户openid, unionid, nickname, phonescenic_spots景点name, address, cover, status, daily_limitschedule_slots景点时段库存spot_id, date, start_time, end_time, total_num, booked_numbookings预约订单user_id, slot_id, booking_no, status, visit_date, visit_timeverify_records核销记录booking_id, verifier_id, verify_timeadmins后台管理员username, password_hash, spot_id, role这里最核心的关系是一个景点scenic_spots对应多个时段库存schedule_slots一个用户在一个时段只能有一条有效预约bookings。我在 bookings 表上加了一个唯一索引(user_id, slot_id, status)的部分索引思路不过 MySQL 不支持部分索引所以实际做成了“先查再插 锁兜底”后面并发部分会细说。2.2 库存设计按日期时段拆分还是按总量扣这是整个系统最开始就要想清楚的问题。很多第一次做预约系统的人直接在 scenic_spots 表里放一个remain_num字段每来一个预约就减 1。这个做法在小流量下没问题但完全没法满足景区“分时段限流”的需求。正确做法是引入schedule_slots表把“某天某景点某个时段”作为库存的最小颗粒度。举个例子拙政园 7 月 16 日上午场 8:00-12:00 放 2000 个号下午场 12:30-16:30 放 3000 个号那这一天的数据就是两条 slot 记录。total_num是时段总名额booked_num是已预约名额。剩余名额不用单独存一个字段查出来total_num - booked_num就是。之所以不冗余剩余数字段是为了避免后续更新时多个字段不一致的问题。时段的设计也要考虑业务灵活性上午场、下午场、夜场或者按小时切分都行。我建议在后台做一个“时段模板”功能管理员可以批量生成连续几十天的 slots不用一个一个手动建。2.3 订单状态机与超时回收预约订单不能只有“成功/失败”两个状态否则超时占位、用户取消、现场核销这些场景都处理不了。我设计的订单状态机如下PENDING占位中用户提交预约但还没最终确认比如需要支付预付款或需要填写游客信息。此时库存已经被扣减。CONFIRMED已预约用户确认完毕预约生效。USED已核销用户到景区扫码验票完成。CANCELED已取消用户主动取消或管理员取消。EXPIRED已过期超过预约日期仍未到场的订单。最容易被忽略的是 PENDING 状态的超时回收。如果用户提交了预约但卡在信息填写页面库存被占着其他真实想去的游客却被挡在外面这非常影响用户体验。我的处理方案是PENDING 状态只保留 15 分钟超过时间由定时任务把订单置为 EXPIRED同时把库存还回去。这里涉及一个关键问题什么时候扣减库存我的选择是提交预约时立即扣库存而不是确认时再扣。原因后面并发章节会详细解释。3. 后端接口实现ThinkPHP与Laravel的具体分工3.1 ThinkPHP端小程序API登录、景点列表、提交预约ThinkPHP 这边主要就是写 API 路由和控制器。开发时我把接口按版本管理路由文件里像这样// route/app.php use think\facade\Route; Route::post(auth/login, Auth/login); Route::get(spots, Spots/index); Route::get(spots/:id/slots, Spots/slots); Route::post(booking, Booking/submit); Route::post(booking/cancel, Booking/cancel); Route::get(booking/list, Booking/myBookings); Route::post(verify, Verify/scan); // 核销员扫码提交预约的接口逻辑大概是这样的流程先验签和参数校验然后判断当前 slot 是否还有库存有就生成订单并返回预约凭证。这里我特意把“生成订单”和“扣库存”放在一个事务里同时处理避免中间状态不一致。ThinkPHP 在写这类接口时有一个很爽的点它内置的validate类做参数校验非常方便不需要像 Laravel 那样额外引入 Form Request。我习惯把所有接口的返回格式统一封装成{code, msg, data}3.2 Laravel端管理后台景点管理与库存配置Laravel 端我给景区管理员做的功能主要有景点 CRUD、批量生成时段库存、查看预约报表、核销记录、操作员权限。这里表现最突出的是 Laravel 的Eloquent 关联和Artisan 命令。例如批量生成未来 30 天的时段库存我用一条 Artisan 命令解决// app/Console/Commands/GenerateSlots.php foreach ($dates as $date) { foreach ($spot-timeSlots as $template) { ScheduleSlot::updateOrCreate( [spot_id $spot-id, date $date, start_time $template-start_time], [end_time $template-end_time, total_num $spot-daily_limit / count($timeSlots)] ); } }用 Artisan 命令而不是页面表单来批量生成好处是可以在后台用 cron 定时执行旺季来了提前把未来一个月的库存都铺好不用运营人员每天手动点。Laravel 自带的php artisan schedule:run也能直接挂载定时任务这块 ThinkPHP 需要额外装扩展体验上确实 Laravel 更省心。后台登录我用的 Laravel 自带的 session 认证机制配合中间件做角色权限控制。同一个域名下 Laravel 的 session 只会存在/admin路径的 cookie 里不会和前面的小程序 Token 体系冲突这点在后面避坑里还会再提。3.3 双框架下的Token鉴权与用户身份统一两个框架共用一个 MySQL 和 Redis但用户体系必须分开小程序用户走 users 表后台操作员走 admins 表。千万不要图省事共用一张用户表权限模型完全不同后面一定会被需求拖死。小程序端的用户鉴权我用的是自定义 Token登录时由 ThinkPHP 生成一个 32 位随机字符串存到 Redis过期时间 7 天返回给小程序端小程序请求时放在Authorization头里。中间件里统一校验// app/middleware/AuthMiddleware.php $token $request-header(Authorization); $userId \think\facade\Cache::get(token_ . $token); if (!$userId) { return json([code 401, msg 登录已过期]); } $request-userId $userId;为什么不直接用 JWT主要是预约系统里的 Token 需要能主动失效用户退出、管理员封禁JWT 做不到立刻失效。用 Redis 存 Token 的好处是可以随时删也能顺带管理过期时间。管理员端则直接使用 Laravel 的 session 机制两边互不干扰。4. 微信小程序端从登录到核销的完整链路4.1 登录态设计wx.login与服务端Session的映射小程序的登录流程是wx.login()拿到临时 code传给后端后端拿 code 去微信接口换 openid。这是标准流程但有两个容易踩的细节。第一个code 只能用一次所以前端必须保证每次登录都重新wx.login()不能拿旧 code 去换。第二个后端拿到 openid 后先查 users 表存在就直接登录不存在要先创建用户再登录。注意不要在小程序端把用户信息传来传去所有用户身份都以服务端查到的 openid 为准。我在小程序端做了一个统一的登录方法// utils/auth.js function login() { return new Promise((resolve, reject) { wx.login({ success(res) { wx.request({ url: ${config.baseUrl}/auth/login, method: POST, data: { code: res.code }, success: (resp) { if (resp.data.code 0) { wx.setStorageSync(token, resp.data.data.token); resolve(resp.data.data); } else { reject(resp.data); } } }); } }); }); }这里登录成功之后我会把token存到storage后面所有请求直接从 storage 里取。Token 过期就清理 storage 并跳转登录页面保证用户无感重新登录。4.2 请求封装与状态码统一处理小程序的wx.request是很底层的 API如果不做封装每个页面都要处理 401、500、网络超时代码会非常难看。我在项目里单独封装了一个request.js// utils/request.js const request (url, method GET, data {}) { const token wx.getStorageSync(token); return new Promise((resolve, reject) { wx.request({ url: ${config.baseUrl}${url}, method, data, header: { Authorization: token }, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else if (res.data.code 401) { wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); reject(res.data); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(res.data); } }, fail: (err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); };统一封装的收益在后期非常明显后端只要调整code的约定小程序端只需改这一个文件。建议在所有接口中都使用统一的{code, msg, data}结构前端处理逻辑简单后端也容易维护。4.3 预约页面的日期选择与场次展示预约页面是整个小程序交互最复杂的部分主要包含日期选择器和场次列表。日期选择我一开始想引入第三方组件但后来发现自定义日历其实更可控小程序原生组件加上scroll-view横向滚动日期成本很低。预约页面的核心逻辑是选一个日期向后端请求该日期下某景点的所有 slots展示时段、剩余名额、是否约满。这里做了一个“剩余名额不足时置灰”的处理避免用户点了提交才被告知没号。这里要提醒的是缓存问题。景点列表可以缓存 5 分钟但 slot 库存信息绝对不能长时间缓存否则会出现页面显示“还剩 100 个”但用户点进去提交却提示“已约满”。微信小程序的wx.setStorageSync虽然能设置缓存但这类动态数据我宁愿每次请求实时获取。4.4 核销码生成与扫码验票每个预约成功后的订单我会生成一个唯一的核销码规则是booking_no订单号加上一个随机盐做 MD5生成 16 位字符串。核销码以二维码的形式展示在小程序“我的预约”页面。核销员的扫码流程用微信小程序的wx.scanCode扫用户的二维码拿到核销码字符串之后调后端的/verify接口。后端逻辑要先判断核销员是否有该景区的核销权限然后校验核销码是否存在、订单状态是否为 CONFIRMED最后把状态更新为 USED 并写入 verify_records。防伪是这个环节的重点。核销码不能是简单的自增 ID否则用户可以猜到别人的码导致冒充入园。推荐用随机字符串 签名后端只认签名过的码。同时核销码要设置有效期比如预约当天有效过期就不能核销。4.5 自定义导航栏与顶部高度的适配预约页面如果做沉浸式设计需要自定义导航栏那就会遇到一个经典问题顶部导航栏高度到底是多少。不同手机不一样不能写死一个 44px 或 64px。正确的适配方式是通过wx.getMenuButtonRect获取胶囊按钮的位置然后动态计算导航栏高度const menu wx.getMenuButtonBoundingClientRect(); const system wx.getSystemInfoSync(); const navBarHeight menu.top menu.height (menu.top - system.statusBarHeight);这个计算逻辑已经非常成熟直接把返回值设置到页面样式里。我有一次图省事写死高度结果在刘海屏手机上导航栏直接顶到状态栏里按钮和胶囊重叠用户体验非常糟糕。5. 并发预约与库存扣减我和“超卖”搏斗的过程5.1 超卖是怎么发生的所谓超卖就是系统显示还剩 100 个名额最后实际卖出去 120 个。这种问题的根源在于多用户同时读到同一个库存值然后各自往下走。举例说明假设某个 slot 剩余 1 个名额。用户 A 和用户 B 同时提交预约两个请求在数据库层面都执行SELECT booked_num FROM schedule_slots WHERE id1都读到booked_num99, total_num100都认为还有名额然后都执行UPDATE ... SET booked_num100。表面看都成功了但实际预约成功了 2 个。这种读-改-写的竞态问题靠数据库的普通查询是解决不了的。最简单的规避方式是给更新加上条件UPDATE ... SET booked_num booked_num 1 WHERE id ? AND booked_num total_num通过affected_rows判断是否抢到名额。但这样并发高的时候仍然有性能瓶颈而且订单表和库存表的一致性要靠数据库事务来保证。5.2 Redis原子扣减的落地设计我最终采用的是Redis 原子操作 数据库兜底的方案。核心思路在 Redis 里为每个 slot 维护一个剩余库存的 key每次预约请求先对 Redis 做原子 DECR 操作返回值小于 0 说明没号了直接返回失败DECR 成功则继续创建订单。具体是这么做的-- lua/decr_stock.lua local key KEYS[1] local current redis.call(DECR, key) if current 0 then redis.call(INCR, key) return -1 end return current通过 Lua 脚本把 DECR 和回滚放在一个原子操作里避免并发下 DECR 到负数。为什么用 Lua 而不直接DECR因为单独 DECR 变成负数之后需要另一次 INCR 才能恢复这两步之间可能被其他请求插入导致库存数据错乱。用 Lua 脚本保证整个判断与回滚操作在 Redis 单线程内执行没有任何并发间隙。库存初始化时把schedule_slots.total_num - booked_num写入 Redis。后续每一次提交预约都是对 Redis key 做 DECR取消预约或超时回收时 INCR 回去。数据库里的booked_num作为最终一致性的兜底Redis 和数据库之间通过定时任务和日志对账。5.3 下单失败与取消预约的回滚Redis 扣减库存只是第一步后续创建订单可能失败比如用户信息不完整、景点已下架、数据库写入异常。这些情况必须把库存还回去否则会出现名额凭空消失。我的处理是在订单创建失败或用户取消预约时对 Redis key 执行 INCR同时更新数据库的booked_num减 1。由于 MySQL 里 booked_num 是整数且不会为负通过更新条件限制Redis 和数据库最终会趋向一致。这里比较容易踩坑的是事务边界。我先扣 Redis再写数据库两者不是同一个事务如果中途进程崩溃Redis 已经减了数据库没写就造成了“幽灵库存”。所以我在订单表里加了一个stock_logs表记录每一次 Redis 库存变化后台定时对账发现不一致就自动补偿。这个对账任务在预约量大的系统里非常值得做。5.4 压测数据与观察结论我用ab工具做了一下简单压测100 个并发同时提交同一个 slot 的预约请求。直接读数据库再更新的方案最终预约成功 35 个而总名额只有 30 个超卖 5 个改用 Redis Lua 扣减后并发请求中恰好只有 30 个成功返回其余全部提示“已约满”数据库最终状态也完全一致。结论是清晰的预约类系统的库存扣减必须在单点原子完成。Redis 的优势是把并发压力集中到内存操作上性能远高于 MySQL 行锁而且结合 Lua 脚本可以保证逻辑原子性。如果你的项目暂时不想引入 Redis也要用数据库的原子 UPDATE 并检查受影响行数绝不能用 SELECT 先查再决定。6. 上线前后遇到的坑与排查思路6.1 微信小程序合法域名与本地联调微信小程序真机上所有wx.request的域名必须是 HTTPS 且在小程序后台配置过 request 合法域名。开发工具里明明可以打开“不校验合法域名”开关但真机预览时这个开关无效。我第一次联调时被这个坑卡了一天开发者工具里接口全通真机上一片空白报errno 600001。原因是后端 Only HTTP没配置 HTTPS也没在 mp 后台添加域名。解决办法是给域名申请 SSL 证书然后在 Nginx 上配置 HTTPS 反向代理。server { listen 443 ssl; server_name api.example.com; ssl_certificate /etc/nginx/ssl/server.crt; ssl_certificate_key /etc/nginx/ssl/server.key; location /think/ { index index.php; if (!-e $request_filename) { rewrite ^/think/(.*)$ /think/index.php?s$1 last; } } }这里也顺带解决了一个 ThinkPHP 路由地址跳转配置的常见问题ThinkPHP 默认的 URL 格式带着index.php?s伪静态规则配置不对就会 404。上面的 Nginx 配置就是让/think/xxx的路径都能被 ThinkPHP 正确解析。6.2 时区问题景点的“今天”边界预约系统对“今天”的定义非常敏感容易出时区 bug。用户在小程序里选了“7 月 16 日”但后端存储用的是服务器本地时间如果服务器时区是 UTC那么北京时间 7 月 16 日早上 8 点服务器时间还是 7 月 16 日 0 点逻辑上问题不大但如果服务器时区是美国或者其他地区就完全错位了。我的处理方式很简单所有涉及日期时间的字段统一用 Asia/Shanghai 时区并且在数据库连接串里显式指定时区。// ThinkPHP 数据库配置 connect_time true, // 建表时使用 DATETIME 而非 TIMESTAMP同时小程序端传日期时用YYYY-MM-DD字符串不用时间戳。例如选择“明天”前端就用date.setDate(date.getDate() 1)算好之后传给后端字符串。这样后端不用关心前端设备时区只要按字符串比较就行。6.3 ThinkPHP与Laravel在同一环境下的类名冲突这是双框架架构最隐蔽的坑。ThinkPHP 和 Laravel 都是基于 Composer 的现代 PHP 框架各自定义了很多同名类比如think\facade\Config和Illuminate\Config\Repositorythink\route\Route和Illuminate\Support\Facades\Route。如果在一个 PHP 进程里同时加载两个框架的自动加载器极容易触发类名冲突轻则报重复加载重则直接把框架内部搞乱。解决办法是物理隔离。我的项目里 ThinkPHP 服务在/var/www/app/api目录Laravel 后台在/var/www/app/admin目录两者的入口文件完全不同Nginx 按路径分发。两个项目的 Composer 自动加载完全独立PHP-FPM 进程也各自运行互不干扰。注意千万不要尝试在同一个入口文件里同时 require 两份框架的autoload.php这不是一个技术技巧而是纯粹的灾难。6.4 图片上传与云存储访问权限景点介绍图片、轮播图在小程序端直接展示不能存在服务器本地磁盘否则一方面流量会压垮带宽另一方面小程序真机上无法直接访问本地 IP 的图片。我统一把图片传到云存储返回 URL 存库。这里有个权限细节云存储文件默认私有读写直接拿来当img src会报签名错误。我做了两个处理第一景点列表和介绍图的 URL 在接口返回时做带有效期签名第二上传时设置Cache-Control: max-age31536000让小程序端能命中缓存减少重复拉取。另外不要在小程序端让用户直接传本地文件路径给后端必须先用wx.uploadFile上传到云存储再由云存储返回地址。6.5 管理后台统计慢查询优化后台“每日预约量”报表一开始直接查 bookings 表GROUP BY visit_date, spot_id数据量到几十万条之后明显变慢页面要等好几秒。查了一下慢查询日志发现缺联合索引(visit_date, spot_id, status)。优化后我在 bookings 表上加了联合索引并且做了两张聚合表daily_spot_stats每日各景点预约统计和hourly_spot_stats每时段预约统计由 Laravel 的定时任务每小时跑一次汇总。报表页面直接查聚合表速度从几秒降到几十毫秒。如果你的报表还要支持多条件筛选建议在聚合表里按维度提前预计算好而不是让运营人员在原表上随便筛选。7. 安全加固与上线前检查清单7.1 接口防刷与参数校验预约系统上线后最大的风险不是黑客攻击而是恶意占号。有人写脚本大量调用提交预约接口把热门时段全占了然后加价转卖。所以接口层面必须做防刷。我做了三层防护同一个用户对一个 slot 只能有一条有效预约。这个由数据库唯一索引和服务端逻辑双重保障已经提前说过了。提交预约接口加频控。每个用户每分钟最多调用 5 次超过就返回“操作过于频繁”。实现起来很简单Redis 里存一个计数 key过期时间 60 秒。关键参数校验。预约日期不能早于今天、不能晚于 30 天后日期字符串必须严格符合Y-m-d格式slot 必须属于所请求的景点。这些校验看起来基础但真的能挡住大量低水平攻击。7.2 越权与水平权限控制预约系统里最容易出现的越权是用户 A 尝试查看或取消用户 B 的订单。这种水平越权靠前端隐藏入口是挡不住的必须后端查询条件强制带上user_id。例如取消预约的接口我的逻辑是$booking Booking::where(id, $request-bookingId) -where(user_id, $request-userId) -first(); if (!$booking) { return error(订单不存在); }核销接口也一样核销员只能核销自己所属景区的订单。后台管理员 Laravel 里可以用中间件做角色权限比如普通操作员只能管理自己景区的景点和库存超级管理员才能跨景区查看所有数据。这类权限控制要在接口层就校验不要依赖前端隐藏按钮。7.3 第三方依赖安全与日志监控PHP 项目里第三方依赖的安全问题容易被忽略。ThinkPHP 和 Laravel 都定期发布安全更新强烈建议上线前把所有依赖升级到当前稳定版本然后关注官方安全公告。不要随便在服务器上装来路不明的扩展包也不要让 composer 把vendor目录暴露在 Web 可访问路径下。日志和监控也很关键。我为系统加了一个统一的请求日志表api_logsThinkPHP 端所有请求都会记录用户 ID、接口路径、请求参数、响应码、耗时、IP。刚开始觉得多写了这一张表很浪费但后来几次线上排错全靠它。出了问题直接查日志就能定位是前端问题、接口问题还是数据库问题不需要让用户进来逐个复现。另外错误监控我接了系统级的告警ThinkPHP 的异常日志和 Laravel 的异常日志都写到文件并由定时脚本扫描发现 ERROR 级别就发告警。上线第一天就抓到一次 Redis 连接超时导致预约 500 的故障告警起了大作用。最后再分享一点实际体会。这种预约系统的通用性很强改一改就能变成活动报名、场馆预约、健身房排课核心的“slot 库存模型 Redis 原子扣减”思路完全复用。我个人觉得最值得花时间的不是页面怎么画而是把库存模型和状态机想清楚。再有一点如果想绕开支付资质问题优先做“免费预约现场购票”等业务量起来再考虑对接微信支付。如果一定要在线支付记得提前准备 ICP 备案域名和 HTTPS 证书景区类目审核会更严格。这套系统做完之后我最大的收获就是预约类系统的灵魂在库存模型代码只是把模型跑起来而已。
返回列表