2.1 档期设计:时段表与容量
场景:一天到底该切几格
档期表是预约系统的地基。切得太粗客人不自由,切得太细容量难维护,粒度选在哪是第一个要回答的问题。除了粒度,还有三个变量互相牵扯:门店之间客流不同、工作日和周末客流不同、每个时段在岗的美甲师数量也不同。四个变量叠在一起,档期设计其实是一道组合题。而组合题最怕的就是一拍脑袋给所有门店配同一套容量,那等于把商场店和社区店的区别全部抹平。
我们最早的设计简单粗暴:一天按小时切,客人只能约整点。结果第一天就被前台吐槽:客人想做 14:30 的指甲,系统里根本没有这个选项。另一头,延长甲要两个半小时,约 14:00 的人服务到 16:30,下一个 14:00 的格子根本接不了新客。
我们的答案是 30 分钟一格,从 10:00 到 21:00 一天 22 个格子。同时每个格子带一个容量字段,而不是约满即删。容量思维是从美甲店的实际运营悟出来的:店里同一时刻有多个美甲师在岗,一个时段能同时服务好几位客人,把它当成一个格子一个客人就完全错了。
粒度这事还考虑过两个极端方案:一是按服务时长动态切格子,客人选了项目才生成对应时段,实现复杂到爆炸;二是干脆不切格子,只记录每位美甲师的开始时间和结束时间排程,最灵活但对客人的选择体验极差。折中之后选了固定 30 分钟格加容量,客人选得到、系统能对齐、排班能维护,三方都能接受。后来复盘,这个折中让我们少写了至少三分之一的代码,也少吵了三次架。
方案:门店、星期、时段三维度容量
容量不是拍脑袋填的,而是按门店按星期按时段排出来的模板。我们给三家店各做了一张容量表,先按星期几定义基线,再用特殊日期覆盖节假日。三店的容量性格差别大到必须分别配置:
| 门店 | 工作日特征 | 周末特征 | 容量峰值 |
|---|---|---|---|
| 社区店 | 上午忙 | 全天均匀 | 午后 3 位 |
| 写字楼店 | 午晚两个尖峰 | 冷清 | 下班 5 位 |
| 商场店 | 下午起量 | 节假日翻倍 | 傍晚 6 位 |
写字楼店周一到周五的基线片段如下,周末完全是另一套容量:
| 时段 | 周一容量 | 周三容量 | 周五容量 | 说明 |
|---|---|---|---|---|
| 10:00 | 1 | 1 | 2 | 刚开门 |
| 12:00 | 3 | 3 | 4 | 午休高峰 |
| 14:00 | 2 | 2 | 3 | 下午平淡 |
| 16:00 | 2 | 2 | 3 | 接学下班的客 |
| 18:00 | 4 | 4 | 5 | 下班高峰 |
| 20:00 | 2 | 2 | 3 | 打烊前 |
容量数字来自林姐提供的过去半年手工登记本,哪个时段几个美甲师在岗写得清清楚楚,我们把登记本逐行录进模板表,比任何拍脑袋都准。录入这件事花了两个晚上,但从此容量数字有了出处,每次开会讨论调整都有据可依,不再是哪个声音大听哪个的。
每逢节假日,直接在 t_slot 上批量生成一条覆盖记录即可,不用改模板。模板表 t_slot_pattern 和实例表 t_slot 分开存:模板定义规则,实例是每天真正被预约的格子。每天凌晨的定时任务把未来 30 天的格子实例化出来,前台排班改的是模板,客人约的是实例。模板表的结构精简到四个字段加一个唯一约束:
CREATE TABLE t_slot_pattern (
id INT PRIMARY KEY AUTO_INCREMENT,
shop_id INT NOT NULL,
weekday TINYINT NOT NULL, -- 0 周日到 6 周六
start_min INT NOT NULL,
capacity INT NOT NULL,
UNIQUE KEY uk (shop_id, weekday, start_min)
);
加唯一索引的目的是防止前台手滑把同一时段录两遍,DB 层面拦住比事后修数据干净。前台在管理工作台改容量时走的是 upsert 语义,改完立刻能看到未来实例的变化,降低了沟通成本。
配置/代码:实例化和查询
实例化的核心是一段纯 SQL 加定时任务,每天跑一次:
// 每天凌晨把未来第 30 天的格子实例化出来
func genSlots(ctx context.Context, day time.Time) error {
wd := int(day.Weekday())
q := `INSERT INTO t_slot (shop_id, slot_date, start_min, capacity)
SELECT p.shop_id, ?, p.start_min, p.capacity
FROM t_slot_pattern p WHERE p.weekday = ?`
_, err := db.Exec(q, day, wd)
return err
}
查询某店某天剩余位置的时候,一行 SQL 就够:
SELECT slot_id, start_min, capacity, booked,
capacity - booked AS remain
FROM t_slot
WHERE shop_id = 1 AND slot_date = '2026-10-24'
AND capacity > booked;
节假日覆盖则走一条批量更新,比如国庆把商场店所有时段容量整体放大一倍:
UPDATE t_slot
SET capacity = capacity * 2
WHERE shop_id = 3
AND slot_date BETWEEN '2026-10-01' AND '2026-10-07';
执行前一定先 SELECT 一遍确认影响行数,再开事务执行,这种全表级更新最怕手滑 Between 写错日期段。
前端拿到剩余位之后自己排序展示,分页、高亮、约满置灰全在前端完成。服务端只保证一件事情:remain 这个数字在并发环境下是准的。准不准靠下一节的锁机制兜底,而准的前提又是档期表自己不能有脏数据,两件事互为表里。
给前端返回时段列表时还有一个小技巧:把未来 30 天的数据一次性拉下来缓存在小程序本地,客人切换日期不再请求网络。每天首次打开刷新一次,响应速度直接起飞,详见 技术栈对齐 里的接口设计。这个缓存也省了服务端大量的重复查询,轻量的 5M 带宽压力进一步下降。
节假日的覆盖则单独给店长开了一个批量工具,一次性把整个国庆的容量拍上去,比逐个时段改快了一个量级,这个功能后来成了店长最爱用的入口之一,国庆前她花十分钟就把七天的容量拍完,然后安心去喝茶。
踩坑:三个与时间有关的坑
第一个坑是跨天预约。有客人约了 20:30 的服务,时长 90 分钟,要做到 22:00,而门店 21:00 打烊。最初系统没管结束时间,客人约成功了到店却被拒,前台只能赔一杯奶茶。之后所有可约判定都要额外校验开始时间加时长不超过打烊时间,再把约 20:30 那栏直接置灰,客人连犯错的机会都没有。
第二个坑是夏令时和法定节假日调整。国庆调休把一个周六变成工作日,模板表按 weekday 匹配直接失效,那一天要么没格子要么容量全错。我们的解法是在 t_slot 加一个 override_day 字段,调休日手工指定按周几的模板生成,并且让它成为节前 checklist 的固定一项。每年日办发布调休安排的那天,我们都会收到自动提醒,再没掉过链子。这个提醒只是一个日历事件,却是整个项目里最便宜的一次事故预防。
第三个坑是容量瞬时为负。极端并发下 booked 可能短暂超过 capacity,前端看到剩一位,下单时却失败。客人的感受是系统在骗人。后来我们在 booked 上加了 check 约束,并在前端展示时多留一位缓冲(_display 时减一),双管齐下治好了幻读问题。
第四个坑是历史数据。模板改了之后,已经生成出来的实例不会自动跟着变,客人约的还是旧容量。我们的做法是改模板时弹窗确认是否同步未来七天实例,默认勾选,把决策权交给前台而不是程序员。这个交互上线之后,关于容量不对的投诉降到了零。另外实例表的保留周期我们定了一年,超过一年的格子归档到冷表,主表永远只装一年数据,查询性能稳定,explain 出来的扫描行数几乎不随时间增长。
小结
档期设计的心法是模板与实例分离、容量而非占用、粒度服务业务。30 分钟一格只是我们的选择,换一个行业也许 15 分钟或一小时更合适,但三个原则是通用的:粒度让客人选得到、容量让同时段接得了、模板让排班改得动。最难的不是写代码,而是把这三个原则给林姐和前台讲明白,她们用得顺手,档期表才真正活下来。我们把模板表的维护界面做得跟登记本一模一样,前台上手只花了二十分钟,连培训文档都省了。地基打好,下一节才能放心地往上叠加并发占位的逻辑。
延伸阅读
- 占位与锁的并发实现,见 预约下单:并发占位与锁
- 门店维度的权限设计,翻 多门店:账号与数据权限
- 另一套时段型业务实践,见 小程序商城实战
- 核销如何回收状态,见 到店核销:扫码与状态回写
