恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
私厨预约系统实战:业务建模、并发防超卖与Node.js落地
首页
资讯中心
/
私厨预约系统实战:业务建模、并发防超卖与Node.js落地
私厨预约系统实战:业务建模、并发防超卖与Node.js落地
发布时间:2026/10/19 13:44:39
做了快十年的后端我越来越确信一件事一个系统难的地方从来不是框架或中间件而是业务模型有没有被拆清楚。前阵子我和一个做私厨服务的创业团队全程落地了一个基于Node.js的私厨服务系统从需求梳理、数据库建模、接口实现到上线压测都走了一遍。这篇文章我想换个角度不讲教科书式的架构而是把这个项目里“怎么拆业务、怎么选技术、怎么踩坑又怎么补坑”的过程原原本本记录下来。如果你也在做预约类、上门服务类、O2O服务撮合类产品或者自己练手想实现一个私厨服务系统这篇应该能帮你少走不少弯路。1. 私厨服务系统的业务边界先搞清楚“做菜”怎么变成订单1.1 私厨和外卖、堂食根本不是一回事最初客户团队给我的需求描述很简单用户选厨师、选时间、下单厨师上门做菜平台抽成。乍一看这和外卖很像但真正开始拆需求的时候我们发现两者完全是两种物种。外卖的核心商品是“一份做好的菜”核心链路是商家出餐、骑手配送订单生命周期以分钟计。堂食的核心是“到店消费”商家靠翻台率和客单价赚钱。而私厨服务的核心商品不是某道菜而是一位厨师在一段时间内的现场服务能力。厨师一个人一天能接的单非常有限往往一单就是一个晚上而且时段是强排他的——同一时段同一个厨师不可能同时出现在两个家庭厨房里。这带来了一系列产品设计约束系统必须能表达“时段”和“产能”不能只做单纯的菜品库存计价纬度是服务时长、服务人数、菜品数量不是一份菜多少钱下单前用户和厨师往往要沟通口味、忌口、食材偏好平台得提供消息和咨询渠道上门服务涉及安全、信任、取消、改约这些高情绪化问题业务规则必须比外卖严格得多。很多人在做这类系统时习惯直接抄外卖订单表抄完才发现字段完全不够用没有时段、没有厨师产能、没有地址隐私状态。所以我在项目一启动就坚持先把边界画清楚这是一个上门服务履约系统不是一个点餐系统。边界清晰之后后端的ER设计和技术选型才有了锚点。1.2 核心业务对象与完整服务链路从领域模型看这套系统里有这些关键实体用户、厨师、服务项目、预约时段、订单、订单事件、评价、投诉、结算单。完整链路是厨师入驻并发布服务项目 → 用户浏览、筛选、咨询 → 用户选时段下单并支付 → 系统通知厨师 → 厨师确认或拒单 → 服务当天上门履约 → 双方确认完成 → 互评 → 平台结算给厨师。这条链路里最容易忽略的是“订单事件”。私厨订单不像电商订单那样几小时走完全流程它从下单到履约可能跨好几天任何变化都需要留痕。用户改约、厨师备菜后取消、上门发现用户不在家、菜做一半临时加菜这些场景在传统订单系统里根本没地方放。于是我们专门建了一张order_events表把所有状态变更、操作人和备注记下来后面处理客诉、判断责任归属、给运营做复盘全靠它。1.3 几条先定死的业务规则业务规则我们和客户团队花了一周一条条列这里说几条直接决定数据结构设计的取消规则分段支付成功到服务开始前24小时免费取消24小时到前2小时取消扣10%作为厨师备菜补偿服务开始前2小时内取消扣50%服务开始后用户单方面取消按已服务部分计费。退款计算需要精确到分所以金额字段从一开始就用整数分存储不碰浮点数。厨师确认时限下单后15分钟内厨师未确认订单自动取消并全额退款。这个时限不能靠人工盯必须交给系统和消息推送。地址逐步解锁下单时用户填完整地址但下单后厨师只能看到小区级别信息服务当天早上8点系统才把门牌号下发给厨师。这条后面专门展开讲因为它涉及私厨特有的信任问题。边界拆完我们才敢开始写库表。2. 技术选型复盘为什么这套系统选择Node.js2.1 不是Node.js最好而是它对这个项目最合适客户一开始问能不能用Java或Go来做。我当时没直接拍板而是把需求特点摆出来挨个分析。这个系统的核心特征是IO密集图片上传、IM聊天、WebSocket推送状态更新、异步消息多、业务规则频繁迭代但重计算任务几乎没有。团队早期只有两名后端都熟JavaScript和TypeScript用Node.js可以让前后端语言统一减少上下文切换的学习成本。下表是我当时给客户做的简单对比维度Node.js TypeScriptJava/Spring BootGo团队上手成本低前后端同栈中高中IO密集型场景很擅长事件循环模型也不错但线程成本高很擅长迭代速度快生态丰富中等中类型安全用TypeScript补上强强招聘维护前端可转型后端人才多后端人才相对少我也明确说了Node.js不太适合强事务、重计算、要长期演进到超大规模的场景但对我们这个预约服务系统单机或小集群规模完全够用。最终技术栈定为Node.js 18 LTS NestJS TypeScript MySQL 8 Redis。选NestJS而不选裸Express是因为它自带依赖注入、模块化和装饰器代码组织规范多人协作时不至于各写各的。代价是多了一点抽象调试链路会稍微变长但整体是值得的。2.2 数据库为什么选MySQL而不是MongoDB做Node.js的人经常默认用MongoDB觉得文档模型灵活、开箱即用。这个项目我坚持用MySQL因为有几个刚需是NoSQL不好满足的订单、金额、退款记录要求强事务必须支持事务和行锁状态机、时段、结算明细之间是强关联关系一对多查询频繁运营后台要按角色、状态、城市、时间段做统计关系型SQL写起来最顺手。所以NoSQL没有参与核心链路Redis只承担缓存、分布式锁、临时会话和延时任务这些非持久化职责。2.3 整体架构与代码模块划分架构一句话讲完Nginx负责负载均衡和WebSocket反代 → Node.js集群PM2托管4个进程→ MySQL Redis → 对象存储存图片和视频→ 第三方地图服务做地址解析和逆地理编码。代码层面我们按NestJS的模块化思想做了清晰分包src/ modules/ auth/ # 登录注册、JWT、角色权限 users/ # 用户管理 chefs/ # 厨师入驻、资质审核 services/ # 服务项目菜单/套餐 slots/ # 预约时段 orders/ # 订单、状态机、超时任务 payments/ # 支付、退款、费用拆分 reviews/ # 评价 complaints/ # 投诉 settlements/ # 结算 im/ # IM 与 WebSocket 推送这个划分的好处是后面新增投诉、结算模块时基本没动过已有代码都是新加模块、写对应路由。模块化带来的维护收益在这个项目里体现得非常直接。2.4 为什么做预约制而不是即时派单其实客户最初提过“即时上门”的想法用户下单附近厨师抢单。但把业务数据摆出来聊完之后我们一致放弃了。私厨产能极度碎片化。大量厨师是兼职白天有自己的工作能服务的时间集中在晚高峰和周末。如果走即时派单本质要解决的是“实时调度”级别的匹配问题复杂度远超一个创业团队需要承担的。预约制则天然把产能提前锁死厨师自己维护服务日历用户在确定时间获得确定服务双方预期都稳定。这个决定直接影响数据模型设计。我们为厨师生成以半小时为粒度的预约时段网格厨师在端上开关“可预约”状态用户下单锁定的不是菜品库存而是某个时段的产能。3. 数据库建模与预约时段设计把并发问题在设计阶段就解决掉3.1 核心表结构与字段设计思路数据库设计阶段我们就坚持一个原则每张表必须清楚回答一个业务问题。核心表大概这样users用户和厨师共用包含mobile、password_hash、nickname、roleUSER/CHEF/ADMIN、credit_score、status。chef_profiles厨师资质的扩展表包含real_name、id_card_hash、health_cert_id、service_area、intro、tags、statusPENDING/APPROVED/BANNED。service_items厨师发布的服务项目包含chef_id、title、description、cover_images、category、price_cents、duration_minutes、max_guests。schedule_slots预约时段包含chef_id、service_date、start_time、end_time、capacity、booked_count、price_cents、status。orders订单主表包含order_no、buyer_id、chef_id、item_id、slot_id、begin_at、end_at、address_json、guest_count、total_amount_cents、discount_cents、pay_amount_cents、status、cancel_reason。order_events订单事件流水包含order_id、from_status、to_status、operator_type、operator_id、remark、created_at。reviews、complaints、settlements、refunds评价、投诉、结算、退款明细。这样建表的好处是运营后台查“某厨师上个月接了多少单、取消率多少”这种问题就是直接JOIN不需要另搭数据分析系统。尤其order_events几乎所有客诉调查和业务报表的底子都在它身上。3.2 可预约时段的产能模型这是整个系统最核心的设计点。我们按30分钟粒度生成 slot一个slot的capacity默认是1。一个订单如果时长跨多个slot比如14:30到16:00就会覆盖14:30、15:00、15:30这三个slot下单时逐个扣减所有覆盖slot的booked_count。这里的关键是把扣减动作做成数据库层的原子操作而不是在代码里先查再改。核心理念是这样的UPDATE schedule_slots SET booked_count booked_count 1, version version 1 WHERE id ? AND booked_count capacity;这个SQL影响行数为0就说明该时段已被约满直接抛出“该时段已有订单”的异常。MySQL InnoDB的行锁会让同一行的并发UPDATE排队执行天然防超卖。所有库存抢占类系统都应该优先考虑这种“条件更新”而不是“读改写”。3.3 订单状态机的完整迁移约束订单状态我们设计了这样一组CREATED已创建未支付→PAID已支付待厨师确认→CONFIRMED厨师已确认→SERVING服务中→COMPLETED已完成→SETTLED已结算。取消和退款路径独立存在CREATED/PAID/CONFIRMED/SERVING都可以走到CANCELLED涉及退款的再走到REFUNDING最终到REFUNDED。状态迁移合法性的约束不是散落在业务代码里而是集中维护了一张状态迁移配置表或代码里的Map。每次变更都往order_events写一条带操作人、操作时间的记录。运营要回答“到底谁的责任”就是拉着这条时间线看。3.4 金额计算与退款拆分金额这块特别强调两点用整数分存储退款要单独建表。订单金额由三部分组成服务费、可选食材代买费、交通补贴。平台从结算金额中扣服务费剩余给厨师。用户支付全款到平台厨师完成订单后按周期结算。退款时按取消规则分层计算。比如用户在前24小时到前2小时取消退90%扣的10%作为厨师备菜补偿。这个计算在代码里是一个小函数输入是取消时间和服务开始时间输出是应退金额。退款结果写入refunds表和订单关联这样才能对清楚账而不是直接改订单金额字段完事。4. 从下单到履约核心接口的代码实现与并发防重4.1 下单接口的原子操作创建订单这步代码不复杂但把防超卖逻辑写对了后面能省一大半排查精力。核心实现大致如下Post(orders) async createOrder(Body() dto: CreateOrderDto) { // 先做参数校验用户状态、服务项目是否在售、地址是否在服务范围内…… const slotIds await this.slotsService.resolveSlotIds( dto.chefId, dto.beginAt, dto.endAt, ); return this.dataSource.transaction(async (manager) { for (const slotId of slotIds) { const result await manager.query( UPDATE schedule_slots SET booked_count booked_count 1, version version 1 WHERE id ? AND booked_count capacity, [slotId], ); if (result.affectedRows 0) { throw new ConflictException(该时段已有订单请更换时间); } } const order manager.create(Order, { orderNo: this.genOrderNo(), buyerId: dto.buyerId, chefId: dto.chefId, slotIds: slotIds.join(,), beginAt: dto.beginAt, endAt: dto.endAt, totalAmountCents: dto.totalAmountCents, payAmountCents: dto.payAmountCents, status: OrderStatus.CREATED, }); await manager.save(order); await manager.insert(OrderEvent, { orderId: order.id, fromStatus: null, toStatus: OrderStatus.CREATED, operatorType: USER, operatorId: dto.buyerId, remark: 用户提交订单, }); // 通过 WebSocket 通知用户去支付 return order; }); }这段代码里最值得留意的就是那个UPDATE ... WHERE booked_count capacity。多个并发请求同时抢同一个厨师时数据库行锁把更新排队执行后到的请求会发现条件不成立影响行数为0直接被拦下。高并发的库存系统都用这套思路千万别在自己代码里“先查一次、判断一下、再更新”。4.2 状态流转与超时任务支付回调确认后订单从CREATED走到PAID同时给厨师推送确认通知。厨师接单接口会校验当前状态必须是PAID如果订单已经被用户取消状态校验这关就过不去不会出现“付了钱又被强行接单”的奇怪状态。超时任务我们分了两层Redis延时队列负责准时触发MySQL扫描兜底。为什么这么做纯Redis队列有宕机丢任务的风险纯数据库轮询在订单多的时候又会带来无谓的压力。所以一个订单支付超时后Redis里先挂一个15分钟后的“关单任务”到了时间执行关单如果这时候Redis里任务意外丢了还有后台每分钟扫描一次兜底把超过15分钟仍未支付的订单捞出来关闭。两层相互补位生产环境跑得很稳。4.3 取消、退款与费用拆分取消接口的逻辑同样遵循“状态校验优先”的原则。先判断订单当前处于什么状态再根据取消时间计算退多少最后写退款单。规则函数大致是这个意思function calcRefund({ status, cancelAt, serviceBeginAt, payAmountCents, }: { status: OrderStatus; cancelAt: Date; serviceBeginAt: Date; payAmountCents: number; }): number { if (status CREATED) return payAmountCents; const hoursLeft (serviceBeginAt.getTime() - cancelAt.getTime()) / 3600000; if (hoursLeft 24) return payAmountCents; if (hoursLeft 2) return Math.floor(payAmountCents * 0.9); return Math.floor(payAmountCents * 0.5); }退款写入refunds表订单状态再迁移到REFUNDED。这里多一个中间状态的实际意义是退款单和支付流水能对上账运营对账的时候不用靠猜。4.4 一个真实发生的并发超卖问题完整排查链路这个坑是上线第三周出的我认为值得完整还原一遍因为它把所有库存类系统的通病都暴露了。现象周末晚高峰两位用户几乎同时预约了同一位热门厨师周六18:00-19:30的时段系统对两边都提示“预订成功”。客服电话瞬间被打爆。第一次排查先看后台订单表发现两个订单的slot_id完全相同order_events里也都有CREATED记录。再看日志两个请求进入createOrder的时间差了不到50毫秒。定位根因我去翻旧版代码发现当时写的是先SELECT booked_count判断小于capacity再去UPDATE加一。两个请求同时读到booked_count0都认为自己能抢到时段于是都成功下单。问题本质是“先读后写”的竞态。修复把“检查扣减”合并成一条条件UPDATE并把slot扣减和订单创建放进同一个事务。修复后任何时刻都只有一个请求能成功让booked_count capacity这个条件成立。验证写了个并发脚本模拟100个请求同时抢同一个时段。修复前成功数量在2到5个之间修复后稳定只有1个成功其余全部返回“该时段已有订单”。生产环境又观察了两周没有再出现超卖。复盘设计阶段就应该用原子更新不要在业务代码里做判断。这个教训很贵因为超卖直接导致用户信任崩塌比花几小时写对SQL的成本高得多。5. 私厨特有的信任与安全设计资质审核、地址脱敏与服务留痕5.1 厨师入驻审核流程私厨不同于外卖商家有实体营业执照它本质是个人能力变现平台必须把第一道信任关守住。我们的入驻流程设计成了五步实名认证加健康证校验 → 提交至少三道拿手菜的作品集照片加视频 → 平台客服电话面试 → 提交一段完整做菜过程视频评审 → 审核通过后才允许发布服务项目。所有审核资料单独存到audit_files表每条记录关联审核对象、文件类型、存储key、审核状态。一旦后续发生纠纷这些原始材料就是证据链的起点。流程确实重但它带来的效果非常明显上线的第一个月平台事故率最高的环节不是菜品品质而是“厨师迟到”资质问题基本被卡死在入驻环节。5.2 双向隐私保护地址逐步解锁私厨上门比外卖更微妙的地方在于用户等于把家庭住址交给了陌生人。一次隐私泄漏可能永久流失一个用户。我们的做法是“地址逐步解锁”下单时用户填写完整地址但系统只对厨师展示服务范围精确到小区级别厨师确认订单后依然看不到门牌号服务当天早上8点才向厨师展示完整门牌号和联系方式服务结束后厨师端订单详情里的完整地址自动隐藏只保留历史范围信息。双方通话统一走平台虚拟小号服务结束后号码失效。这是隐私和履约效率之间的取舍隐私不给够用户不敢下单隐私一步到位放开又会流失大量家庭用户。我们宁可让沟通多几步也要把“家里地址什么时候给对方”的控制权交给用户感知得到的时间规则。5.3 服务过程留痕与申诉证据链上门服务没法像外卖那样用“已送达”按钮完结。我们要求厨师开始服务时在App内打卡并拍现场照片系统记录时间戳服务结束后上传收尾照片包括餐桌效果和厨房恢复情况如果到了现场发现用户不在也必须拍照并勾选“用户未在场”。所有照片传到对象存储返回的key写入order_events的备注附件。投诉流程是用户发起投诉 → 生成投诉单 → 平台客服查看订单时间线、照片、虚拟号通话录音 → 判定责任 → 决定退款或赔偿。这套证据链设计得很轻但跑了一段时间后发现能自动化判定责任的比例其实不高大部分还是靠客服人工介入。证据链真正的价值不是“全自动判案”而是让双方在争执时有据可查把情绪化争论变成对事实的核对。6. 上线部署与压测调优那些文档里不会写的参数6.1 部署拓扑与进程管理部署环境是一台4核8G的云服务器架构就是Nginx加PM2托管的Node集群后面挂MySQL和Redis。Node多进程部署时有一个必须记住的前提多个实例不能共享内存态数据。Session、锁、计数器一律放Redis实时消息通过Redis适配器做跨实例广播这样服务才具备水平扩容的条件。PM2启动命令大致是这样pm2 start dist/main.js -i 4 --name private-kitchen --max-memory-restart 1G-i 4表示启动4个实例内存超过1G自动重启。这个配置对4核机器比较合适不是越多越好实例太多反而增加进程间通信和数据库连接压力。6.2 Socket.IO穿透Nginx的配置实时通知我们用的是Socket.IO但Nginx默认不会升级HTTP协议导致WebSocket总是断连。这个坑几乎人人都会踩配置其实就一段location /socket.io/ { proxy_pass http://node_cluster; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }最容易漏的是最后两行。Nginx默认proxy_read_timeout是60秒如果不改长连接每分钟被切断客户端会频繁自动重连表现为“消息总延迟”、“App莫名其妙掉线”。6.3 连接池、日志与定时任务的三个大坑这三个坑都是上线后陆续暴露的每一个都值得单独记一笔。MySQL连接池和Node实例数要联动调整。默认连接池上限可能只有104个Node实例意味着最多40个并发DB连接高峰期直接就出现连接超时。我们压测后把池大小调到40并监控DB活跃连接数保持平稳。日志爆盘是个安静的问题。开发环境随便console.log没问题到生产环境PM2会把所有输出写文件访问量一大磁盘说满就满。我们改成按天拆分日志文件错误日志和访问日志分开设置最多保留7天并做了磁盘告警。集群模式下定时任务会重复执行。多个Node实例同时扫超时订单同一单可能被处理多次甚至重复退款。解决办法是在Redis里抢分布式锁用SET key value NX EX 60这种命令拿到锁的实例才执行任务。这类问题在单实例调试时永远测不出来上了集群才暴露所以多进程部署前就要考虑。6.4 压测结果与容量规划下单接口做了一轮压测单实例大概能到600 QPS4实例约2000 QPS。对这个日均千单量级的项目余量很充足。压测中第一个瓶颈出现在MySQL行锁等待然后才是Node进程本身。后来我们把热门厨师的slot粒度从30分钟调整到20分钟热点时段分散后行锁竞争明显下降。容量规划有个经验公式可以先用峰值QPS约等于日均单量除以3600再乘以20。如果以后要做“周末秒杀热门厨师时段”这类运营活动至少再预留3到5倍余量最好直接拍出来让开发和运维都心里有数。7. 上线后的真实反馈与下一步可以做的事上线三个月后运营数据和我们最初的预期有出入但方向更清晰了。最受欢迎的场景不是“日常做饭”而是家庭聚餐、生日宴、月子餐这些高客单低频场景用户最关心的不是价格而是“这个厨师靠不靠谱”厨师端最大的痛点是排期不稳定很多人一周只能接两三单。团队下一步的计划里已经有几个明确方向包月和次卡把单次预约变成订阅式服务稳定厨师排期食材代买很多用户希望厨师顺手买菜这需要平台承接上传小票、审核、报销的完整流程多厨师上门企业团建、大型家宴需要同时下多位厨师订单要从单slot扩展到“一单多厨”。如果现在让我重新设计一遍这套系统我第一个会改的是把“时段产能”从预生成slot改成事件驱动的日历块这样能更自然地支持多厨师并行和临时加时。另一个一定会原样保留的设计是order_events它几乎是后面所有运营能力的底子。也希望这篇把真实踩坑过程写出来的文章能让同样在做这类项目的人少走一段弯路。