酒吧这门生意,白天看着安静,一到晚上九点半之后就是另一套节奏:卡座满员、散台翻台、服务员端着托盘在人缝里穿行,吧台那边堆着一摞手写单子,谁先谁后全靠喊。我在几个夜场项目里都见过同一个场景——高峰期点单靠吼,出酒靠记,结账靠算,最后对账靠猜。酒吧点餐小程序系统要解决的从来不是"把菜单搬到手机上"这么简单,它真正要接管的是桌台状态、下单节奏、吧台排队、库存扣减和结账对账这条完整链路。这篇内容面向的是打算自己动手做一套、或者正在评估要不要做的开发者和门店经营者,我会从需求拆解一路讲到技术选型、数据模型、核心功能落地、夜间峰值处理,再到实际踩过的坑,把方案摊开讲透,能直接抄的部分我会写清楚参数和代码,不能直接抄的部分我会讲清楚为什么这么选。
1. 需求拆解:酒吧点餐和正餐点餐差在哪
1.1 场景差异决定了技术形态
很多人第一反应是拿正餐扫码点餐系统改一改就用,实测下来基本都要返工。正餐的节奏是"坐下来、看菜单、慢慢点、吃完结账",一顿饭可能就下一单;酒吧的节奏是"开台、先来一轮、喝完了再加、加三四轮、最后一起结"。这意味着订单不是一次性的,而是围绕一个开台记录持续追加的。数据模型上如果按正餐那样"一单一桌"设计,第二天你会发现同一张桌有七八条孤立订单,对账时根本拼不起来。
再一个是菜品结构。正餐的 SKU 通常是"菜名+份量",酒吧的 SKU 是"酒名+容量+温度+整瓶/半打/单支+是否加冰+是否配果盘",属性组合能翻出十几倍。像洋酒这类还涉及"存酒"业务,客人这瓶没喝完存在店里,下次来接着喝,这就要求系统里有存酒记录和核销流程。我在一个项目里因为前期没做存酒模块,开业第二周就被店长追着加需求,改动量比预想大得多。
第三是时间分布极度集中。工作日可能一晚上几十单,周五周六晚上十点到凌晨一点这两个多小时能吃掉整晚七成的单量。这种"脉冲式"流量对系统的要求不是平均吞吐,而是峰值扛不扛得住、降级方案有没有。
1.2 角色与权限怎么划分才不乱
酒吧里的角色比想象中多,梳理不清楚后面权限系统会一团乱麻。我一般按这五类来切:
| 角色 | 主要动作 | 权限边界 |
|---|---|---|
| 顾客 | 扫码、浏览、下单、支付、催单、呼叫服务 | 只能看自己桌的订单 |
| 服务员 | 代客下单、改单、退单、开台、换台、结账 | 管辖区域内的桌台 |
| 吧台/出品 | 接单、制作、出酒确认、缺货标记 | 只看订单明细,不看金额 |
| 店长 | 全部数据、改价、免单、日结 | 全店 |
| 财务/老板 | 报表、对账、导出 | 只读 |
关键是改价和免单权限必须收口。我见过一个店把改价权限开给所有服务员,一个月下来流水对不上,查了半天发现是现场给熟客抹零抹多了,系统里没留痕。正确做法是:改价必须走审批,至少留下操作人、原价、新价、理由四个字段,日结时能一键导出差异单。
1.3 三个必须提前想清楚的边界问题
第一个是先付还是后付。快消型酒吧(清吧、livehouse)适合下单即付,减少跑单;带卡座最低消费的适合开台付押金、离场结算。两种模式在代码里是两条不同的状态流转路径,最好在配置层做成开关,别写死。
第二个是是否允许顾客自助开台。允许的话扫码即开台,体验好但可能被路过的人误开;不允许的话需要服务员确认,多一步操作但可控。我的建议是首次扫码弹一个"确认开台"的轻确认,成本很低但能挡掉大部分误操作。
第三个是库存扣减的粒度。是按 SKU 扣还是按原料扣?酒吧的酒水大多是整瓶出,SKU 级扣减就够用;但如果店里做特调鸡尾酒,就得考虑基酒的原瓶消耗。前者简单,后者要引入配方表,工作量差一个量级。前期别贪,SKU 级先跑起来。
2. 技术选型:前端、后端与实时通信的取舍
2.1 小程序端:原生还是跨端框架
这个选择的判断标准其实只有一个——你的店有几家、未来半年会不会做第二个平台。只有一家店、只跑一个平台,原生开发最稳,包体积小、扫码唤起快、坑最少。如果打算同时上多个平台,或者要复用到 H5、店内平板,那就上跨端框架。
我在实际项目里踩过的坑是:跨端框架在低端安卓机上的首屏时间会比原生慢 200 到 500 毫秒,酒吧环境本来就信号拥堵,这个差距在排队扫码时会被人感知到。所以我的折中方案是——顾客端点单用原生写,员工端(吧台、服务员)用跨端框架打包成 App 或 H5,因为员工端对首屏不敏感,但对多端复用需求高。
包体积也要控制。小程序主包一般限制在 2MB 左右,酒水图片如果用原图很容易爆。做法是把图片全部走 CDN 加参数裁剪,列表页用 200x200 的缩略图,详情页再加载大图,主包只留图标和骨架屏。这一招能把主包压到 1MB 以内。
2.2 后端语言:别为了架构而架构
单店或三五家店的规模,微服务是负担不是优势。我推荐模块化单体:一个进程、一个数据库,代码按领域分模块(桌台、订单、商品、支付、报表),模块之间通过内部接口调用,将来真要拆再拆。
语言选型上,我给个实际对比:
| 方案 | 优势 | 适合场景 | 注意点 |
|---|---|---|---|
| Node.js | 开发快、WebSocket 生态好、前后端同语言 | 中小型项目、实时推送多 | CPU 密集任务弱,别在进程里跑重计算 |
| Java | 生态成熟、事务处理稳、招人容易 | 连锁多店、需要强一致 | 内存占用高,小机器跑不动 |
| Go | 并发强、部署单文件、内存省 | 峰值压力大、预算有限 | 开发速度略慢于 Node |
我最近两个项目都用的 Node.js 加 TypeScript,原因是吧台端要长连接推送,Node 的事件循环天然适合这类 IO 密集场景。数据库用 MySQL 8,缓存用 Redis,这套组合在 2 核 4G 的机器上跑单店完全够用。
2.3 实时通信:轮询、WebSocket 还是 MQTT
吧台端必须实时看到新订单,服务员端要实时看到出品状态。三个方案的实测差异很明显:
- 短轮询:实现最简单,3 秒一次请求。问题是 10 个吧台终端就是每秒 3 次以上请求,而且新订单平均延迟 1.5 秒,高峰期吧台会抱怨"单子来得慢"。
- WebSocket:延迟毫秒级,双向通信,能主动推。缺点是要处理断线重连、心跳保活,服务端要维护连接数。50 个终端以内毫无压力。
- MQTT:适合终端多、网络不稳定的场景,有 QoS 等级保证。但引入 Broker 增加运维成本,单店规模属于杀鸡用牛刀。
我的选择是 WebSocket 加降级兜底:连接正常走推送,心跳超时或连续重连失败三次,自动切回 5 秒轮询。这个兜底非常重要——酒吧的 WiFi 经常被上百台手机挤爆,纯 WebSocket 方案在信号差的时候会静默丢消息,吧台以为没单,实际单子已经下了。
心跳参数我一般设成:客户端每 25 秒发一次 ping,服务端 60 秒没收到就判定断线。这个数值比很多默认值保守,原因是移动网络切换(从 WiFi 到 4G)时连接容易假死,心跳太松会拖很久才发现。
2.4 数据库与缓存的分工
分工原则很简单:钱和状态以 MySQL 为准,速度和并发靠 Redis 挡。
MySQL 里存订单、桌台、商品、支付流水、操作日志。Redis 里存三类东西:桌台实时状态(读多写多)、商品库存计数(原子扣减)、接口幂等键和限流计数(有过期时间)。
库存扣减这块要特别说一下。直接用SELECT ... FOR UPDATE加行锁在峰值时会排队,几十个并发就能把响应拖到秒级。我用的方案是 Redis 加 Lua 脚本做原子预扣,成功后再异步落库:
-- KEYS[1] = stock:sku:{skuId} -- ARGV[1] = 扣减数量 local stock = tonumber(redis.call('GET', KEYS[1]) or '-1') local qty = tonumber(ARGV[1]) if stock < 0 then return -2 -- 库存键不存在,回源 end if stock < qty then return -1 -- 库存不足 end redis.call('DECRBY', KEYS[1], qty) return stock - qty返回值设计成三种状态很重要:-2表示缓存里没有这个键,需要回源数据库加载;-1表示确实不够;大于等于 0 表示扣减成功并返回剩余量。少了-2这个分支,缓存刚重启时会把所有商品都判成缺货,这个坑我在上线第一天就踩到了。
3. 数据模型设计:桌台、SKU 与订单的三层结构
3.1 核心表怎么建
我把核心表分成三层:场地层、商品层、交易层。
场地层包括area(区域,如吧台区、卡座区、露台)和table(桌台)。桌台表里除了桌号,还要存区域 ID、容纳人数、最低消费、当前状态、当前开台记录 ID。最低消费这个字段看着不起眼,实际很关键——离场结算时要判断是否达标,不达标按最低消费收取。
商品层是category(分类)、product(商品,如"某品牌威士忌")、sku(规格,如"700ml 整瓶""半打套餐")。商品和 SKU 拆开的好处是列表页只查 product,点进去才查 sku,减少数据传输。
交易层是session(开台记录)、order(订单批次)、order_item(订单明细)、payment(支付流水)。这里的关键设计是session 和 order 是一对多:一次开台可以对应多个订单批次(第一轮、第二轮),但结账时按 session 汇总。这样既保留了加单的灵活性,又保证了对账的完整性。
3.2 桌台状态机要画死在代码里
桌台状态如果没有严格的状态机,最后一定会出现"显示空闲实际有人"的鬼故事。我固定的六个状态和流转规则:
| 状态 | 含义 | 可流转到 |
|---|---|---|
| IDLE | 空闲 | OPENING |
| OPENING | 已开台未下单 | ORDERING, IDLE |
| ORDERING | 点单中 | SETTLING, ORDERING |
| SETTLING | 待结账 | CLEANING |
| CLEANING | 清理中 | IDLE |
| LOCKED | 锁定(维修/预留) | IDLE |
所有状态变更必须走统一的changeTableStatus方法,方法内部做三件事:校验流转合法性、写状态变更日志、发 WebSocket 广播。绝对不允许在业务代码里直接 update 状态字段,这是我定下的死规矩,违反过的项目无一例外都出现过状态漂移。
还有一个细节:开台要有超时自动释放。服务员开了台客人没来、或者客人走了没结账,桌台会一直挂着。我设的是开台后 30 分钟内无任何订单,状态从 OPENING 自动回落 IDLE;ORDERING 状态则由店长手动处理,不自动释放,避免把正在喝酒的客人误清台。
3.3 加单、并台、换台的实现要点
加单最简单,往当前 session 追加一个新 order 批次即可,注意带上批次号方便吧台区分"这是第二轮"。
并台(两桌合并)稍微麻烦。做法是选一张主桌,把从桌的 session 挂到主桌的 session 下,从桌状态置为 SETTLING 并标记"已并入 X 桌"。结账时只结主桌,从桌不再单独出单。历史订单的桌号字段保留原值,不做修改,这样审计时能追溯。
换台本质上是修改 session 的 table_id。要注意同步两件事:原桌状态回 IDLE,新桌状态改为 ORDERING;同时给吧台推一条"桌号变更"通知,否则服务员端着酒找不到人。我在一个项目里漏了这步通知,结果一晚上有三单酒送错桌。
4. 核心功能落地:从扫码到出酒的完整链路
4.1 扫码进店与桌号绑定的防伪处理
二维码里不能只放明文的桌号,否则谁都能改。我的做法是生成一个带签名的 scene 参数,格式大致是t=12&s=ab12cd34,其中s是tableId + 时间戳 + 密钥的哈希前 8 位。服务端校验签名不通过就跳到通用菜单页,不绑定任何桌台,只能看不能下单。
落地页的处理逻辑按这个顺序判断:先校验签名,再看桌台状态。IDLE 状态提示"确认开台",OPENING 和 ORDERING 直接进菜单,SETTLING 提示"该桌正在结账",LOCKED 提示"该桌暂不可用"。这个顺序不能反,先判断状态再校验签名会给攻击者提供探测桌台状态的通道。
还有一个容易被忽略的点:同一张桌多人扫码。五个人同时扫,如果不做处理会开出五个 session。解决办法是以 table_id 为 key 加 Redis 分布式锁,开台操作串行化,第二个人进来时读到已有 session 直接复用。锁的过期时间设 5 秒足够,别设太长,否则第一个人开台失败会把桌锁死。
4.2 SKU 与规格:别让用户在菜单里迷路
酒吧菜单最大的问题是选择太多。一款威士忌有整瓶、半打、单支、加冰、常温、配什么软饮,全列出来用户直接懵。我的处理是两级收敛:列表页只展示商品和起步价,点击后弹出规格选择面板,面板里按"容量—温度—搭配"三个维度分组,每组单选用大按钮,默认值预选好。
数据结构上用属性表加 SKU 表。属性表定义"容量"有哪些选项、"温度"有哪些选项,SKU 表存具体的组合和价格。生成 SKU 时用笛卡尔积,但要注意过滤无效组合——比如"半打"和"单支"是互斥的,不能同时选。这个过滤规则建议在后台配置时手动勾选有效组合,别用程序自动生成全部,实际营业中总有一些组合是你永远不卖的。
套餐的处理要单独说。酒吧常见的"洋酒+4 瓶软饮+果盘"套餐,不能简单当成一个 SKU 卖,因为要拆到吧台和果盘两个出品口。我的方案是套餐表加子项表,下单时按子项分别生成 order_item,但对外显示为一个套餐,价格取套餐价。结算时按子项成本分摊,方便后续算毛利。
4.3 加菜、退菜、催单的状态同步
这三个操作是高频动作,也是状态最容易乱的地方。
加菜走新增 order 批次,不修改原订单。好处是吧台按批次制作,服务员按批次送,不会出现"这杯是第二轮的还是第一轮的"这种混乱。
退菜要分外场和吧台两个视角。服务员在顾客端发起退单,订单进入REFUNDING状态,吧台端必须收到确认请求——因为酒可能已经开了。吧台确认"未制作"则直接退款,确认"已制作"则转店长审批。这个双确认机制听着麻烦,但能挡掉大量"点了又不要"的纠纷。
催单别做成一个纯通知。我见过做成弹窗提醒的,吧台根本不看。正确做法是把催单次数记在 order_item 上,催一次加一,吧台端列表按催单次数倒序高亮显示。数据驱动比弹窗有效十倍。
4.4 支付与结算:把异常路径想全
支付这块,正常路径没什么好说的,难的是异常路径。我列了几个必须处理的场景:
支付回调丢失是最常见的。方案是双保险:客户端支付成功后主动查一次订单状态,同时服务端定时任务每 30 秒扫一遍"支付中超过 2 分钟"的订单去主动查询。两者谁先到算谁,通过订单号的唯一索引保证不会重复入账。
金额不一致也要防。回调里的金额必须和订单金额做比对,不一致时进人工审核队列,别自动入账。这个校验我用DECIMAL(10,2)存金额,绝不用浮点数,也别在代码里做浮点运算,全部转成以"分"为单位的整数计算。
押金和最低消费的处理要单独设计。开台收押金的话,结算时先扣押金再收差额,押金原路退回的部分要生成独立的退款流水。最低消费不达标的,差额作为"最低消费补差"单独记一项,报表里要能分开看,否则算不清真实客单价。
4.5 吧台端与打印的联动
吧台端是整个系统的核心工作面,设计原则只有一个:一眼能看清该做什么。界面按三列排:待接单、制作中、已出酒。新单进来自动置顶并响铃,超时未接单的(我设的是 3 分钟)变红闪烁。
打印机这块,酒吧环境油污多、纸卷换得勤,故障率比餐厅高。我的做法是云打印加本地兜底:主用云打印机走网络下发,同时保留一台 USB 小票机接在吧台电脑上,云打印连续失败两次就自动切本地。打印内容只留必要信息——桌号、下单时间、商品名、规格、数量、备注,别打一长串流水号,吧台看不清。
还有个小细节:断网重打。网络恢复后要能补打漏掉的单子,但不能把已经打过的重复打。实现方式是打印任务表里存状态,PENDING的才打,打完置DONE,重连时只捞PENDING的。
5. 夜间峰值处理:扛住那三小时
5.1 峰值到底有多高
先算一笔账。一家 40 桌的中型酒吧,周五晚高峰上座率八成,32 桌。每桌平均 5 人,每人平均下一单,加上加单,一晚总订单量大概 400 到 600 单。这些单子集中在 21:30 到 00:30 三个小时内,折算下来平均每分钟 3 到 4 单,但真实分布是极不均匀的——开场那半小时和跨年节点,瞬时能到每分钟 30 单以上。
按每分钟 30 单、每单 5 个明细算,写入 QPS 大约 150,再加上菜单浏览的读请求,总 QPS 峰值 300 到 500。这个量级单机完全能扛,关键不是性能,而是不能出错——峰值时段出一单错,现场就是一场混乱。
5.2 库存超卖的三种防法
超卖是酒吧最痛的问题,客人点了没货,现场体验极差。三层防护:
第一层是预扣,用前面那段 Lua 脚本在 Redis 里扣,扣不到直接拦。第二层是下单幂等,客户端生成一个 requestId,服务端用唯一索引挡住重复提交。第三层是异步落库加对账,每 5 分钟把 Redis 的库存值和数据库比一次,不一致就以数据库为准回写 Redis,同时告警。
这里有个取舍要说清楚:库存预扣会导致"下单占库存但未支付",如果用户放弃支付,库存要回滚。我的做法是预扣时给订单设一个 15 分钟的支付窗口,超时未支付由定时任务把库存加回去。15 分钟这个值不是拍脑袋,是看了实际支付转化数据后定的——超过 15 分钟还没付的,八成是不会付了。
5.3 幂等、限流与降级
幂等的实现要覆盖三个入口:下单、支付回调、退款。下单用 requestId 唯一索引;支付回调用订单号加状态机的状态校验(已经是 PAID 的直接返回成功);退款用退款单号唯一索引。
限流按接口分开设。菜单查询这类读接口放宽到单 IP 每秒 20 次,下单接口收到单 IP 每秒 2 次、单桌每秒 1 次。这个值看着很紧,但实际营业中一桌一秒下一单已经很快了,超过的要么是脚本要么是误触。
降级预案我准备了三级:一级是关掉非核心功能(比如推荐、评价、图片大图),二级是把实时推送降级成轮询,三级是只保留下单和支付两个接口,其余返回缓存数据。降级开关做成后台可手动触发的,别等出事再去改代码发版。
6. 常见问题与排查实录
6.1 问题速查表
下面这张表是我在几个项目里攒下来的,按出现频率排序:
| 现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 扫码后桌号不对 | 二维码复用旧桌号 / 签名校验绕过 | 核对二维码生成记录和桌台变更日志 | 换台时同步重新生成二维码 |
| 订单重复出现在吧台 | 客户端重复点击 / 缺幂等键 | 查同一 requestId 的记录数 | 补唯一索引,前端加按钮防抖 |
| 支付成功但订单未支付 | 回调丢失 / 验签失败 | 查支付渠道流水和回调日志 | 启动主动查询补偿任务 |
| 吧台收不到新单 | WebSocket 断连未重连 / 心跳参数过松 | 查连接日志和心跳间隔 | 缩短心跳,加轮询降级 |
| 库存显示有货但下不了单 | Redis 与数据库不一致 | 比对两边库存值 | 定时对账回写,加告警 |
| 打印小票缺行 | 打印模板字符集问题 / 纸宽设置错 | 用纯英文测试打印 | 统一 UTF-8,纸宽按 58mm 配 |
| 结账金额对不上 | 改价未留痕 / 最低消费计算遗漏 | 导出订单变更日志 | 强制改价审批,补最低消费逻辑 |
| 深夜系统变慢 | 定时任务撞上高峰 | 查任务执行时间分布 | 把报表、对账任务挪到凌晨 4 点后 |
6.2 三个只有踩过才知道的坑
第一个坑是时区。服务器默认 UTC,业务按本地时间算"今日营业额",结果凌晨 0 点到 8 点的单子被算到前一天去了。这个 bug 极其隐蔽,因为白天测试完全正常,只有跨零点才会暴露。解决办法是全链路统一用本地时区,数据库连接串里显式指定,别依赖默认值。
第二个坑是二维码贴在桌面上被磨损。听起来不是技术问题,但真发生过——客人扫不出来,服务员只能手输桌号,手输就出错。后来我们在系统里加了"桌号手动选择"的兜底入口,扫码失败时服务员可以人工指定,但会记一条日志,方便统计哪些桌的码该换了。
第三个坑是凌晨的定时任务。日结、报表、库存对账这些任务如果定时在 0 点跑,正好撞上跨年、节假日的高峰尾巴。我现在的统一做法是所有重任务排在凌晨 4 点到 6 点之间,且必须能被打断——任务里加检查点,检测到当前 QPS 超过阈值就暂停,10 分钟后再续。
7. 上线节奏:别指望一次全量推开
系统做完不等于能用,酒吧的现场变量太多了。我的上线节奏一般是三步走。
第一步是影子运行,通常跑三到五天。系统正常接收订单,但不作为唯一依据,服务员仍然用原来的方式记录,每天打烊后拿系统数据和实际单据对一遍。这一步的目的是找出那些需求文档里没写的业务动作,比如"某个卡座的老客有固定折扣""某个时段的服务费怎么算",这些只能靠对账对出来。
第二步是单区域灰度,选一个区域(比如露台或者某个卡座区)先切到系统,其余区域照旧。这一步主要验证硬件——打印机、平板、WiFi 覆盖。酒吧的 WiFi 是个大坑,金属装修、人多、酒水泼溅,AP 掉线是常态,灰度期要重点看有没有死角。
第三步是全量切换,但前两周保留人工兜底。我会在吧台留一台平板专门跑员工端,出问题能立刻手动补单。真正让我放心撤掉兜底的信号是:连续一周没有出现"系统单和实际单不一致"的情况。
这套东西做完,一套酒吧点餐系统算是真正立住了。技术选型上没有什么惊艳的选择,Node.js、MySQL、Redis、WebSocket,都是成熟得不能再成熟的东西。真正决定成败的是那些不起眼的细节——桌台状态机守不守得住、幂等键有没有漏、心跳参数是不是够保守、定时任务有没有避开高峰。我个人的体会是,这类系统的复杂度从来不在写代码本身,而在你是否愿意花时间去现场看一晚上,看服务员怎么用、吧台怎么骂、客人在哪一步卡住。看懂了再去改代码,改一次顶十次。