云主机编号的分配哲学:为什么不直接 max+1

云主机卖出第一台的时候,编号这件事根本不叫问题。等到第三十台、第五十台,回收的号开始和新开的号挤在一起,它就成了一个能让你半夜爬起来查日志的问题。

狸花云给每台容器分配 ID 的逻辑,前后改过三次。现在这套叫「号段池」:不取最大值加一,而是从起始号开始找第一个空闲号。

max+1 的三个毛病

最直觉的做法是 max(已用) + 1。第一版就是这么写的,跑了不到两天就露馅。

一、编号只增不减。 用户删了机器,号就永远空在那里。卖出去一百台、回收八十台,下一台拿到的还是 101。号段越长,运维读日志越累——你没法靠编号判断”这台机器大概什么时候开的”。

二、并发下会撞号。 max+1 是”先查再算”,查完到写进去之间有窗口。两个订单同时进来,都查到 max=57,都想拿 58。这不是理论风险,是必然事件。

三、号段不可规划。 我们想给系统预留 100~199,给托管容器用 200 起。max+1 不看这个,一个数字从 1 开始往上涨,边界全靠运气。

现在的实现

server/pve.mjs 里的 nextVmid:


async nextVmid(startFrom = 200, reserved = [], poolEnd = 999) {
  const list = await this.guests().catch(() => [])
  const used = new Set(list.map(g => parseInt(g.vmid, 10))
                         .filter(n => Number.isFinite(n)))
  for (const r of reserved || []) {
    const n = parseInt(r, 10)
    if (Number.isFinite(n)) used.add(n)
  }
  const start = Math.max(100, parseInt(startFrom, 10) || 200)
  for (let v = start; v <= poolEnd; v++) {
    if (!used.has(v)) return String(v)
  }
  // 号段用满:退回 PVE 自己的 nextid,不硬撑
  try {
    const pveNext = parseInt(String(await api('/cluster/nextid')).trim(), 10)
    if (Number.isFinite(pveNext)) return String(Math.max(pveNext, poolEnd + 1))
  } catch (e) { /* ignore */ }
  return String(poolEnd + 1)
}

三个设计点。

最小空闲号(first-fit)。 回收的号立刻能再用,编号始终紧凑地聚在 200~250 这种区间里。代价是号会被复用——这就引出下一个问题。

reserved 不是可选参数,是安全边界。 号复用的真正危险不在编号本身,在于旧号上可能还挂着东西:旧订单、旧授权、旧账单记录。如果 PVE 里容器删了就当号空闲,下一台新容器会顶着同一个 vmid 继承那些”属于上一个用户”的引用。所以已占用的集合必须是 PVE 实际容器 ∪ 未终结订单里引用的号。

号段用满不退化成死循环。 200~999 满了就调 /cluster/nextid 拿号,从池外继续。宁可编号跳出去,也不能让下单流程卡死在一个空池子上。

哪些订单的号可以回收

这张表是整个逻辑里最容易写错的地方,写反了就是数据越界:

订单状态 号是否保留 理由
pending 保留 用户已下单,容器可能正在创建
paid 保留 钱到了,号已经花出去
provisioning 保留 正在交付中,此时复用会直接踩掉正在创建的任务
active 保留 机器在跑
failed 保留 交付失败但容器可能已建成,需要人工核对
cancelled 可回收 容器已销毁
refunded 可回收 同上

我们只把 cancelled 和 refunded 放行,其余全部保留。failed 尤其不能放——失败不等于没创建,PVE 那边的容器可能还在,只是我们的流程没走完。这条规则宁可保守:多留几个号不算浪费,错杀一个号就是把别人的机器拆了。

多节点之后冒出来的新问题

代码里有这么一行:


if (nodeId && it.nodeId && it.nodeId !== nodeId) continue

多节点环境下,号段是按节点独立的——node1 上的 201 和 node2 上的 201 是两台不同机器,互不影响。因为这几台 PVE 是独立安装的,没有组成集群。

这里有个前提必须写清楚:一旦把多台 PVE 加进同一个集群,这行过滤就是错的。 集群内 vmid 是全局唯一的,此时还按节点去重,两台节点同时开容器就会撞同一个号。真到那一步,reserved 要改成全集群收集,过滤逻辑整段删掉。这类”现在对、条件变了就错”的代码,最好像注释一样写在原地——不然半年后接手的人只会看到一行莫名其妙的 continue。

还没解决的:竞态

诚实一点,这套现在仍有窗口期。nextVmid 是”查—算—用”三步,不是原子操作。两个订单几乎同时进来,理论上能算出同一个号,然后在 createLxc 阶段被 PVE 以”vmid 已存在”顶回来。

目前靠的是撞号后创建失败、订单进 failed 让人工介入——不优雅,但至少不会静默出错。要真正解决得加一把按节点的互斥锁,把分配和创建串起来。这事记在待办里了,还没做。

编号这种事,用户一辈子不会注意到。它做对了是隐形的,做错了是数据事故。所以我们把它当安全边界来写,不当成计数器来写。

💬 留下你的爪印