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 |
%H | 24 小时制时 | 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,问题依旧存在。
延伸阅读
- https://plumephp.com/lua-string-pattern-matching/
- 时间与时区处理
- PostgreSQL 时区与日期时间
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。