做后端开发这几年,我见过太多因为时间戳换算翻车的现场。日志里莫名其妙多出来的 8 小时、两个服务之间因为差 13 位数字导致验签失败、前端传过来一个2023-09-18 12:00:00,后端解析完时间又对不上……这些问题的根源,大多不在“哪个 API 用错了”,而是对 C# 里时间与时间戳互相转化的底层约定没吃透。这篇文章我把 C# 中 DateTime/DateTimeOffset 和 Unix 时间戳互相转化的完整套路拆开讲一遍,覆盖秒级、毫秒级、微秒级精度,把时区造成的 8 小时问题从原理到修复讲透,再聊聊格式化字符串解析时那些一眼看上去没问题、一跑起来就报错的小陷阱。不管你是刚转 .NET 的初学者,还是写过很多年 C# 的老手,只要你还得跟时间打交道,这篇就值得从头看完。
1. 时间戳的本质与C#里最容易混淆的两个时间容器
1.1 时间戳的“原点”到底是什么
Unix 时间戳的定义很简单:从协调世界时 1970 年 1 月 1 日 0 点 0 分 0 秒(UTC + 0)开始,到某个时刻之间经过的秒数。注意关键词是“UTC”,它和时间没有半毛钱关系,它描述的是一个绝对时刻。你在北京、在纽约、在伦敦,同一个时刻拿到的 Unix 时间戳是完全相同的数字。
C# 里定义了现成的原点:
DateTime.UnixEpoch // 等价于 new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)很多新手的第一个坑就在这里:自己手写new DateTime(1970, 1, 1)没有指定DateTimeKind.Utc,默认是Unspecified。这样减出来的差值,在后续叠加时区逻辑时极容易整出错位。
1.2 DateTime 的 Kind 是“幺蛾子”源头
C# 里的DateTime本身并不自带时区信息,它只有一个Kind属性,取值是Unspecified、Utc、Local三者之一。关键问题是:Kind 只是元数据,不影响 Ticks 的数值本身,但在做相减、格式化、反序列化时,很多底层行为会根据 Kind 做出不同处理。
举个例子,你能清楚说出DateTime.Now减去DateTime.UnixEpoch到底得到的是不是正确秒数吗?光看代码根本看不出来。这就是它危险的地方。
所以我个人的强烈建议是:业务代码里统一使用DateTimeOffset来承载时间值。它除了包含 Ticks,还记录了 Offset(相对 UTC 的偏移量),自描述性比 DateTime 强得多。而且从 .NET Framework 4.6 开始,系统就内置了时间戳转换方法,不需要自己写减法,容错率高一大截。
2. 秒级、毫秒级、微秒级互转:三种精度的写法与适用场景
2.1 秒级时间戳(10位):基础写法与 2038 边界检查
秒级时间戳是现在绝大多数 API 接口的默认选择,长度是 10 位数字。推荐写法直接用内置方法:
// 当前 UTC 时间 -> 秒级时间戳 long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // 秒级时间戳 -> UTC DateTime DateTime utcTime = DateTimeOffset.FromUnixTimeSeconds(timestamp).UtcDateTime; // 秒级时间戳 -> 指定时区的 DateTimeOffset DateTimeOffset dto = DateTimeOffset.FromUnixTimeSeconds(timestamp) .ToOffset(TimeSpan.FromHours(8));如果你还在维护老项目,用的是 .NET Framework 4.5 及以下版本,只能手写转换:
long timestamp = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;这里有一个很好的习惯:返回类型一定要用 long,别用 int。32 位 int 在 2038 年就会溢出,虽然听起来还早,但很多锁定期限很长的金融系统、嵌入式设备用的还是这种老写法,到时候不是改一行代码的问题,是整个通信协议都要换。
2.2 毫秒级时间戳(13位):前端与后端互通的标配
如果你对接过 JavaScript 或者 Java 后端,一定会看到 13 位毫秒级时间戳。Java 的System.currentTimeMillis()、JavaScript 的Date.now()返回的都是毫秒数。
C# 同样有内置方法,不需要自己乘 1000:
long millis = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); DateTimeOffset dto = DateTimeOffset.FromUnixTimeMilliseconds(millis); DateTime utcTime = dto.UtcDateTime;我遇到过不少同事不清楚这两个 API 存在,手写(long)(DateTime.UtcNow - UnixEpoch).TotalMilliseconds,然后因为TotalMilliseconds是 double,四舍五入或者精度丢失导致整毫秒数差 1,验签死活过不了。用内置方法省心得多。
2.3 微秒与纳秒级时间戳:高精度场景的手工课
微秒级(16位左右)和纳秒级(19位左右)时间戳没有现成 API,必须自己换算。核心是利用 .NET 的一个基础数据单位:Ticks。1 Tick = 100 纳秒 = 0.1 微秒。
// 当前 UTC 时间 DateTime nowUtc = DateTime.UtcNow; // 时间差(以 100ns Tick 为单位) long ticksSinceEpoch = (nowUtc.ToUniversalTime() - DateTime.UnixEpoch).Ticks; // 微秒级时间戳:每 10 个 Tick 为 1 微秒 long micros = ticksSinceEpoch / 10; // 纳秒级时间戳:每个 Tick 是 100 纳秒 long nanos = ticksSinceEpoch * 100;反推回来也很直接:
// 微秒 -> DateTime DateTime dt = DateTime.UnixEpoch.AddTicks(micros * 10);不过这里有个残酷的真相:Windows 上DateTime.UtcNow的实际分辨率并不高,通常只能到毫秒级甚至 15 毫秒左右。你想用普通系统时钟拿微秒级精度,基本是白费力气。真正要做高精度计时,请走Stopwatch.GetTimestamp(),或者 .NET 8 之后的TimeProvider,再配合换算系数归一化。
我把三种精度整理成一张表,方便你对照用:
| 精度 | 位数 | 常用场景 | C# 推荐写法 |
|---|---|---|---|
| 秒 | 10 | 通用 API、日志时间戳 | ToUnixTimeSeconds() |
| 毫秒 | 13 | 前端 JS、Java 系统毫秒 | ToUnixTimeMilliseconds() |
| 微秒 | 16 | 性能分析、高精度日志 | (DateTime.UtcNow - UnixEpoch).Ticks / 10 |
| 纳秒 | 19 | 基准测试、高精度计时 | 建议用 Stopwatch,而非 DateTime |
2.4 为什么推荐直接使用 DateTimeOffset 完备转换
很多老博客喜欢展示“DateTime 减去 UnixEpoch 再转 long”的写法,这在特定环境下能跑通,但隐患一堆。比如你使用DateTime.Now减,得到的时间戳天然带上了本地时区的偏移;又比如你使用DateTime.SpecifyKind去强转,代码可读性和维护性都很差。
DateTimeOffset的好处在于:它把“绝对时间点”和“本地时刻的偏移量”绑定在一起,无论是转时间戳还是再转回本地时间,语义都非常清晰,不需要猜。往后对接国际业务、多区域部署,不用返工。
3. 时区是万恶之源:完整复盘差 8 小时从产生到消失
3.1 时间戳本身没有时区,但 C# 的 DateTime 有
先明确一件事:时间戳数字是全球同步的,你在东八区拿到的时间戳,在零时区服务器上拿到的时间戳,是同一个数字。会出问题的是“时间戳 -> DateTime”或“DateTime -> 时间戳”这两个方向和时区的交错。
大多数差 8 小时 bug,本质都是把“本地时间当成 UTC 时间”或者反过来“把 UTC 时间当成本地时间”,然后硬做加减,结果就是 3600 秒的倍数错位。
3.2 差 8 小时 bug 的最小复现代码
我见过最典型的错误代码长这样:
long badTimestamp = (long)(DateTime.Now - new DateTime(1970, 1, 1)).TotalSeconds;单独看好像没什么问题。但注意:DateTime.Now返回的是本地时间,假定你的服务器在东八区,此刻本地时间是 12:00,而 UTC 时间其实是 04:00。用本地时间减 1970-01-01 00:00:00,得到的秒数就比正确时间戳多了整整 8 个小时。这个错误时间戳发到对端,对方一解析成 UTC 时间,就会看到 20:00 而不是 12:00。
修复方案很简单:
long okTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();或者坚持用 DateTime 的话:
long okTimestamp = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;3.3 从“代码里补 8 小时”到“彻底不用补”
很多人图省事,发现问题后直接在代码里写:
timestamp += 8 * 3600;或者:
dateTime = dateTime.AddHours(8);这种打补丁的方式,是最容易埋雷的。它只在你所处的时区恰好是东八区的环境下成立,一旦服务器迁移到别的时区,或者同一套代码被部署到不同地区的多个节点,立刻就会出问题。而且这种补丁具有传染性,后面接手的开发者看到“8 小时”往往不知道来源,又补一层,最后时间变成一团乱麻。
我见过一个系统,同一个时间字段被连续打了三次“+8”补丁,最后日志里的时间戳完全对不上,排查花了一整天。正确思路应该是:
- 所有服务之间、服务与数据库之间、接口与接口之间,一律使用 UTC 时间或时间戳;
- 只在最外层展示层(比如前端渲染、报表导出)才按用户的时区做本地化;
- 任何代码里出现“固定小时数调整”,都视为坏味道,必须揪出根因。
3.4 DateTimeOffset 如何天然规避时区问题
如果使用DateTimeOffset,你拿到一个时间值时它自带了 Offset,可以清楚地告诉别人“这是 UTC+8 时间的 12:00,还是 UTC 时间的 12:00”。在存时间戳、解析前端字符串时,歧义会小很多。
举个实际例子:
// 从前端拿到 ISO 格式字符串,带 Z 后缀表示 UTC string isoString = "2023-09-18T04:00:00Z"; DateTimeOffset dto = DateTimeOffset.Parse(isoString); long timestamp = dto.ToUnixTimeSeconds(); // 无论服务器在哪个时区,结果一致如果不带Z后缀,则妥协会多一些,我放到下一节详细讲。
4. 字符串与时间戳的“相亲”:格式化解析中的歧义和兼容性陷阱
4.1 ToString 指定格式:别让默认格式出卖你
很多人打日志直接DateTime.Now.ToString(),这在做了国际化部署的场景里非常危险。不同语言的系统默认日期格式差异很大,有的会把月日顺序调换,有的会用斜杠分隔。你这边看着是“2023-09-18”,对端解析出来变成“2023-18-09”,当场报错。
所以格式化时请务必带上格式串和不变文化:
string logText = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);这样无论服务器操作系统语言是什么,输出的格式都是可预期的。
4.2 ParseExact 不惯着模糊字符串
在解析字符串时,我强烈建议用DateTimeOffset.TryParseExact代替裸的Parse。因为裸 Parse 会先用当前文化的规则猜格式,猜对了算运气,猜错了就是线上事故。
来看一个完整且安全的解析链路,把字符串转成时间戳:
string input = "2023-09-18T12:00:00"; if (DateTimeOffset.TryParseExact( input, "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTimeOffset result)) { long timestamp = result.ToUnixTimeSeconds(); // 使用 timestamp } else { // 输入不合规,记日志并返回错误 }这里有几个关键点:
yyyy-MM-dd'T'HH:mm:ss是常见的 ISO-8601 无时区格式,T用来拼接日期和时间;- 加上
AssumeUniversal表示如果字符串没带时区,就默认它是 UTC 时间; AdjustToUniversal表示解析后统一转成 UTC,避免后续误用本地时间。
4.3 字符串不带时区:AssumeUniversal 还是 AssumeLocal?
如果你们的接口历史比较老,前端传上来的字符串可能完全没有时区尾巴,比如"2023-09-18 12:00:00"。这时候后端需要有一个明确的约定文档,到底是按 UTC 解释还是按服务器本地时间解释。
我处理某跨平台项目时,遇到过对方 Java 后端按 UTC 发字符串,我们这边却按服务器本地时间解析,结果所有时间都差了 8 小时。后来统一约定:系统间传输字符串一律带Z后缀或者直接传数字时间戳,不准传裸日期时间字符串。这个约定比任何代码修复都省事。
如果实在没办法,只能在两种策略里二选一:
- 按 UTC 解释:
DateTimeStyles.AssumeUniversal,所有节点行为一致。 - 按本地解释:
DateTimeStyles.AssumeLocal,只适合单机房、单一地区且所有人都不关心跨时区的小系统。
4.4 识别“时间戳字符串”和“日期字符串”的差异
还有一小类陷阱:前端把时间戳转成字符串再传过来,长度为 10 位或 13 位数字。很多人下意识用DateTime.Parse去处理,必然失败。
正确的做法是用long.TryParse先解析数字,再判断位数,最后用FromUnixTimeSeconds或FromUnixTimeMilliseconds还原:
string tsString = "1695081600000"; if (long.TryParse(tsString, out long millis)) { DateTimeOffset time = DateTimeOffset.FromUnixTimeMilliseconds(millis); Console.WriteLine(time.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss")); }这种方式能统一处理两类输入,而且不会有文化差异问题。
5. 工程落地建议:统一 UTC、精度选型与老代码迁移
5.1 唯一的内部规范:存储用 UTC,传输用时间戳或 ISO-8601,展示用本地时间
这句话我几乎在每个项目里都会写进开发规范。具体拆开是三层:
- 数据库字段一律存 UTC 时间(
datetime类型字段就是 UTC 的值,或者直接存bigint时间戳); - 服务间传参、API 返回体一律使用 Unix 时间戳或带
Z后缀的 ISO-8601 字符串; - 前端或报表层拿到时间值后,再根据用户浏览器时区转成本地时间展示。
这三层各自独立,谁都不能越权去改时间含义。别小看这条规范,它能解决掉 80% 的时间错乱问题。
5.2 时间戳精度选型建议
业务场景不同,精度选择自然不同,我给一个通用的决策思路:
| 业务场景 | 推荐精度 | 理由 |
|---|---|---|
| 常规日志 | 秒 | 可读性好,不需要高精度,节省存储 |
| 用户操作记录 | 毫秒 | 前端展示需要到毫秒,便于追查顺序 |
| 订单/交易流水 | 毫秒 | 与 Java 等后端系统对齐,避免精度不一致 |
| 性能监控 | 微秒 | 观察短延迟时有意义 |
| 基准测试 | 纳秒 | 必须用 Stopwatch,别用 DateTime |
确定精度之后,所有系统间交互的字段都要在同一页文档里写清楚,防止又出现“一边传 10 位,一边按 13 位解析”的尴尬。
5.3 老代码迁移:从 DateTime.Now 到 DateTime.UtcNow 的平滑过渡
如果你要改造一个老项目,我建议分三步走:
- 全局搜索
DateTime.Now,把业务逻辑层、数据访问层里的替换成DateTime.UtcNow(注意:纯 UI 展示层可以保留,但要和逻辑层解耦); - 在公共工具类里封装统一的时间戳转换方法,所有代码都走这一套入口,禁止到处手写减法;
- 针对历史数据和接口,写一个灰度开关,先让新旧逻辑并存,对比一段时间的时间戳是否一致,再逐步切流量。
迁移过程中最容易漏掉的是日志系统。很多日志框架默认格式化本地时间,换成 UTC 之后,排查问题时看到的日志和之前的历史日志会差 8 小时。我的做法是:日志输出统一用带时区标识的格式,比如2023-09-18T04:00:00Z,让人一眼看出是 UTC。
最后分享一个排查小技巧:如果你在代码里看到timestamp += 8 * 3600这种补丁,千万别直接删,先确认调用方是拿它当本地时间还是 UTC 时间。我见过一个系统因为这个补丁被重复打了三次,最后变成一个匪夷所思的数字,排查花了一天。所以我的习惯是:任何跨系统的时间字段,一律在注释里标明“UTC”或“+08:00”字样。这比任何转换代码都管用。