凌晨2点,我回滚了刚上线的代码

12 阅读5分钟

一、凌晨1:47,手机炸了

我被钉钉的报警声吵醒。

屏幕上赫然写着: 【故障告警】订单系统500错误率飙升 - P0级

P0,最高优先级,系统不可用。

半小时前,我刚上线了一个“小优化”——改了几行代码,自测没问题,提测没问题。我自信地点了发布,安心睡觉。

现在,凌晨1:47,我看着监控曲线,那条线直直地冲上去,然后断了。

二、那几行代码干了什么

3天前,产品提了个需求:用户输入手机号时,去掉前后的空格。

很简单对吧?我当时的心理活动:5分钟改完,摸鱼半天

现有代码是这样的:

async function createOrder(req, res) {
  const { phone } = req.body;
  // 创建订单...
  const order = await Order.create({ phone });
  res.json({ success: true, order });
}

我加了一行“贴心”的优化:

// 新增:去掉手机号前后的空格
if (phone) {
  phone = phone.trim();
}

自测:输入" 13800138000 " → 变成"13800138000" ✅
测试同学也测了几个用例,都过了。

完美,上线!

三、我忽略的“小细节”

问题出在哪?

用户复制手机号时,经常会带一些看不见的字符。比如:

场景一:iPhone用户从通讯录复制
通讯录里的手机号可能是这样的:13800138000 
最后面那个不是普通空格,是Unicode的\u00A0(不换行空格)

场景二:从网页复制带国家码的号码
+86 13800138000 中间的短横线或全角空格

场景三:iOS系统自动添加的格式化符号
有些系统会在手机号里悄悄加上(窄空格)或­(软连字符)

我的trim()只去掉了普通空格(\x20),但这些特殊空白字符它根本不认识。

结果是什么?

  • 用户输入的手机号:13800138000\u00A0
  • 我的代码:phone.trim() → 还是13800138000\u00A0
  • 数据库校验:手机号包含非法字符 → 抛错 → 用户下单失败

这些场景占比不高,但刚好集中在凌晨的海外用户和夜猫子用户。监控显示,错误率从0.1%飙升到37%。

四、回滚那5分钟

发现问题后,我面临两个选择:

方案耗时风险
热修复15分钟可能引入新问题
回滚5分钟无损恢复

我选了回滚。

1:52,开始操作:

git checkout 上一个稳定版本
npm run build
pm2 restart order-service

每敲一行命令,手都在抖。监控面板上的红线开始回落:

  • 1:54 37% → 15%
  • 1:55 15% → 3%
  • 1:57 3% → 0.2%
  • 1:58 恢复正常

1:59,我在群里发:已回滚,系统恢复。

老板回了个:👍

那一刻,这个👍比任何表情都亲切。

五、为什么会犯这个错

1. 对“空白字符”的理解太狭隘

我以为空格就是空格,不知道Unicode里有几十种空白字符:

  • \u0020 普通空格(我认识的)
  • \u00A0 不换行空格(我不认识的)
  • \u2000-\u200A` 各种宽度空格(我更不认识的)
  • \u202F 窄空格
  • \u205F 中数学空格

我的trim()只处理了第一种,剩下的全漏了。

2. 测试用例不完善

我只测了:

  • ✅ " 13800138000 " → 普通空格
  • ✅ "13800138000" → 正常手机号

我没测:

  • ❌ "13800138000\u00A0" → 不换行空格
  • ❌ "138 0013 8000" → 中间有空格
  • ❌ "+86 13800138000" → 带国家码

3. 缺少防护机制

  • 没有代码评审:如果有人问一句“用户复制过来的手机号可能带什么特殊字符”,可能就避坑了
  • 没有灰度发布:如果先放量1%,最多影响1%的订单
  • 发布后没看监控:错误率其实在上升,但我睡觉了

六、改进方案

事故后,我把代码改成了这样:

async function createOrder(req, res) {
  let { phone } = req.body;
  
  if (phone) {
    // 1. 去除所有Unicode空白字符
    phone = phone.replace(/[\s\u00A0\u2000-\u200B\u202F\u205F]+/g, '');
    
    // 2. 只保留数字和+
    phone = phone.replace(/[^\d+]/g, '');
    
    // 3. 如果以86开头但没加号,补上
    if (phone.startsWith('86') && !phone.startsWith('+86')) {
      phone = '+' + phone;
    }
  }
  
  // 继续其他逻辑...
}

还写了个专门的测试用例集:

// 测试用例
const testCases = [
  ' 13800138000 ',              // 普通空格
  '13800138000\u00A0',          // 不换行空格
  '138 0013 8000',              // 窄空格
  '138-0013-8000',              // 短横线
  '+86 13800138000',             // 带国家码和空格
  '86138001380000',  // 全角数字(另一个坑)
];

还定了几条规则:

  • 重要修改必须Code Review,哪怕只改一行
  • 先写测试用例再写代码,必须包含边界场景
  • 灰度发布,先放量1%观察10分钟
  • 不在周五晚上发布,有事白天处理

七、凌晨3点的感悟

那11分钟的事故,让我明白了几件事:

  • 你以为的“小优化”,可能是别人的“大灾难”
  • 用户的输入永远比你想象的野
  • 代码没有“简单”的,每一行都可能出问题
  • 凌晨的月亮很圆,但我再也不想看了