她问我:数据库还在存 Timestamp?我说:大人,时代变了

深夜代码系列 · 第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. 前后端数据流转方案

我在白板上画了个完整的数据流:

sequenceDiagram participant F as 前端(小汐) participant B as 后端(我) participant D as MySQL Note over F,D: 核心原则:13 位时间戳 + UTC F->>B: POST /api/action
{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 位时间戳:17047236858911704723685756......我前端不用做任何额外处理,直接用 new Date(ts) 就能转成本地时间显示。"

阿辰在旁边喝了口茶,淡淡地说:"这才是前后端该有的协作方式。"

💡 经验总结

这次深夜重构,让我彻底理解了 MySQL 8 时代的时间存储最佳实践。

核心要点

  1. MySQL 8 首选 DATETIME(3),不要再用 TIMESTAMP

    • DATETIME(3)(3) 代表毫秒精度(小数点后 3 位)
    • 存储空间:6 字节(比 TIMESTAMP 的 4 字节只多 2 字节)
    • 时间范围:1000年 ~ 9999年(而 TIMESTAMP 只到 2038年)
  2. DATETIME(3) 天然匹配前端的 13 位时间戳

    • JavaScript 的 Date.now() 返回 13 位毫秒时间戳(标准)
    • DATETIME(3) 可以完整保存毫秒,精度完全对应
  3. 统一用 UTC,时区问题交给应用层

    • 数据库只存 UTC 时间,不要让 MySQL 去处理时区
    • 后端与数据库交互时用 UTC
    • 前端根据用户本地时区显示(浏览器自动处理)
  4. TIMESTAMP 的三大问题

    • 2038年危机 :最大只能存到 2038-01-19 03:14:07
    • 精度不足:只能到秒,无法满足现代应用的毫秒级需求
    • 时区依赖 :受服务器 time_zone 配置影响,迁移易出Bug

常见坑点

  • 坑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 不备份

    • 问题:TIMESTAMPDATETIME 可能涉及时区隐式转换
    • 正确做法:先在测试环境验证,生产环境操作前必须备份
  • 坑4:后端返回时不转换为 13 位时间戳

    • 问题:返回字符串 "2025-01-09 01:00:00.123",前端还要手动处理
    • 正确做法:统一返回 13 位数字时间戳

技术拓展

  • 如果需要更高精度 (如日志系统、高频交易),可以用 DATETIME(6) 存储微秒
  • 如果追求极致性能 ,可以直接用 BIGINT 存储 13 位时间戳数字(但牺牲了可读性)
  • 关于时区处理的更多细节,可以研究 CONVERT_TZ() 函数

🌙 温馨收尾

小汐关上电脑,站起来伸了个懒腰。她走过我身边时,轻轻拍了拍我的肩膀:"谢了。"

阿辰也收拾好东西,路过时只说了句:"早点休息。"

办公室里又只剩下我一个人了。窗外的雨还在下,但声音变小了很多。我看了看电脑右下角的时间:01:18:47.325。

显示器的光映在键盘上,那些被敲了无数次的按键在光影下显得有些斑驳。我想起三个月前建这张表的时候,也是深夜,也是下雨。那时候我随手选了 TIMESTAMP,觉得够用了。

现在想想,有些"够用"的选择,只是因为问题还没暴露而已。


这里是《深夜代码》,我们下期见。

相关推荐
西柚补习生3 小时前
通用 PWM 原理基础教学
数据库·mongodb
Mr.Entropy3 小时前
JdbcTemplate 性能好,但 Hibernate 生产力高。 如何选择?
java·后端·hibernate
小张程序人生3 小时前
ShardingJDBC读写分离详解与实战
数据库
木风小助理3 小时前
三大删除命令:MySQL 核心用法解析
数据库·oracle
tc&3 小时前
redis_cmd 内置防注入功能的原理与验证
数据库·redis·bootstrap
麦聪聊数据3 小时前
MySQL 性能调优:从EXPLAIN到JSON索引优化
数据库·sql·mysql·安全·json
YDS8293 小时前
SpringCloud —— MQ的可靠性保障和延迟消息
后端·spring·spring cloud·rabbitmq
Facechat4 小时前
视频混剪-时间轴设计
java·数据库·缓存
lalala_lulu4 小时前
MySQL中InnoDB支持的四种事务隔离级别名称,以及逐级之间的区别?(超详细版)
数据库·mysql
无限大64 小时前
为什么"区块链"不只是比特币?——从加密货币到分布式应用
后端