将 SQL Server SmallDateTime 转换为 UNIX time 的时区处理
结论
UNIX time 表示确定的 UTC 时间点,不包含时区信息。转换之前,需要先确定数据库中的 2026-07-18 10:00:00 是哪个时区的本地时间。
存储方式应根据数据的含义来选择:
- 对于已经发生或已经确定的时间点,例如创建时间、付款时间,统一按 UTC 存储,由 API 转换为 UNIX time。
- 对于按某地当地时间安排的事件,例如“悉尼时间上午 10 点开会”,保存本地日期时间和时区 ID。
- 客户端时区用于显示。只有记录原本就是按该客户端的时区创建时,才能用它确定原始时间点。
不要使用 Web API 服务器的本地时区来解释数据库中的时间。即使服务器迁移到其他地区,同一个数据库值也应转换为相同的 UNIX timestamp。
问题产生的原因
SQL Server 的 smalldatetime 只保存日期和时间,不包含以下信息:
- UTC 偏移量;
- 时区 ID;
- 夏令时规则。
使用 SqlDataReader.GetDateTime() 读取这种字段时,通常会得到 DateTimeKind.Unspecified。下面的隐式转换会把该值当作运行进程所在时区的本地时间:
long unix_dt =
((DateTimeOffset)sqlDR.GetDateTime(0)).ToUnixTimeSeconds();
这样一来,同一个 2026-07-18 10:00:00 在澳大利亚服务器和美国服务器上可能会被解释为两个不同的时间点。
CURRENT_TIMEZONE()、CURRENT_TIMEZONE_ID() 和 SYSDATETIMEOFFSET() 返回的是 SQL Server 当前运行环境的时区设置,无法找回 smalldatetime 字段没有保存的时区信息。
已确定的时间点统一存储为 UTC
创建时间、更新时间、发送时间等字段表示确定的时间点,写入数据库前可统一转换为 UTC。
可以使用 datetime2 保存 UTC:
CREATE TABLE Example
(
CreatedUtc datetime2(7) NOT NULL
);
在 .NET 中写入:
DateTimeOffset now = DateTimeOffset.UtcNow;
command.Parameters.Add("@CreatedUtc", SqlDbType.DateTime2)
.Value = now.UtcDateTime;
从数据库读取后,字段值本身不带 DateTimeKind。如果已经约定该列存储 UTC,需要明确指定它的含义:
DateTime value = sqlDR.GetDateTime(0);
DateTime utc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
long unixTime = new DateTimeOffset(utc).ToUnixTimeSeconds();
DateTime.SpecifyKind() 不会改变日期和时间的数值,只会声明该值应按 UTC 解释。因此,这种写法只适用于确实存储 UTC 的列。
也可以使用 SQL Server 的 datetimeoffset:
CREATE TABLE Example
(
CreatedAt datetimeoffset(7) NOT NULL
);
读取代码如下:
DateTimeOffset value = sqlDR.GetFieldValue<DateTimeOffset>(0);
long unixTime = value.ToUnixTimeSeconds();
datetimeoffset 保存日期时间和 UTC 偏移量,因此可以确定对应的 UTC 时间点,但不会保存完整的地区时区规则。
处理现有的澳大利亚本地时间
如果现有 smalldatetime 数据已约定为悉尼、墨尔本等地区的本地时间,应使用固定的业务时区解释这些值,不能使用服务器时区。
private static readonly TimeZoneInfo BusinessTimeZone =
TimeZoneInfo.FindSystemTimeZoneById("AUS Eastern Standard Time");
public static long LocalTimeToUnixSeconds(
DateTime value,
TimeZoneInfo timeZone)
{
DateTime localTime =
DateTime.SpecifyKind(value, DateTimeKind.Unspecified);
if (timeZone.IsInvalidTime(localTime))
{
throw new ArgumentException(
"该本地时间处于夏令时跳转产生的不存在时间段。",
nameof(value));
}
if (timeZone.IsAmbiguousTime(localTime))
{
throw new ArgumentException(
"该本地时间处于夏令时回拨产生的重复时间段,需要指定采用哪个偏移量。",
nameof(value));
}
DateTime utc = TimeZoneInfo.ConvertTimeToUtc(localTime, timeZone);
return new DateTimeOffset(utc).ToUnixTimeSeconds();
}
调用方式:
DateTime storedValue = sqlDR.GetDateTime(0);
long unixTime =
LocalTimeToUnixSeconds(storedValue, BusinessTimeZone);
TimeZoneInfo.ConvertTimeToUtc() 会根据目标日期采用相应的标准时间或夏令时规则。提前检查无效时间和歧义时间,可以防止夏令时切换期间产生不易察觉的转换错误。
什么时候需要保存时区
是否需要保存时区,要看记录表示什么,与客户端当前所在的位置无关。
如果所有旧记录都属于同一个业务地区,可以只在系统配置或租户配置中保存一次时区 ID,不必在每条记录中重复保存。
如果不同记录可能属于不同地区,则应保存:
LocalDateTime: 2026-07-18 10:00:00
TimeZoneId: AUS Eastern Standard Time
数据库结构可以是:
CREATE TABLE Appointment
(
LocalStart datetime2(0) NOT NULL,
TimeZoneId nvarchar(100) NOT NULL
);
如果记录属于某个客户,并且客户的业务时区固定,可以在客户表中保存 TimeZoneId,再通过外键关系获取。如果客户以后可能修改时区,而历史记录仍要保留原来的含义,则需要把时区写入记录本身,或者保存指向不可变历史配置的引用。
只保存 UTC+10:00 这样的偏移量通常无法满足需求。偏移量不包含夏令时规则,也无法区分偏移量相同的不同地区。未来预约或重复计划需要保存地区时区 ID。
Windows 常用 "AUS Eastern Standard Time" 这类时区 ID,Linux 环境可能使用 "Australia/Sydney" 这样的 IANA ID。部署到不同操作系统之前,需要确认运行环境是否支持所选 ID,必要时建立明确的映射。
客户端时区只负责显示
API 返回 UNIX time 后,各客户端收到的整数应完全相同。客户端可以按自己的时区显示:
同一个 UNIX timestamp
→ 悉尼本地时间
→ 美国山区时间
→ 用户选择的其他时区
客户端所在位置只影响显示结果,不会改变这个时间点。如果业务要求始终显示事件发生地的时间,API 可以同时返回事件的 TimeZoneId。
迁移现有数据时要先确认约定
smalldatetime 数据本身无法说明原始时区。迁移前需要根据业务规则确认:
- 所有旧值是否都采用澳大利亚东部时间;
- 数据中是否包含其他地区的时间;
- 夏令时回拨产生重复时间时,应采用哪个偏移量;
- 哪些字段表示确定的时间点,哪些字段表示本地计划时间。
确认这些约定后,可以把含义明确的历史时间批量转换为 UTC。对于无法确定来源时区的数据,不能仅凭服务器时区可靠地还原。
smalldatetime 的精度只有分钟。如果业务需要记录秒或更高精度,应迁移到 datetime2 或 datetimeoffset。
备注:内容仅供参考。
