Lua 时间日期处理与时区

系统梳理 Lua 的时间日期处理:os.time、os.date、os.clock 的语义与常见陷阱,UTC 与本地时间的转换、时区偏移计算、时间差与格式化解析,以及高精度计时、单调时钟与跨时区服务的工程实践与最佳建议,帮助后端开发者避开时间陷阱。

Lua 的时间处理能力来自标准库的 os 模块,它是对 C 标准库 <time.h> 的一层薄封装:os.time 对应 time()/mktime(),os.date 对应 localtime()/strftime(),os.clock 对应 clock()。这层封装简单直接,但也继承了 C 时间库的所有陷阱——时区依赖环境变量、没有时区数据库、os.clock 测的不是墙上时间。

在 Web 后端(OpenResty)、日志系统、定时任务这些场景里,时间处理出错往往很隐蔽:跨时区用户的"今天"不一致、夏令时切换时多算或少算一小时、用 os.clock 测出的耗时与真实耗时对不上。本文把这些坑逐一拆开,给出可落地的写法。时间字符串的解析离不开模式匹配,相关技巧见 https://plumephp.com/lua-string-pattern-matching/。

os.time 与 os.date 基础

两个函数构成了 Lua 时间处理的地基:

-- os.time():返回当前 Unix 时间戳(秒,整数)
print(os.time())              -- 例如 1759801500

-- os.time(table):把时间表转为时间戳
local ts = os.time({year = 2026, month = 10, day = 7, hour = 12, min = 0, sec = 0})
print(ts)

-- os.date():把时间戳格式化为字符串(默认本地时间)
print(os.date("%Y-%m-%d %H:%M:%S"))    -- 当前本地时间

-- os.date("*t"):返回时间表
local t = os.date("*t")
print(t.year, t.month, t.day, t.hour, t.min, t.sec, t.wday)

时间表(time table)的字段是固定的:

字段含义范围
year年完整年份,如 2026
month月1~12
day日1~31
hour时0~23
min分0~59
sec秒0~61(含闰秒)
wday星期几1~7,1 是星期日
yday一年中的第几天1~366
isdst是否夏令时boolean

os.time 的输入表只需 year/month/day,缺省时 hour/min/sec 按 0 处理。wday、yday 是输出字段,传入时会被忽略。

时区:最容易被忽略的坑

Lua 标准库没有时区概念,一切都依赖进程的 TZ 环境变量与 C 库的 localtime。这意味着同一段代码在不同服务器上可能算出不同结果。

-- 默认按本地时区解释
print(os.date("%Y-%m-%d %H:%M:%S"))       -- 取决于 TZ

-- 前缀 "!" 强制按 UTC
print(os.date("!%Y-%m-%d %H:%M:%S"))      -- 始终是 UTC

os.date 的格式串前缀 ! 是关键:加了它,时间按 UTC 计算,与 TZ 无关。因此服务端日志与存储应统一用 UTC,只在展示给用户时转成本地时间。

-- 存储:永远用 UTC 时间戳(时间戳本身就是 UTC 无关的绝对时间)
local now = os.time()

-- 存储可读的 UTC 字符串
local utc_str = os.date("!%Y-%m-%dT%H:%M:%SZ", now)

-- 展示:转成用户时区(见下文手动偏移法)

时间戳(Unix timestamp)是自 1970-01-01 00:00:00 UTC 起的秒数,本身不带时区,是跨时区系统间交换时间的正确载体。凡是跨系统传时间,都应传时间戳而非格式化字符串。

os.date 格式串速查

os.date 的格式串沿用 strftime,常用占位符:

占位符含义示例
%Y四位年2026
%m两位月10
%d两位日07
%H24 小时制时22
%M分25
%S秒30
%A星期全名Wednesday
%j一年中的第几天280
%z时区偏移+0800
%Z时区名CST
%%字面量 %%
local ts = os.time()
print(os.date("%Y-%m-%d", ts))            -- 2026-10-07
print(os.date("%A, %B %d, %Y", ts))       -- Wednesday, October 07, 2026
print(os.date("%Y-%m-%dT%H:%M:%S%z", ts)) -- 2026-10-07T22:25:30+0800
print(os.date("!%Y-%m-%dT%H:%M:%SZ", ts)) -- 2026-10-07T14:25:30Z(UTC)

%z 与 %Z 的取值依赖平台:Linux 上能正确给出 +0800 与 CST,某些嵌入式平台或 Windows 上可能为空。不要依赖 %Z 做逻辑判断。

时区转换与偏移计算

Lua 没有内置时区库,转换靠手动偏移或第三方库。最简单的手动偏移法:

-- 已知目标时区相对 UTC 的偏移(小时),把时间戳转为该时区的可读时间
local function to_zone(ts, offset_hours)
    return os.date("!%Y-%m-%d %H:%M:%S", ts + offset_hours * 3600)
end

-- 北京时间 UTC+8
print(to_zone(os.time(), 8))              -- 2026-10-07 22:25:30

原理是:先用 ! 按 UTC 格式化,再把时间戳加上偏移量,等价于"把 UTC 时钟拨到目标时区"。

要反解(把某时区的本地时间字符串转回时间戳),则反向减去偏移:

local function from_zone(str, offset_hours)
    local y, mo, d, h, mi, s =
        str:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)")
    local ts = os.time({
        year = tonumber(y), month = tonumber(mo), day = tonumber(d),
        hour = tonumber(h), min = tonumber(mi), sec = tonumber(s),
        isdst = false,
    })
    -- os.time 按本地时区解释,需先纠正到 UTC 再应用目标偏移
    return ts - os.difftime(os.time(), os.time(os.date("!*t"))) - offset_hours * 3600
end

这段代码暴露了手动偏移法的脆弱:它要借助 os.difftime 计算本机时区偏移,遇到夏令时切换还会出错。生产环境请用 luatz 或 date 这类带时区数据库的库:

local luatz = require "luatz"
local tz = luatz.get_tz("Asia/Shanghai")
local tt = tz:locate(os.time())           -- 按该时区解析当前时间
print(tt:strftime("%Y-%m-%d %H:%M:%S"))

luatz 内置了 IANA 时区数据库,能正确处理夏令时与历史时区变更,是跨时区服务的正确选择。若服务端是 OpenResty,则可结合 Nginx 的时区配置统一处理,参见 https://plumephp.com/lua-openresty-gateway/。

时间差与比较

os.difftime 计算两个时间戳之差(秒),但两个时间戳直接相减在 Lua 5.3+ 中也成立(都是整数):

local t1 = os.time()
-- ... 做些事 ...
local t2 = os.time()
print(os.difftime(t2, t1))     -- 秒差
print(t2 - t1)                 -- 等价写法(5.3+)

计算两个日期相差的天数,用时间戳相减最可靠:

local function days_between(ts1, ts2)
    return math.floor((ts2 - ts1) / 86400)
end

local d1 = os.time({year = 2026, month = 1, day = 1, hour = 0})
local d2 = os.time({year = 2026, month = 12, day = 31, hour = 0})
print(days_between(d1, d2))    -- 364

不要用 os.date 的字段手动算天数差(如 (y2-y1)*365 + (d2-d1))——闰年、大小月、时区都会让结果出错。时间戳相减是唯一稳妥的做法。

判断"是否是同一天"要小心时区:

local function same_day(ts1, ts2, offset_hours)
    offset_hours = offset_hours or 0
    local d1 = os.date("!%Y%m%d", ts1 + offset_hours * 3600)
    local d2 = os.date("!%Y%m%d", ts2 + offset_hours * 3600)
    return d1 == d2
end

先加偏移再按 UTC 取年月日,比直接用 os.date("*t")(受本机 TZ 影响)更可控。

高精度计时:别用 os.clock

这是 Lua 时间处理里最经典的陷阱。os.clock 返回的是进程消耗的 CPU 时间,不是墙上时钟(wall clock):

-- 错误:os.clock 测的是 CPU 时间
local t0 = os.clock()
local f = io.open("big_file"):read("*a")   -- 大量 IO 等待
print(os.clock() - t0)                     -- 远小于真实耗时(IO 等待不算 CPU)

正确的耗时测量要用墙上时钟:

-- 正确:用 os.time(秒级)或高精度时间源(毫秒/微秒级)
local t0 = os.time()
do_work()
print("耗时:", os.time() - t0, "秒")       -- 秒级精度

-- 需要毫秒级时,用 os.clock 之外的来源:
-- 1) Lua 5.4 无内置高精度计时,可用 socket.gettime(LuaSocket)
local socket = require "socket"
local ms0 = socket.gettime()
do_work()
print(string.format("耗时: %.3f ms", (socket.gettime() - ms0) * 1000))

-- 2) OpenResty 环境用 ngx.now()(毫秒)或 ngx.update_time() + ngx.now()
-- local t0 = ngx.now()

os.clock 唯一合适的用途是测量纯 CPU 计算的耗时(如算法性能对比),此时它恰好排除了 IO 等待的干扰。测接口响应时间、脚本总耗时这类"用户感知"的时间,必须用墙上时钟。

常见陷阱与最佳实践

把上面的经验收敛成几条规则:

  • 存储统一 UTC:数据库、日志、跨系统传输都用 UTC 时间戳或 UTC 字符串,展示时才转本地;
  • 传时间戳而非字符串:字符串带隐式时区,时间戳是绝对时间;
  • 别用 os.clock 测墙上时间:它测 CPU 时间;
  • 时区转换用 luatz:手动偏移在夏令时上必错;
  • 天数差用时间戳相减:不要手工算字段;
  • 格式化前先确认时区:%Y-%m-%d 到底是本地还是 UTC,取决于有没有 !。

一个易被忽视的点是秒级时间戳的单调性。os.time() 依赖系统时钟,若运维调整了服务器时间(或 NTP 回拨),时间戳可能倒退,用它做"超时判断"会出错。需要单调递增的计时(如计算超时、限流窗口),应使用单调时钟:

-- OpenResty:ngx.now() 基于单调时钟的毫秒时间
-- 通用场景:用 os.clock(CPU 时间,单调)做相对比较,而非绝对时间

对于限流、超时这类需要"稳定时间流"的场景,绝对时间戳不可靠;单调时钟(monotonic clock)才是正确工具。

定时任务与调度

很多 Lua 程序需要"到点执行"的能力。标准库没有调度器,但用 os.time 配合循环即可实现最小版本:

-- 每隔 60 秒执行一次任务,且对齐到整分钟
local function run_every(interval, fn)
    while true do
        fn()
        local now = os.time()
        local next_at = now - (now % interval) + interval
        local sleep = next_at - now
        -- 通用环境无 sleep,需用宿主提供的接口(如 socket.sleep / ngx.sleep)
        socket.sleep(sleep)
    end
end

对齐到整点(now - now % interval + interval)比"执行完再睡 60 秒"更稳:后者会因任务耗时不断漂移,跑几小时后偏移几分钟。这是定时任务的基本功。

在 OpenResty 里则用 ngx.timer.every 或 ngx.timer.at,它们基于 Nginx 事件循环,不阻塞工作进程:

-- 每 5 秒执行一次
local ok, err = ngx.timer.every(5, function(premature)
    if premature then return end      -- 服务正在关闭
    do_scheduled_job()
end)
if not ok then ngx.log(ngx.ERR, "定时器创建失败: ", err) end

注意 premature 参数:当 Nginx 关闭时会以 premature = true 触发一次回调,用于清理资源。忽略它会导致关闭流程异常。

跨时区的定时任务(如"每天北京时间 0 点跑报表")要特别小心:调度器按服务器时区判断"0 点",若服务器是 UTC,就需要把目标时间换算成 UTC 再比较,或统一把服务器 TZ 设为业务时区。更稳妥的做法是用带时区库算出目标时刻的 UTC 时间戳,与 os.time() 比较。

常见问题(FAQ)

os.time() 返回的是本地时间还是 UTC?

返回的是 Unix 时间戳,它本身是绝对时间,不区分时区。os.time(table) 在把时间表转为时间戳时,会按本地时区解释时间表;要按 UTC 解释,需自行处理时区偏移。

os.date("%H") 拿到的是本地小时吗?

是。os.date 默认按本地时区格式化。加 ! 前缀(os.date("!%H"))才按 UTC。这是跨时区服务里最常见的错误来源之一。

为什么 os.clock 测出的耗时比实际短很多?

因为 os.clock 返回的是进程消耗的 CPU 时间,不包含 IO 等待、睡眠等不占 CPU 的时间。测量真实耗时请用墙上时钟(os.time、socket.gettime、ngx.now)。

Lua 有没有内置的时区支持?

没有。标准库只有 UTC 与"本机时区"两个概念,本机时区由 TZ 环境变量决定。要处理任意时区(含夏令时),需引入 luatz、date 等第三方库,它们内置 IANA 时区数据库。

跨时区服务怎么保证时间一致?

三点:所有存储与传输统一用 UTC 时间戳;所有日志打 UTC;只在最终展示给用户时按用户时区转换。这样"服务器在哪个时区"就完全不影响业务逻辑。

os.time 能处理 2038 年之后的时间吗?

取决于平台。32 位系统上 time_t 是 32 位有符号整数,2038-01-19 后溢出(2038 年问题);64 位系统无此限制。Lua 5.3+ 的时间戳是 64 位整数,但底层 C 库若仍是 32 位 time_t,问题依旧存在。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. Lua 在嵌入式与 IoT 中的开发实践
  2. Lua 与 WebAssembly 互操作
  3. Lua 数值计算与位运算技巧