扫码点餐里的并发和断网:我在第一个全栈项目里做的几个决定

0 阅读5分钟

正文

最近我把自己的第一个全栈项目开源了:一个给小餐厅用的堂食扫码点餐系统,后端 ASP.NET Core + EF Core + PostgreSQL,前端 React + TanStack。顾客扫桌上的二维码点菜,厨房和传菜台在平板上看订单,店主收完钱在后台关掉账单。

仓库:github.com/lorenzoyang…

先交代一下:大部分代码是 Claude Code 和 Codex 写的,我负责的是产品范围、架构和下面这些规则。这篇不聊 AI,聊的是这个项目里我觉得最值得讲的部分。

餐厅里有两件事一直在发生:

  1. 好几个人同时操作。 几个服务员、几桌客人、厨房和店主,同时在用。
  2. 网络不可靠。 餐厅 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 以内,浏览器那边不会丢精度。)

四、幂等的下单

顾客在信号很差的地方点了「下单」。请求到了,订单存好了,响应丢了。手机显示失败,顾客再点一次,厨房收到两份一样的订单。

解决办法是让下单变成幂等的:

  1. 浏览器生成一个 clientRequestId,先把它和完整的订单一起存进 sessionStorage,然后才发 POST。从这一刻起购物车锁定,直到知道结果。
  2. 后端加锁、确认这一轮用餐还在进行之后,第一件事就是查有没有同 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)
  1. (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 恢复

下单的问题其实适用于每个写操作:开桌、出餐、结账。超时或者断网,结果就是未知的,可能成功了,也可能没有。

我给最敏感的那些写操作定了同一套流程:

  1. 人确认之后,先在 sessionStorage 里存一个最小的「意图」(比如厨房存订单 ID、订单行 ID 和动作「出餐」),然后发 POST。
  2. 成功或者服务器明确拒绝:清掉意图,显示结果。
  3. 超时或网络错误:标记为「结果待核对」,相关的操作先锁住。
  4. 网络恢复、页面重新可见或者刷新时:用 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。