乐于分享
好东西不私藏

美团一面真题:设计一个预约系统,时间粒度怎么切?

美团一面真题:设计一个预约系统,时间粒度怎么切?

前两天帮一个朋友复盘美团面试,题目是:设计一个餐厅预订系统。他上来就说用 Redis 加锁防超卖,面试官打断了他:"先别急着聊技术方案,你告诉我,餐厅的预订时段,你打算怎么切?"

他愣住了。

这道题,面试有个标准答法;但真在业务里做过预约系统的人都知道,标准答法和生产现实之间,差着一条鸿沟。今天我把两边都说给你听——哪句是面试题的"正确废话",哪句是会让你半夜接告警的真东西。

一、六维考量:面试并列,生产排序

面试标准答

一个完整的预约系统,至少回答六个问题:时间粒度、并发控制、库存模型、生命周期、一致性、可用性。其中时间粒度是地基,其他五个是延伸。

真实生产

这六个维度不是"设计出来"的,是"长出来"的。我见过的预约系统 v1,通常就是一张预约记录表加一个状态字段,库存是后来才补的,对账是出了超卖事故后才加的。

更关键的是:面试把这六维并列,真实里它们的优先级完全由业务决定。酒店最怕库存不一致(超卖一间房是事故),丽人最怕时段利用率低(椅子空着就是亏),景区最怕峰值扛不住(放票瞬间崩)。你按酒店的经验去设计餐厅,大概率用力点错了。

双轨小结:面试考你"想得全",生产考你"排得对"。

二、时间段细化的三种策略

策略一:固定粒度切分

面试标准答

把营业时间切成等长块,每块一个可约单元。简单,一个 slot 表搞定。

真实生产

纯固定切分在真实系统里极少独立存在。即便看起来固定(比如景区场次、疫苗时段),背后也是"资源 + 时长"推导出来的,不是人填的。而且——重点来了——餐厅几乎不用固定时间段模型。餐厅留的是桌、是物理座位,不是一段被切好的时间。用固定时间段去套餐厅,从根上就拧了。

策略二:规则驱动的动态粒度

面试标准答

定义服务模板(桌型、时长、可用时段),系统实时算可用 slot。灵活,大桌小桌各算各的。

CREATE TABLE service_template (    id BIGINT PRIMARY KEY,    shop_id BIGINT,    table_type VARCHAR(32),    -- 桌型:大桌/小桌/包间    duration_min INT,          -- 预估用餐时长(分钟)    weekly_pattern JSON,       -- 周期模式    capacity_per_slot INT,    slot_interval_min INT);

真实生产

实时算的"慢",在美团那个量级是真的痛,这点面试答法没说错。但更真实的痛是:规则谁来维护?

真实里规则是商家自己配的。商家配错(把午餐设成 25 小时、把容量填成负数),系统就得兜底。规则引擎的真实成本叫"规则爆炸"——特殊日子、特殊桌型、特殊渠道、特殊会员等级,规则越叠越多,最后没人敢改,因为不知道改一条会炸哪条。

策略三:预物化时段

面试标准答

提前把 slot 算好存起来,用户查的时候直接读,空间换时间,生产首选。

CREATE TABLE appointment_slot (    id BIGINT PRIMARY KEY,    shop_id BIGINT,    table_type VARCHAR(32),    slot_date DATE,    start_time TIME,    end_time TIME,    total_capacity INT,    booked_count INT DEFAULT 0,    status TINYINT DEFAULT 1,    version INT DEFAULT 0);

真实生产

预物化确实是主流优化,但代价被严重低估了。核心矛盾是:预物化的 slot 是派生数据,不是真相源。真相源是商家的桌台和排班。商家下午两点改了营业时间,你预物化的 slot 就得失效重算。

美团那个量级,全量重算成本扛不住,所以真实做法是增量更新 + 事件驱动:商家改配置 → 发事件 → 只重算受影响商家的 slot。而且预物化本身引入"数据新鲜度"问题——用户看到的 slot,可能是几分钟前的快照。这在某些业务里(比如紧俏的包间)是能引发客诉的。

双轨小结:预物化是优化,不是起点。上来就预物化,源数据变了你哭都来不及。

三、五个工程难点:标准答很美,生产很脏

难点 1:时段重叠

面试标准答

每次插入预订,查新时段和已有预订是否重叠,重叠就拒绝。

SELECT COUNT(*) FROM appointment_recordWHERE shop_id = ? AND table_type = ?  AND status IN ('CONFIRMED', 'PENDING')  AND start_time < :new_end_time  AND end_time > :new_start_time

真实生产

重叠检查在分布式下不是一条 SQL。多个服务同时写,靠的是 DB 的约束(唯一索引、排他锁)才真稳。更根本的是:餐厅的"留座"根本不是严格时间块互斥——它是"这张桌子我给你留到几点",超时你没来我就翻台给别人。模型选成严格时间段互斥,反而制造了不存在的冲突。

难点 2:库存碎片

面试标准答

取消后剩的时间不够一桌,标记为碎片时段,不重新开放。

真实生产

碎片,是 slot 库存模型自己造出来的难题。真实里很多系统根本不预扣细粒度库存,而是用桌台状态:一张桌子空着就能接下一桌,不存在"30 分钟槽被拆成 5+25"这回事。

这说明一件事:选错模型,才会制造出本来不存在的难题。 你花大力气解决碎片,不如一开始就别用会碎片的模型。

难点 3:并发防超卖

面试标准答

三层防线:Redis 预扣 → 数据库乐观锁 → MQ 异步落库。

-- Redis Lua 原子预扣local remaining = redis.call('DECR', KEYS[1])if remaining < 0 then  redis.call('INCR', KEYS[1]) return -1endreturn remainingUPDATE appointment_slotSET booked_count = booked_count + 1, version = version + 1WHERE id = ? AND booked_count < total_capacity AND version = ?

真实生产

这套"三层"最大的问题是它本身就是双写不一致的著名大坑。Redis 扣了 DB 没写、Redis 挂了 DB 是旧值、MQ 重复消费导致多扣——每一条都是生产事故,我都见过。

真实稳健的做法往往是:以 DB 为唯一真相源。用 DB 事务 + 行锁 + 唯一约束保证不超卖;Redis 只做展示层缓存和限流闸门,不承认真实库存。或者直接用成熟的库存服务 / 分布式事务框架,而不是手写三层去赌"对账能兜住"。

"对账兜底"四个字,在面试里一笔带过;在生产里,它是一整套对账系统、告警、人工干预流程。别小看它。

难点 4:超时释放

面试标准答

下单发延迟消息,15 分钟后检查未确认就释放,记得做二次状态检查。

message.setDeliverTime(now + 15 * 60 * 1000);mqProducer.send("reservation.timeout", message);// 消费时检查状态if (res.getStatus() == PENDING) {    res.setStatus(EXPIRED);    slotService.releaseCapacity(res.getSlotId());}

真实生产

二次检查只是最低要求。真实坑有两个:

一是 race:用户刚付完款,超时消息也到了。光"检查状态"不够,要做状态机合法流转校验——只有 PENDING→EXPIRED 合法,CONFIRMED 状态不能被超时消息改掉。

二是 重复投递:延迟队列是"至少一次"投递,同一条超时消息可能来两次。释放操作必须幂等,否则释放两次把别人的库存也释放了。

难点 5:节假日与特殊排班

面试标准答

建一张排班例外表,优先级最高,覆盖模板规则。

CREATE TABLE schedule_override (    id BIGINT PRIMARY KEY,    shop_id BIGINT,    override_date DATE,    override_type TINYINT,    -- 1-休息 2-加班 3-包场    time_ranges JSON,    capacity_override INT);

真实生产

例外表真实存在,但真实的复杂度是"例外之上还有例外":国家调休(周六变工作日)、商家临时闭店、某个渠道的独家活动。这些散落在多个系统——HR 排班、营销活动、商家后台——不是一张干净的 override 表能兜住的。

真实做法是事件聚合:把各来源的"不可约 / 特殊"事件汇总成一份当天的有效规则,而不是查一张静态表。这张"表"是算出来的,不是存出来的。

四、整体架构:骨架是对的,肉是后来长的

面试标准答

用户 → 网关 → 预订服务,底下 Redis 预扣、MQ 落库、DB 存储,再加对账和定时任务。

真实生产

图是对的骨架。但真实系统里这些组件之间有大量"胶水":对账任务、补偿任务、监控告警、灰度开关、降级预案。

而且这个架构不是一天画出来的——先单库,再加重缓存,再拆服务,再上 MQ。它是演化结果,不是设计结果。你拿面试那张干净的图去对标真实系统,会觉得自己"少了好多东西"。不是你少了,是那张图省略了。

五、时段可见性:方向对,但别独立

面试标准答

建一个可见性规则引擎,查询 slot 时统一过滤:会员提前 7 天、普通用户提前 3 天、特价时段仅首页可见。

return slots.stream()    .filter(s -> s.getBookableTime().isAfter(now))    .filter(s -> minutesBetween(now, s.getStartTime()) >= bufferMin)    .filter(s -> user.canAccess(s.getVisibilityLevel()))    .collect(toList());

真实生产

规则引擎的方向是对的。但真实里"可见性"和会员体系、营销系统深度耦合,不是个独立模块。而且真实坑是:可见性规则改了,缓存的 slot 没失效,导致老用户看到老规则的结果。可见性不是加个 filter 就完事,它牵一发动全身。


收尾

回到开头那个朋友。他后来跟我说,面试官听他讲完三种策略,说了句"你比前面几个想得透"——但那是面试。

真去做的时候,你会发现:

面试考你"想得全"——六维铺开,时间粒度三种策略讲清,结构化思维就立住了,够了。

生产考你"选得对、扛得住"——先想清楚你的资源到底是"时间段"还是"桌台/房间",选对模型比选对策略重要十倍;预物化是优化不是起点;Redis 别当真相源;规则会爆炸,留好逃生通道。

一句话收掉:标准答法让你过面试,真实落地才不翻车。两边都懂,你才是真的懂。


如果你觉得这篇有用,关注JAVA葵花宝典

大厂面试真题拆解 · 生产环境真实踩坑 · 系统设计底层思路

不是背诵题,是工程视角。每一篇都让你看完能直接用。

关注 JAVA葵花宝典,技术路上不孤单。