深夜代码系列 · 第6期
关注我,和小豆一起在掘金刷小说
🔥 开篇引爆
周四深夜,办公室里的中央空调早就停了,只剩下几台显示器发出的微弱嗡鸣声,混杂着硬盘读写时偶尔传来的咔哒声。窗外下着雨,雨滴砸在空调外机上发出闷闷的啪嗒声,像是有人在敲打着铁皮,催促着这栋楼里最后几个不肯离开的人。
空气里飘着速溶咖啡的苦味,还有从楼下便利店带上来的关东煮汤汁蒸发后留下的味道。我的手掌贴在键盘上,能感觉到键帽因为长时间敲击而变得温热,指尖有些发麻。
"豆子......"
小汐的声音从我左后方传来,轻得像是怕惊扰了这深夜的寂静。我回过头,她正侧坐在椅子上,一只手撑着下巴,另一只手无力地搭在鼠标上。办公室昏黄的壁灯光从侧面打过来,在她脸颊上留下一道浅浅的阴影,让她看起来比平时更疲惫。
她的头发松松地扎着,有几缕已经从发圈里滑了出来,垂在耳边。她眨了眨眼,睫毛在灯光下投下细小的影子。
"你能过来看看吗?"她的声音里带着一丝犹豫,像是不确定该不该在这个点打扰我,"我这个时间轴组件,排序怎么都不对。"
我滑着椅子过去,心里隐隐有种不好的预感------每次她用这种语气叫我,通常意味着问题不简单。
她把屏幕转向我,指着那个密密麻麻的操作记录列表:"你看,这几条明明是我连续点的,时间应该是有先后顺序的。但它们在列表里的位置完全乱了,有时候后点的反而排在前面。"
我凑近看了看,确实,那几条用户操作记录的时间戳看起来都一样,全是整秒。
"你传给我的时间戳是 10 位的,"她轻叹了口气,用鼠标在控制台里划了一下,"但我前端用的是 13 位毫秒时间戳。Date.now() 返回的就是 13 位。你给我 10 位,我只能自己乘以 1000 补成 13 位,但这样毫秒位永远是 000,同一秒内的操作根本排不了序。"
🎯 场景还原
我打开后端的返回数据,心里已经猜到问题出在哪了:
json
{
"id": 1024,
"user_id": 88,
"action": "click_button",
"timestamp": 1704723600 // 10 位秒级时间戳
}
"这个......"我有些尴尬地挠了挠头,"我后端存的是 MySQL 的 TIMESTAMP 类型,它只能精确到秒。所以返回的时间戳就是 10 位的。"
小汐抬起头看着我,眼神里写满了不解:"豆子,现在是 2025 年了,前端早就全面用 13 位毫秒时间戳了。你看这个......"
她打开她的代码,指给我看:
javascript
// 前端创建时间戳的标准做法
const now = Date.now();
console.log(now); // 输出:1704723600123(13 位)
// 但后端返回的是这个
const serverTime = 1704723600; // 10 位
const timestamp = serverTime * 1000; // 只能硬补成 1704723600000
// 问题:同一秒内的所有操作,毫秒位都是 000,无法区分先后
"我做的是用户行为分析,"她的声音很轻,但我能听出那种无奈,"产品要求能精确回放用户在页面上的每一个操作。如果同一秒内用户点了三个按钮、输入了两段文字,我需要知道具体的顺序。但你的精度只到秒,这些细节全丢了。"
我看着数据库表结构,这是项目启动时我"顺手"建的:
sql
CREATE TABLE `user_actions` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`action` varchar(100) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
"我当时选 TIMESTAMP 是因为它只占 4 个字节,"我试图解释,但声音越来越没底气,"而且它会自动处理时区......"
小汐摇了摇头,她的手指在桌面上轻轻敲了两下:"豆子,你知道 TIMESTAMP 最多能存到哪一年吗?"
我愣了一下。
"2038 年。"她看着我的眼睛说,"如果这个系统活过 2038 年,你这个字段就会炸。"
那一刻,我突然意识到自己当初的"顺手"决定,可能埋下了一个定时炸弹。
🧠 思路分析
"还在用 TIMESTAMP 存时间?"
一个平静的声音从茶水间方向传来。是阿辰。
他端着保温杯走过来,身上还披着那件常年不离身的灰色冲锋衣。眼镜在灯光下反着光,让人看不清他的眼神,但从他嘴角那一丝若有若无的笑意,我知道他肯定又要讲大道理了。
"辰哥,你还没走?"小汐有些惊讶。
"刚修完一个时区 Bug,"阿辰在我们旁边坐下,拧开保温杯喝了一口,"刚好听到你们在讨论时间戳的问题。豆子,你现在用的这个 TIMESTAMP,已经不适合现代应用了。"
他转身走到白板前,拿起记号笔画了两个图:
"你把 TIMESTAMP 想象成超市里的鲜牛奶,"他指着第一个图说,"保质期短(只到 2038 年),而且对环境很敏感------温度变了它就变质了。你的服务器从北京迁到伦敦,它存的时间就会跟着变,因为它会自动根据服务器时区转换。"
小汐听得很认真,微微点了点头。
"而 DATETIME,尤其是 DATETIME(3),"阿辰画了第二个图,"就像真空包装的食品 。保质期长(能存到 9999 年),不受环境影响。不管你把它放在哪个时区的服务器上,它存的值都不会变。而且这个 (3) 很关键------它表示小数点后保留 3 位,也就是毫秒。"
"毫秒?"小汐的眼睛亮了一下。
"对,"阿辰在白板另一侧快速写下了一个对比表:
| 对比维度 | TIMESTAMP | DATETIME(3) |
|---|---|---|
| 存储空间 | 4 字节 | 6 字节 |
| 时间范围 | 1970~2038 | 1000~9999 |
| 精度 | 秒(10位时间戳) | 毫秒(13位时间戳) |
| 时区行为 | 自动转换(易出Bug) | 不处理(手动管理UTC) |
| 前端兼容 | ❌ 需要乘以1000补零 | ✅ 原生匹配13位 |
"你看最后一行,"阿辰指着表格,"现代前端用的都是 13 位毫秒时间戳,这是 Date.now() 的标准。而 DATETIME(3) 的精度正好是毫秒,和前端完美匹配。"
他停顿了一下,看着我:"而且,DATETIME(3) 只比 TIMESTAMP 多占 2 个字节。2025 年了,谁还在乎那 2 个字节?"
"更重要的是,"阿辰继续说,"如果用 DATETIME(3) + UTC 的方案,前后端就完全打通了。前端传 13 位时间戳过来,你转成 DATETIME(3) 存进数据库。读出来时,再转回 13 位返回给前端。精度一点不丢。"
💻 代码实战
既然方案明确了,那就动手。我打开终端,准备对这张表做个"手术"。
1. 数据库表结构升级
sql
-- 第一步:从 TIMESTAMP 升级为 DATETIME(3)
-- (3) 表示保留 3 位小数,即毫秒精度
ALTER TABLE `user_actions`
MODIFY COLUMN `created_at` DATETIME(3) NOT NULL DEFAULT '1970-01-01 00:00:00.000';
-- 如果你还有 updated_at 字段,也一起改了
-- ALTER TABLE `user_actions`
-- MODIFY COLUMN `updated_at` DATETIME(3) NOT NULL;
"看这里,"我指着 SQL 对小汐说,"这个 DATETIME(3) 中的 (3) 就是关键。以前我们写 DATETIME 都不加这个,默认精度是 0,只能到秒。加了 (3) 之后,就能存到毫秒了,比如 2025-01-09 18:00:00.123。"
为什么是 3 位小数?
DATETIME(0):秒,对应 10 位时间戳DATETIME(3):毫秒,对应 13 位时间戳(前端标准)DATETIME(6):微秒,对应 16 位时间戳(高频交易场景)
2. 前后端数据流转方案
我在白板上画了个完整的数据流:
{action: "click", ts: 1704723600123} Note right of F: Date.now() 生成
13 位毫秒时间戳 B->>B: 转换函数
1704723600123 → "2025-01-09 01:00:00.123" B->>D: INSERT INTO user_actions
(created_at) VALUES ('2025-01-09 01:00:00.123') D-->>B: 写入成功 F->>B: GET /api/actions B->>D: SELECT created_at FROM user_actions D-->>B: "2025-01-09 01:00:00.123" B->>B: 转换函数
"2025-01-09 01:00:00.123" → 1704723600123 B-->>F: {ts: 1704723600123, ...} Note left of F: 前端拿到 13 位时间戳
直接使用,精度完整
3. 后端转换函数实现
核心是两个工具函数:13 位时间戳 ↔ DATETIME(3) 的双向转换。
javascript
const dayjs = require('dayjs');
const utc = require('dayjs/plugin/utc');
dayjs.extend(utc);
// =========== 转换工具函数 ===========
/**
* 前端 13 位毫秒时间戳 → 数据库 DATETIME(3) 格式
* @param {number} timestamp - 13 位毫秒时间戳,如 1704723600123
* @returns {string} - 如 "2025-01-09 01:00:00.123"
*/
function timestampToDb(timestamp) {
// 关键:使用 utc() 确保转换为 UTC 时间
return dayjs.utc(timestamp).format('YYYY-MM-DD HH:mm:ss.SSS');
}
/**
* 数据库 DATETIME(3) 格式 → 前端 13 位毫秒时间戳
* @param {string} datetime - 如 "2025-01-09 01:00:00.123"
* @returns {number} - 13 位毫秒时间戳
*/
function dbToTimestamp(datetime) {
// valueOf() 返回 13 位毫秒时间戳
return dayjs.utc(datetime).valueOf();
}
// =========== API 接口实现 ===========
// 记录用户操作
app.post('/api/action', async (req, res) => {
const { user_id, action, ts } = req.body;
// 前端传来 13 位时间戳,转为数据库格式
const createdAt = timestampToDb(ts);
console.log(`前端传入: ${ts} (13位)`);
console.log(`存入数据库: ${createdAt}`);
await db.query(
'INSERT INTO user_actions (user_id, action, created_at) VALUES (?, ?, ?)',
[user_id, action, createdAt]
);
res.json({ success: true });
});
// 获取用户操作列表
app.get('/api/actions', async (req, res) => {
const { user_id } = req.query;
const [rows] = await db.query(
'SELECT id, action, created_at FROM user_actions WHERE user_id = ? ORDER BY created_at DESC',
[user_id]
);
// 将数据库的 DATETIME(3) 转为前端需要的 13 位时间戳
const actions = rows.map(row => ({
id: row.id,
action: row.action,
ts: dbToTimestamp(row.created_at) // 转为 13 位时间戳
}));
res.json(actions);
});
"看,"我指着代码说,"你前端传过来 1704723600123,我用 timestampToDb 转成 '2025-01-09 01:00:00.123' 存进数据库。读出来时,用 dbToTimestamp 转回 1704723600123 返回给你。毫秒位的 123 完整保留下来了。"
4. 前端代码调整
小汐也快速改了她的代码:
javascript
// 记录用户操作时,直接用 Date.now()
async function logAction(action) {
await fetch('/api/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: getCurrentUserId(),
action: action,
ts: Date.now() // 13 位毫秒时间戳
})
});
}
// 获取操作列表并渲染
async function renderActionTimeline() {
const res = await fetch(`/api/actions?user_id=${getCurrentUserId()}`);
const actions = await res.json();
// 后端返回的 ts 就是 13 位时间戳,直接排序
actions.sort((a, b) => b.ts - a.ts);
// 渲染时显示毫秒
actions.forEach(item => {
const date = new Date(item.ts);
const timeStr = date.toLocaleString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3 // 显示毫秒
});
renderTimelineItem(item.action, timeStr);
});
}
📊 效果验证
代码改完,我重启了服务。小汐迫不及待地刷新了页面。
"我来测试一下,"她说着,手指在按钮上快速点了五次,动作连贯得像是在弹钢琴,"这五次应该都在同一秒内。"
几秒后,她的时间轴组件里出现了五条记录:
makefile
01:23:45.891 - 点击按钮
01:23:45.756 - 点击按钮
01:23:45.623 - 点击按钮
01:23:45.489 - 点击按钮
01:23:45.234 - 点击按钮
小汐的脸上终于露出了笑容,那种释然的笑,像是终于放下了压在心里的一块石头:"你看!毫秒位都不一样,排序完全准确了!"
她快速滚动列表,那些密密麻麻的操作记录按照时间顺序整整齐齐地排列着,不会再像之前那样在同一秒内乱序。
"而且你看控制台,"她打开开发者工具,"你返回给我的直接就是 13 位时间戳:1704723685891、1704723685756......我前端不用做任何额外处理,直接用 new Date(ts) 就能转成本地时间显示。"
阿辰在旁边喝了口茶,淡淡地说:"这才是前后端该有的协作方式。"
💡 经验总结
这次深夜重构,让我彻底理解了 MySQL 8 时代的时间存储最佳实践。
核心要点
-
MySQL 8 首选
DATETIME(3),不要再用TIMESTAMPDATETIME(3)的(3)代表毫秒精度(小数点后 3 位)- 存储空间:6 字节(比
TIMESTAMP的 4 字节只多 2 字节) - 时间范围:1000年 ~ 9999年(而
TIMESTAMP只到 2038年)
-
DATETIME(3)天然匹配前端的 13 位时间戳- JavaScript 的
Date.now()返回 13 位毫秒时间戳(标准) DATETIME(3)可以完整保存毫秒,精度完全对应
- JavaScript 的
-
统一用 UTC,时区问题交给应用层
- 数据库只存 UTC 时间,不要让 MySQL 去处理时区
- 后端与数据库交互时用 UTC
- 前端根据用户本地时区显示(浏览器自动处理)
-
TIMESTAMP 的三大问题
- 2038年危机 :最大只能存到
2038-01-19 03:14:07 - 精度不足:只能到秒,无法满足现代应用的毫秒级需求
- 时区依赖 :受服务器
time_zone配置影响,迁移易出Bug
- 2038年危机 :最大只能存到
常见坑点
-
❌ 坑1:前端 13 位时间戳直接存入 TIMESTAMP
- 问题:
TIMESTAMP会自动截断,毫秒丢失 - 正确做法:用
DATETIME(3)存储
- 问题:
-
❌ 坑2:ORM 驱动的时区配置不正确
- 问题:Sequelize、TypeORM 等会根据本地时区自动转换
- 正确做法:连接配置中强制
timezone: '+00:00'
javascript// Sequelize 示例 const sequelize = new Sequelize({ dialect: 'mysql', timezone: '+00:00' // 关键!强制使用 UTC }); -
❌ 坑3:老表直接 ALTER 不备份
- 问题:
TIMESTAMP转DATETIME可能涉及时区隐式转换 - 正确做法:先在测试环境验证,生产环境操作前必须备份
- 问题:
-
❌ 坑4:后端返回时不转换为 13 位时间戳
- 问题:返回字符串
"2025-01-09 01:00:00.123",前端还要手动处理 - 正确做法:统一返回 13 位数字时间戳
- 问题:返回字符串
技术拓展
- 如果需要更高精度 (如日志系统、高频交易),可以用
DATETIME(6)存储微秒 - 如果追求极致性能 ,可以直接用
BIGINT存储 13 位时间戳数字(但牺牲了可读性) - 关于时区处理的更多细节,可以研究
CONVERT_TZ()函数
🌙 温馨收尾
小汐关上电脑,站起来伸了个懒腰。她走过我身边时,轻轻拍了拍我的肩膀:"谢了。"
阿辰也收拾好东西,路过时只说了句:"早点休息。"
办公室里又只剩下我一个人了。窗外的雨还在下,但声音变小了很多。我看了看电脑右下角的时间:01:18:47.325。
显示器的光映在键盘上,那些被敲了无数次的按键在光影下显得有些斑驳。我想起三个月前建这张表的时候,也是深夜,也是下雨。那时候我随手选了 TIMESTAMP,觉得够用了。
现在想想,有些"够用"的选择,只是因为问题还没暴露而已。
这里是《深夜代码》,我们下期见。