最近在给一个基于 DeepSeek API 的智能体应用做后台数据统计,数据库用的是 PostgreSQL。需求听起来特别朴素:把"过去 7 天"换算成毫秒值,然后用它去过滤消息表里的毫秒时间戳。我本以为写个now() - interval '7 days'就完事了,结果真上手才发现,"7 天"和"时间毫秒值"这两个词单独看都不难,组合在一起却处处是细节——时区、精度、自然日与滚动窗口的差别、边界条件的取舍、索引能不能命中,甚至还有把秒当毫秒用的乌龙。
这篇文章就把这套东西完整梳理一遍。它不是 DeepSeek 部署教程,也不讲 API 怎么调,专注解决一个具体问题:在 PostgreSQL 里,如何正确、高效地拿到"7 天前那一刻"的毫秒值,以及拿它去做什么。适合正在做 DeepSeek 相关应用后端、需要处理会话统计、消息清理、时间窗口过滤的朋友参考,也适合所有在 PostgreSQL 里被时间戳折腾过的开发者。
1. 为什么 DeepSeek 开发者会盯上"7 天时间毫秒值"
1.1 这个需求从哪来:AI 应用里的时间窗口
DeepSeek 相关的应用这两年越来越多了,对话机器人、AI 搜索、Agent 工作流、推理服务代理,五花八门。这类应用的后端通常有两块核心数据:对话消息和调用日志。这两块数据天然带着时间属性,而业务上最常见的窗口就是"最近 7 天"——比如统计 7 天活跃会话、清理 7 天前的历史消息、计算 7 天内的 API 调用频率。
那为什么偏偏要跟"毫秒"较劲?原因很实在:大多数前端 SDK、消息队列、缓存系统的时间戳本身就是毫秒级的。你从请求头、日志文件或者 Webhook 回调里拿到的数据,往往直接就是类似1747612800000这样的 13 位整数。后端表里存的消息时间字段,建表的人图省事,也可能直接用bigint存了毫秒。要与这些字段对齐比较,SQL 里就必须算出一个毫秒值来,否则只能to_timestamp先转一遍,费劲又容易出错。
1.2 毫秒这个精度,到底解决什么问题
有人会说,我业务上明明只关心"天",为什么要跟毫秒较劲?实际干活的时候,毫秒至少有三层价值:
- 后端消息表里同一秒会落很多条记录,排序和去重需要毫秒甚至更高精度,不然并发写入时 ID 和时间戳无法一一对应。
- API 网关和推理服务的调用日志普遍用毫秒时间戳,很多语言里就是
time.now() * 1000这种写法,直接拿字段过滤最省事。 - 跨系统传时间时,整数毫秒比字符串时间格式更不容易产生歧义。
1747612800000在任何语言、任何时区解析结果都一样,而2025-05-19 10:00:00还要额外告诉对方这是哪个时区的时间。
正是这些理由,让"7 天时间毫秒值"成了 DeepSeek 类项目后端一个绕不开的小需求。它的核心其实就是两件事:把"过去 7 天"这个业务语义翻译成一个毫秒级整数,同时保证这个数算出来之后跟业务预期完全一致。听起来简单,但坑全藏在"一致"这两个字里。
2. 环境准备:PG 版本选择与基础时间数据类型
2.1 PostgreSQL 版本怎么选
动手写 SQL 之前,先把环境的事说清楚。最近总看到有人纠结 PostgreSQL 下载哪个版本,我的建议非常简单:
- 如果你在 Linux 服务器上,优先用系统软件源里的稳定版。Debian/Ubuntu 直接
apt install postgresql,一般已经到 15、16、17 了。不要为了追新特意从源码编译,除非你有特殊插件需求。 - 如果你在 Windows 上开发测试,官方安装包就行。安装时注意记住端口(默认 5432)和超级用户密码,装完用
pg_ctl status或服务管理器确认一下服务有没有启动。 - 生产环境追求稳定,选最新稳定版或者次新稳定版。PostgreSQL 没有严格的 LTS 概念,社区维护周期很长,选一个主流版本比追最新小版本更稳妥。
我手头项目用的是 PostgreSQL 16,下面讲的写法在 12 到 17 上我都试过,完全兼容。EXTRACT、interval、date_trunc这些函数的语义这些年一直很稳定,不用担心版本差异。
2.2 timestamp、timestamptz、epoch 三兄弟的差别
在写"毫秒值"之前,必须先把三种时间相关的东西分清楚,否则后面必翻车。这三兄弟是 PostgreSQL 时间处理里最基础的三个概念:
timestamp:不带时区的日历时间,比如2025-05-19 10:00:00。它没有"绝对时刻"的含义,同一个字符串在北京和纽约读出来的语义是不同的。timestamptz:带时区的时间戳。内部统一按 UTC 存储,展示时按你会话的时区转换。它有明确的"绝对时刻"含义。- epoch:自 1970-01-01 00:00:00 UTC 以来的秒数(小数)。乘以 1000 后就是毫秒时间戳。epoch 是与时区无关的纯数值,跨系统传值最安全。
很多人把timestamp当timestamptz用,短期看不出问题,一旦涉及跨时区的时间窗口,就会差出几个钟头。我实操时有一个硬性约定:业务表里的时间字段全部用timestamptz,需要对外传值时才转成毫秒整数。这个约定能帮你躲过一大半时间问题。
3. 三种获取 7 天时间毫秒值的核心写法
3.1 写法一:EXTRACT(EPOCH FROM ...)(最推荐)
最正统、也是我最常用的一种写法:
-- 当前时刻的毫秒值 SELECT (EXTRACT(EPOCH FROM now()) * 1000)::bigint AS now_ms; -- 7天前那一刻的毫秒值 SELECT (EXTRACT(EPOCH FROM now() - interval '7 days') * 1000)::bigint AS seven_days_ago_ms;这里面的逻辑链是:EXTRACT(EPOCH FROM now())拿到当前绝对时刻的秒数(带小数),- interval '7 days'先把时刻往前拨 7 天,再整体取 epoch,最后乘以 1000 转毫秒,::bigint去掉小数尾巴。
有人会问,为什么不能直接EXTRACT(EPOCH FROM now()) * 1000完事?因为EXTRACT返回的是numeric类型,乘 1000 后仍可能带小数。SQL 里数值类型是会发生隐式转换的,你写WHERE created_at_ms >= 1747012800000.678时,PostgreSQL 会帮你转,但你要精确比较时最好显式转成bigint,让所有参与比较的值都是同一种类型,避免隐式转换规则给你添乱。
3.2 写法二:现在秒数减去 7 天的秒数
另一种思路是纯数值运算:
SELECT (EXTRACT(EPOCH FROM now()) - 7 * 86400) * 1000 AS seven_days_ago_ms;这里先把"7 天"换成秒数:7 * 86400 = 604800 秒。然后在秒域里做减法,最后乘 1000。如果你先乘 1000 再减7 * 86400 * 1000,结果也是一样的,就是数字写起来长一点,容易手滑。
这种写法的优点是"一眼能看出 7 天等于 604800 秒等于 604800000 毫秒",适合写一次性诊断脚本;缺点是硬编码数字,别人看代码时要心算一下才知道你在干嘛。放到长期维护的项目里,可读性稍差。
3.3 写法三:date_trunc 对齐后拿整点
如果业务要求的是"7 天前的 0 点整",而不是"当前时刻往前倒推 7 天",那得用date_trunc:
-- 7天前那天的00:00:00 对应的毫秒值 SELECT (EXTRACT(EPOCH FROM date_trunc('day', now()) - interval '7 days') * 1000)::bigint AS seven_days_ago_start_ms;这个写法会把当前的时分秒全部归零,拿到 7 天前那天的00:00:00.000。典型应用场景是按自然日统计报表——从"7 天前的 0 点"开始汇总;或者做缓存过期判断,"今天没过期不算,7 天前的今天才算过期"。
3.4 三种写法怎么选
| 写法 | 语义 | 灵活度 | 适合场景 | 维护成本 |
|---|---|---|---|---|
写法一EXTRACT(EPOCH FROM now() - interval '7 days') | 当前时刻往前推 7 天 | 高,可换成任意 interval | 默认使用,绝大多数业务过滤 | 低 |
写法二EXTRACT(EPOCH FROM now()) - 604800 | 当前时刻减去 604800 秒 | 中,秒数要手动算 | 一次性诊断脚本、调试 | 中 |
写法三date_trunc('day'...) | 7 天前那天的 0 点 | 中,只适合对齐到天 | 自然日报表、日粒度统计 | 中 |
我的建议很明确:默认用写法一,语义清晰,读代码的人舒服,数据库执行计划也不会被搞乱;只有明确需要"按天对齐"时才用写法三;写法二留给自己写临时脚本就够了。
4. 毫秒值背后的计算原理与边界陷阱
4.1 毫秒值到底是怎么算出来的
先做个手动验证:7 天 = 7 × 24 × 60 × 60 = 604800 秒 = 604800000 毫秒。所以"7 天前的毫秒值"其实等价于"当前毫秒值 - 604800000"。但直接写interval '7 days'更安全,因为 PostgreSQL 处理interval时是按日历时间走的。遇到夏令时切换,硬减 604800 秒和减interval '7 days'的结果可能不一致,绝大多数业务里"7 个自然日"比"硬减 604800 秒"更符合直觉。
这里还有个容易忽略的小常识:EXTRACT(EPOCH FROM interval '7 days')返回的是 604800。但如果你拿一个混合了月份和年份单位的 interval 去转秒,结果可能不是你预想的数值。比如interval '1 month'在 PostgreSQL 里默认按 30 天算,interval '1 year'按 365 天算。这是文档里明确写的行为,很多人在时间单位换算上翻车,多半就是没注意这一点。
4.2 时区是最大的坑
时区问题是我在这个需求上踩过最深的坑,单独拿出来说。先看一个现象:
SET timezone = 'Asia/Shanghai'; SELECT now(), (EXTRACT(EPOCH FROM now()) * 1000)::bigint; SET timezone = 'UTC'; SELECT now(), (EXTRACT(EPOCH FROM now()) * 1000)::bigint;你会发现now()的展示值变了,但毫秒值没变。这是正常的,因为 epoch 锚定的是 UTC 绝对时刻,跟你会话时区无关。
真正的坑在另一处:如果你把timestamptz转成timestamp再取 epoch,结果就跟时区有关了。
-- 这会比预期少 8 小时的毫秒数(28800000) SELECT EXTRACT(EPOCH FROM now()::timestamp) * 1000;我见过很多"为什么我算出来差 8 小时"的排查帖,最后都落在这条:把timestamptz强转成timestamp后,绝对时刻被悄悄改写了。本意只是想去掉时区后缀,实际上把时间本身的语义也丢掉了。
所以动手前先把类型想清楚:时间列是timestamptz,就直接在timestamptz上算;是timestamp,就先AT TIME ZONE 'UTC'转成timestamptz再算。别偷懒用::timestamp强转。
4.3 边界问题:7 天到底是哪一刻
"过去 7 天的数据"这句话有歧义。按 7 × 24 = 168 小时倒推,得到的是滚动窗口,适合"最近 7x24 小时有活动"这类判断;按自然日对齐,得到的是"从 7 天前的 0 点到现在的报表",两种语义算出来的毫秒值可能差好几个小时。
毫秒值边界上还得注意过滤条件:
>=表示包含 7 天前那一刻;>表示不包含;- 如果业务语义是"7 天前的 00:00:00.000",用
date_trunc('day', ...)拿到的正好是整点毫秒,边界落在.000结尾。
写过滤条件我习惯用>=和<搭配,不用BETWEEN。因为BETWEEN是双侧闭区间,会把两端都包进来,统计"7 天内的数据"时容易莫名多一天。正确写法是:
WHERE created_at_ms >= :seven_days_ago_ms AND created_at_ms < :now_ms左闭右开区间是时间窗口过滤最不容易出错的姿势。
5. 实战:DeepSeek 应用里的毫秒时间窗口场景
5.1 场景一:7 天活跃会话统计
假设有一张会话表:
CREATE TABLE conversations ( id bigint PRIMARY KEY, session_id text NOT NULL, last_active_ms bigint NOT NULL, created_at timestamptz DEFAULT now() );现在要查"7 天内有活跃记录的会话",直接这样写:
SELECT session_id, count(*) FROM conversations WHERE last_active_ms >= (EXTRACT(EPOCH FROM now() - interval '7 days') * 1000)::bigint GROUP BY session_id ORDER BY count(*) DESC;这个查询在真实项目里用来判断"哪些对话 Agent 用户还在用"。给运营看活跃度,或者做"7 天未活跃用户召回"的名单,都很直观。如果你还要按小时看活跃趋势,可以把count(*)替换成date_trunc('hour', to_timestamp(last_active_ms / 1000.0))一起分组。
5.2 场景二:消息过期清理
AI 应用的消息表很容易膨胀。一条对话,思考过程、用户输入、模型输出、工具调用结果,全要落库。7 天前的消息基本没有线上价值,定时清理很有必要。最简单的写法:
DELETE FROM messages WHERE created_at_ms < (EXTRACT(EPOCH FROM now() - interval '7 days') * 1000)::bigint;但千万别直接一把梭删全表。数据量大的时候,一次删几十万行会长时间锁表,把线上的读请求全堵住。我习惯分批量删:
DELETE FROM messages WHERE id IN ( SELECT id FROM messages WHERE created_at_ms < (EXTRACT(EPOCH FROM now() - interval '7 days') * 1000)::bigint LIMIT 5000 );写成一个循环任务,每次删 5000 行,删完一批停一下再删下一批。成本低,对线上影响也小。如果你用 PostgreSQL 的定时任务,也可以配合pg_cron来做周期调用。
5.3 与 DeepSeek API 交互时的毫秒对齐
DeepSeek API 的调用日志、限流策略、请求超时判断都会落到时间上。比如做 API 调用统计,想按小时分桶看趋势:
SELECT date_trunc('hour', to_timestamp(ts_ms / 1000.0)) AS bucket, count(*) AS calls, sum(tokens_used) AS total_tokens FROM api_call_log WHERE ts_ms >= (EXTRACT(EPOCH FROM now() - interval '7 days') * 1000)::bigint GROUP BY bucket ORDER BY bucket;把毫秒值还原成时间桶,再做小时级聚合,查"7 天内的调用趋势"。观察 DeepSeek API 的配额消耗和异常波动非常好用。接监控告警也方便:如果某个小时调用数突然掉到 0,多半是 API Key 失效或网络出问题了;如果某一小时 token 消耗突然飙升,也能第一时间发现。
6. 性能与索引:毫秒查询不只是写对就行
6.1 为什么直接对毫秒字段过滤可能很慢
SQL 写对了,性能也可能翻车。最常见的问题是,有人会这么写:
-- 看起来逻辑没问题,但索引大概率用不上 SELECT * FROM messages WHERE to_timestamp(created_at_ms / 1000.0) > now() - interval '7 days';逻辑上没问题,可一旦字段被函数包裹,created_at_ms就不再是普通列参与比较,而是先经过一个函数计算出一个新值再去比较。PostgreSQL 的 B-tree 索引面对这种"列套函数"的写法,没法直接命中,结果就是全表扫描。数据量一大,这条查询能把数据库拖到报警。
6.2 正确的索引姿势与查询写法
正确的做法是保持列在比较表达式左边不变形,把所有计算都放到右边:
CREATE INDEX idx_messages_created_ms ON messages(created_at_ms); EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM messages WHERE created_at_ms >= (EXTRACT(EPOCH FROM now() - interval '7 days') * 1000)::bigint;右边即使写了一条完整 SQL,它整体上也只是个常量表达式,PostgreSQL 会在执行计划阶段先把它算成具体数值,然后用这个常量去走索引。看EXPLAIN的输出,应该是Index Scan using idx_messages_created_ms,而不是Seq Scan。这里我再多强调一句:对bigint毫秒列建索引完全可行,B-tree 索引对等值和范围查询都很友好;如果你表里同时有timestamptz列和毫秒列,两个索引都建也不冲突,各管各的查询场景。
7. 踩坑记录:我在这类需求上翻过三次车
7.1 把秒当毫秒用
第一次写统计脚本,从外部系统接过来一个时间戳,我看着就像普通时间戳,直接当成毫秒去用。结果 7 天的窗口算出来一个离谱的数字,后面全乱。后来才反应过来,那是个秒级时间戳(10 位数字),毫秒级应该是 13 位。凡是外部系统传时间戳过来,第一件事永远是确认单位:10 位是秒,13 位是毫秒,16 位是微秒。别凭感觉,直接看位数。
7.2 时区偏移 8 小时
第二次栽在 4.2 节说的那个坑上。排查时我手动算了两个值,发现差了 28800000 毫秒,也就是 8 小时,立刻就知道是时区被吞了。定位方法很简单:把now()分别用timestamptz和::timestamp各取一次 epoch,对比差值。如果不为 0,就是强转问题。
7.3 用 date_part 取毫秒,取到的根本不是毫秒值
这个坑最隐蔽。有段时间我想取当前时间的"毫秒部分",查了文档看到date_part('milliseconds', ...),名字里带"milliseconds",我以为是毫秒时间戳,结果它返回的是"秒的小数部分",范围只有 0 到 999。我需要的是 13 位的毫秒时间戳,它给我返回的是三位数的毫秒余量。后来才明白,date_part('milliseconds', ts)是取时间的小数秒部分,比如12.345里的345,而 Unix 毫秒时间戳是另一个完全不同的东西。正确做法永远是用EXTRACT(EPOCH FROM ts) * 1000。
这三条坑,前两条通过规范类型设计和位数检查就能避免,第三条则纯属 API 命名误导,写出来让大家少走弯路。
现在这套"7 天毫秒值"的需求我已经写成了肌肉记忆:时间列统一timestamptz;对外传值用EXTRACT(EPOCH FROM 时间) * 1000转bigint;过滤条件保持列不变形;边界用左闭右开区间。这套组合拳在 DeepSeek + PostgreSQL 的项目里用了很久,再没出过时间相关的幺蛾子。如果你也在做类似的后端,建议先把这几条记下来,能少走好几天的弯路。