正文
最近我把自己的第一个全栈项目开源了:一个给小餐厅用的堂食扫码点餐系统,后端 ASP.NET Core + EF Core + PostgreSQL,前端 React + TanStack。顾客扫桌上的二维码点菜,厨房和传菜台在平板上看订单,店主收完钱在后台关掉账单。
先交代一下:大部分代码是 Claude Code 和 Codex 写的,我负责的是产品范围、架构和下面这些规则。这篇不聊 AI,聊的是这个项目里我觉得最值得讲的部分。
餐厅里有两件事一直在发生:
- 好几个人同时操作。 几个服务员、几桌客人、厨房和店主,同时在用。
- 网络不可靠。 餐厅 Wi-Fi 经常时好时坏,请求发出去了,响应可能回不来。
几乎每个设计决定,都在回答这两个问题。
一、行锁,以及固定的加锁顺序
先看一个最简单的竞争:两个服务员同时给 5 号桌点「开桌」。如果每个请求都是「读一下有没有进行中的用餐,没有就新建一个」,两个请求可能同时读到「没有」,于是 5 号桌出现两轮用餐。
我的做法是在事务里做决定之前,先用 SELECT ... FOR UPDATE 或 FOR SHARE 锁住决定依赖的那几行。EF Core 没有行锁的 LINQ API,所以这里用 FromSql 手写 SQL(插值会被转成参数,不会有注入问题):
// Data/RowLocks.cs(节选)
db.RestaurantTables.FromSql(
$"SELECT * FROM restaurant_tables WHERE restaurant_id = {restaurantId} AND id = {id} FOR UPDATE")
.AsNoTracking()
.SingleOrDefaultAsync(ct);
锁一多就有死锁的风险:A 锁了桌位在等用餐,B 锁了用餐在等桌位。所以所有操作都按同一个顺序加锁,而且只往一个方向走:
餐厅 → 桌位 → 用餐 餐厅 → 菜品
| 操作 | 加锁 |
|---|---|
| 开桌 | 餐厅 SHARE → 桌位 UPDATE |
| 顾客下单 | 餐厅 SHARE → 桌位 SHARE → 用餐 UPDATE |
| 厨房出餐 / 取消、结账 | 只锁用餐 UPDATE |
| 改菜单 | 餐厅 UPDATE |
下单时为什么要用 SHARE 锁住餐厅?因为改菜单(价格、售罄)会用 UPDATE 锁住餐厅。这样在下单的那几毫秒里,没人能改价格,复制进订单的价格就是刚检查过的价格。而不同桌的两个订单都只拿 SHARE 锁,互相兼容,可以并行。
所有锁都放在一个文件里,加锁顺序写在文件开头的注释里,review 时一眼就能对上。
另外还有一层数据库约束:table_id 上有一个只针对 status = 'ACTIVE' 的部分唯一索引,所以就算哪天代码忘了加锁,一张桌也不可能有两轮进行中的用餐。
二、条件 UPDATE 作为第二道防线
厨房把一道菜标为「出餐完成」时,不是读出来改完再保存,而是一个带条件的 UPDATE,然后检查改了几行:
// Kitchen/KitchenService.cs(简化)
var changed = await db.OrderItems
.Where(i => i.RestaurantId == restaurantId && i.OrderId == id && i.Id == itemId
&& i.Status == OrderItemStatus.New)
.ExecuteUpdateAsync(u => u
.SetProperty(i => i.Status, OrderItemStatus.Ready)
.SetProperty(i => i.ReadyAt, now), ct);
if (changed != 1)
throw Changed(); // 409 ORDER_ITEM_CHANGED
有了用餐的行锁,第二个请求本来就会排在后面,读到这道菜已经是 READY。条件 UPDATE 是给将来准备的:万一某条新的写入路径忘了加锁,也不会有人悄悄覆盖别人的结果。
三、账单的乐观版本号
店主打开 5 号桌的账单,看到 45 欧,走去收银台收钱。这时顾客又点了一杯饮料,或者厨房取消了一道菜。店主点「确认收款」时,关掉的必须是店主看到并收了钱的那一份。
这里没法用行锁:总不能在店主盯着屏幕的那一两分钟里一直锁着数据库。所以每轮用餐有一个 bill_version,金额每变一次就加 1(新订单、取消一道菜)。前端把确认框里显示的版本和总额冻结住,结账时一起发过去:
{ "expectedBillVersion": 7, "confirmedTotalCents": 4500 }
后端锁住这一轮用餐,从数据库里的订单行重新算一遍。版本或总额对不上,就返回 409 BILL_CHANGED,响应里带上最新的账单,前端显示出来让店主重新确认。
两种锁各管一段:悲观锁保护事务的那几毫秒,乐观版本保护人做决定的那几秒到几分钟。
(金额全部用整数欧分保存,最大值限制在 JavaScript 能精确表示的 2⁵³ − 1 以内,浏览器那边不会丢精度。)
四、幂等的下单
顾客在信号很差的地方点了「下单」。请求到了,订单存好了,响应丢了。手机显示失败,顾客再点一次,厨房收到两份一样的订单。
解决办法是让下单变成幂等的:
- 浏览器生成一个
clientRequestId,先把它和完整的订单一起存进sessionStorage,然后才发 POST。从这一刻起购物车锁定,直到知道结果。 - 后端加锁、确认这一轮用餐还在进行之后,第一件事就是查有没有同 ID 的订单,有就直接返回:
// Ordering/OrderService.cs(简化,前面还锁了餐厅和桌位)
var session = await LockSessionAsync(guest, RowLock.Update, ct);
var original = await Orders(guest)
.SingleOrDefaultAsync(o => o.ClientRequestId == input.ClientRequestId, ct);
if (original is not null)
{
await transaction.CommitAsync(ct);
return new(OrderResponse.From(original), false); // 200:订单已经存在
}
// 只有第一次请求才会走到这里:是否在接单、是否售罄、价格是否变了,然后保存(201)
(dining_session_id, client_request_id)上有唯一索引,数据库兜底。
检查顺序是这里最容易做错的地方。如果重试的请求也去检查售罄和价格,那么一个在菜卖完之后重试的顾客会收到「售罄」,可这位顾客的订单其实早就在厨房里了。
还有个小坑:生成 UUID 我没用 crypto.randomUUID(),因为它只在安全上下文(HTTPS 或 localhost)里能用,我在局域网里用手机通过 HTTP 测试时它会报错。所以用 getRandomValues 自己拼了一个 UUIDv4:
// lib/ids.ts
export function randomUuid(random: Pick<Crypto, "getRandomValues"> = crypto): string {
const bytes = random.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, (n) => n.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
五、结果未知时,只用 GET 恢复
下单的问题其实适用于每个写操作:开桌、出餐、结账。超时或者断网,结果就是未知的,可能成功了,也可能没有。
我给最敏感的那些写操作定了同一套流程:
- 人确认之后,先在
sessionStorage里存一个最小的「意图」(比如厨房存订单 ID、订单行 ID 和动作「出餐」),然后发 POST。 - 成功或者服务器明确拒绝:清掉意图,显示结果。
- 超时或网络错误:标记为「结果待核对」,相关的操作先锁住。
- 网络恢复、页面重新可见或者刷新时:用 GET 查原来那个资源。已经完成了就清掉意图;没完成,就让人重新确认后再发。
前端从来不会自己重发 POST。GET 不改变任何东西,重复多少次都安全;自动重发的 POST 可能把一件事做两遍,比如把刚结完账的桌又开了一次。
为什么用 sessionStorage 而不是 localStorage:它只属于一个标签页,关掉就没了,两个标签页不会互相干扰。里面只放标识符,不放密码和入桌码。
六、用轮询,不用 WebSocket
厨房和传菜每 10 秒问一次服务器,桌台和结账每 30 秒,页面隐藏或者离线时停止。
小餐厅就几台设备,晚 10 秒可以接受。换来的是:没有长连接要维护,不用写重连,代理不需要特殊配置,每个请求都是普通的 GET,照常走认证和授权。断网恢复后,下一次轮询就全同步回来了。
轮询的请求要轻:只读进行中的用餐,从不扫描历史。判断「有没有新订单」用一个水位线:最新订单的创建时间,从索引的一端读出来。水位线比上次前进了,厨房就响一声。第一次加载和页面重新可见时只记录基准,不响。
另一个规则是:数据太旧时不许写。厨房、传菜和结账页面如果超过 30 秒没刷新成功,或者页面刚从后台切回来还没刷新,写操作就会被禁用,免得基于旧数据关掉一张已经变了的账单。
七、怎么测试并发
两个请求的竞争没法靠运气复现。后端的集成测试每个测试类会用 Testcontainers 在 Docker 里起一个真实的 PostgreSQL,跑真实的迁移。并发测试里有一个 LockGate,它是 EF Core 的 DbCommandInterceptor 子类,能识别对 dining_sessions 加锁的那条 SQL,让一个事务停在那里:
// KitchenTests.cs(节选)
private sealed class LockGate(bool hold) : DbCommandInterceptor
{
public TaskCompletionSource Attempted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Acquired { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
private static bool Matches(DbCommand cmd) =>
cmd.CommandText.Contains("FROM dining_sessions", StringComparison.Ordinal)
&& cmd.CommandText.Contains("FOR ", StringComparison.Ordinal);
// ...
}
测试先让第一个请求拿到锁停住,再发第二个请求,检查它是在等,还是被拒绝,然后放开第一个,看最终结果对不对。后端一共 165 个测试。
局限
这个项目规模不大,局限也很明显:
- 只能单实例,限流的计数放在内存里。
- 轮询的请求数随设备数增长,餐厅多了应该换 SSE 或 WebSocket。
- 入桌码四位,大约一百万种组合,主要靠限流防猜。
- 前端只有纯逻辑的测试,没有组件测试和 E2E。
- 不能打印厨房单,可这偏偏是我拜访过的餐厅几乎都要的功能。
最后
想自己跑起来看看的话,装了 Docker 就行:
git clone https://github.com/lorenzoyang/TavoloFlow.git
cd TavoloFlow
docker compose -f demo/compose.yaml up -d
打开 http://127.0.0.1:8080/staff/login ,店主账号 demo.ramen.owner,密码 123456。
上面每一节在仓库的开发笔记里都有更完整的版本,包括我觉得做错的地方:github.com/lorenzoyang…
有问题或者觉得哪里设计得不对,欢迎在评论区或者 issue 里说。觉得有用的话,也可以点个 star。