事情是这样的。那天我review了自己的代码,心想这次稳了——单元测试全绿,lint通过,连PM都夸我代码写得漂亮。结果上线第一天,财务对账差了3000块;第二天,用户反映余额对不上;第三天,CTO在群里@了我。
这不是故事会,这是事故报告。今天我们就来扒一扒那些「编译器懒得告诉你、测试懒得发现、只有线上才会跳出来」的诡异bug。看完你会发现,原来自己这么多年的代码都是在赌运气。
一、浮点数比较:你以为的0.1+0.2==0.3?
先问个问题,下面这段代码会打印什么?
const a = 0.1;
const b = 0.2;
console.log(a + b === 0.3); // ???
答案是false。0.1 + 0.2 = 0.30000000000000004。这是IEEE 754浮点数的固有特性,不是bug,是标准。
但我见过有人这么写:
// 结算金额比较,千万别这么写
if (order.amount + refund.amount === totalAmount) {
// 对账成功
}
上线后对账系统每天都有零星差异,几分钱到几十块不等。调查了一周才发现是浮点数作祟。
正确的做法:
// 方案1:使用整数运算(最小单位,如分、厘)
const amountInCents = 199; // 1.99元
// 方案2:使用epsilon比较
const epsilon = 0.000001;
if (Math.abs(a + b - 0.3) < epsilon) {
// 相等
}
// 方案3:使用成熟的金额库
// Python: Decimal
// JavaScript: decimal.js
// Go: shopspring/decimal
记住,任何涉及金钱的计算,必须用整数或Decimal,绝不能用float/double。这不是性能优化,这是职业道德。
二、时区问题:UTC真能解决一切吗?
很多程序员被教育说「存储用UTC,显示做转换」。听起来很完美,但现实会给你一巴掌。
看这个场景:
// 用户下单,假设现在是2024-03-10 02:30
const orderTime = new Date('2024-03-10T02:30:00Z'); // UTC时间
// 业务逻辑:订单超过24小时自动取消
const now = new Date();
const diffHours = (now - orderTime) / (1000 * 60 * 60);
if (diffHours > 24) {
cancelOrder(orderId);
}
这段代码在UTC时间凌晨2:30看起来完全正常。但如果后端服务器在美国(EST/UTC-5),而用户在日本(JST/UTC+9),你试试看?
真正的问题在于:
- 夏令时切换时,一小时可能不存在或重复出现
- 不同地区的新年定义不同(不是所有地方都用1月1日)
- 跨时区的日期边界判断(「今天」指的是哪个时区的今天?)
我曾经遇到过一个问题:用户的会员到期时间是「北京时间到期日23:59」,但我们的服务器在UTC时间计算。结果每年都有几天,用户反映「我明明没到期,为什么不能用了」。
血的教训:
// 存储:存储UTC时间戳 + 时区标识
{
expireTime: 1710054399, // Unix timestamp
timezone: "Asia/Shanghai" // IANA时区名,不是"+8"!
}
// 业务规则:用IANA时区数据库(如 moment-timezone / date-fns-tz)
const expireDate = zonedTimeToUtc(
addDays(startOfDay(expireTime, 'Asia/Shanghai'), 1),
'Asia/Shanghai'
).subtract(1, 'second');
记住,永远不要用 Offset(如+8)存储时区,必须用IANA时区名(如Asia/Shanghai)。因为+8可能是北京,可能是香港,可能是菲律宾——夏令时处理完全不同。
三、Unicode归一化:两个一样的字符串就是不相等
用户反馈:「我明明注册过,为什么提示我用户名已存在?」
你去数据库查,发现确实没有这个用户名。但登录就是失败,百思不得解。
问题出在Unicode归一化。看这个例子:
const s1 = "café"; // é = e + 重音符组合
const s2 = "cafeé"; // é = 预组合形式
console.log(s1 === s2); // false!!!
console.log(s1.normalize() === s2.normalize()); // true
这两个字符串在屏幕上看起来完全一样,但底层字节完全不同。一个是单个字符é(U+00E9),另一个是e+组合重音符(U+0065 U+0301)。
用户注册时用了一种写法,登录时用了另一种写法。你存的username和输入的username在字节层面根本不一样,但肉眼看不出来。
解决方案:
// 用户名标准化
function normalizeUsername(str) {
return str
.normalize('NFKC') // Unicode规范化形式C(兼容组合)
.toLowerCase()
.trim();
}
// 注册时
user.username = normalizeUsername(input);
// 登录时
const hashed = normalizeUsername(input);
// 再和数据库里的hash比较
这个问题在涉及国际化(i18n)时特别容易中招。中文虽然基本不受影响,但你要是做全球化产品,迟早会遇到。
四、竞态条件:多线程下的幽灵bug
单线程环境你就安全了?想多了。
看这个经典的秒杀超卖问题:
// 伪代码,假设库存=1
async function grabDiscount(orderId) {
const order = await Order.find(orderId);
if (order.stock > 0) { // T1时刻检查:有库存
// 突然...另一个请求也进来了,也判断有库存
await doSomething(); // T2时刻:业务处理
order.stock -= 1; // T3时刻:扣减库存
await order.save();
return true;
}
return false;
}
两个请求同时进来,都判断库存>0,都去扣减,结果库存变成-1。这就是经典的check-then-act竞态。
解决方案:
// 方案1:数据库层面加锁
async function grabDiscount(orderId) {
const result = await Order.decrement('stock', {
where: { id: orderId, stock: { [Op.gt]: 0 } }
});
return result[1] > 0; //affected rows
}
// 方案2:分布式锁
const lock = await redis.lock('order:' + orderId, 3000);
try {
// 业务逻辑
} finally {
await lock.release();
}
// 方案3:乐观锁(版本号)
async function grabDiscount(orderId) {
const order = await Order.find(orderId);
const updated = await Order.update(
{ stock: order.stock - 1, version: order.version + 1 },
{ where: { id: orderId, version: order.version } }
);
if (updated === 0) throw new Error('并发冲突,请重试');
}
你以为只有秒杀场景才有?库存查询、余额扣减、验证码校验、点赞数++——只要涉及「读取-判断-写入」三步,都可能有竞态。区别只是概率大小。
五、结语:代码是写给机器的,更是写给下一个人的
上面这些坑,每一个都有人踩过,每一个都造成过真实的损失。有的丢钱,有的丢用户,有的丢工作。
但最可怕的不是bug本身,而是你对代码的盲目自信。当你觉得「这段代码这么简单,不可能有问题」的时候,bug往往就来了。
所以:
- 涉及金额?用整数运算,加测试用例专门测边界值
- 涉及时间?存时间戳+时区名,用成熟的时区库
- 涉及字符串比较?先normalize再比较
- 涉及并发?先想清楚有没有竞态,再动手写
代码审查能发现逻辑错误,发现不了这些「看起来对、跑起来错」的幽灵。唯一的办法是知道它们的存在,并且在写代码的时候就刻意避开。
下次review代码的时候,如果有人问你「这段代码有什么问题?」——别急着说「没问题」。先想想今天聊的这些。
祝大家的代码都能安稳上线,不被凌晨三点的报警电话叫醒。