You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

8.7 KiB

时间戳规范重大修正:采用 TIMESTAMPTZ

日期:2026-01-29
类型:架构决策修正
影响范围:所有数据库表、时间相关文档和代码


变更概述

纠正了项目初期的错误决策,将时间戳字段从 TIMESTAMP WITHOUT TIME ZONE 改为 TIMESTAMPTZ,以确保数据正确性和符合 PostgreSQL 最佳实践。


问题背景

项目初期采用了 TIMESTAMP WITHOUT TIME ZONE 记录事件时间戳,理由是"应用层统一使用 UTC,无需数据库层时区转换"。

经过实践发现,这个决策存在严重问题:

  1. 语义错误created_atupdated_at 表示真实世界时间点,应该包含时区信息
  2. 容易出错:依赖应用层保证 UTC,容易因疏忽导致时区混淆
  3. 违反最佳实践:PostgreSQL 官方明确推荐使用 TIMESTAMPTZ
  4. 多时区支持差:无法正确处理不同时区的查询

新规范

1. 所有事件时间必须使用 TIMESTAMPTZ

-- ✅ 正确
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
deleted_at TIMESTAMPTZ
last_login_at TIMESTAMPTZ

2. 禁止使用 TIMESTAMP WITHOUT TIME ZONE 记录事件时间

-- ❌ 错误
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP

3. TIMESTAMP WITHOUT TIME ZONE 仅用于无绝对时间语义的字段

-- ✅ 允许:无绝对时间语义
daily_report_time TIME WITHOUT TIME ZONE  -- 每天的报告时间(如 09:00)
business_hours_start TIME WITHOUT TIME ZONE  -- 营业开始时间

4. 默认值使用 now()

-- ✅ 推荐
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

-- ⚠️ 虽然等价,但 now() 更简洁
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP

修正理由

1. 语义正确性

created_at 表示真实世界中事件发生的时间点,这是一个绝对时间,必须包含时区信息。

示例:
- 用户在北京时间 2026-01-29 15:00 创建账户
- 对应 UTC: 2026-01-29 07:00
- 对应纽约时间: 2026-01-29 02:00

这三个表示同一个时间点,TIMESTAMPTZ 能正确处理。

2. PostgreSQL 官方推荐

PostgreSQL 文档明确指出:

For timestamp with time zone, the internally stored value is always in UTC. An input value that has an explicit time zone specified is converted to UTC using the appropriate offset for that time zone.

来源:PostgreSQL Documentation

3. 性能相同

常见误解:TIMESTAMPTZ 性能更差

实际情况

  • 存储大小相同:都是 8 字节
  • 索引效率相同:内部都是整数比较
  • 时区转换开销:微秒级,可忽略不计

4. 避免常见陷阱

# 使用 TIMESTAMP WITHOUT TIME ZONE 的问题:
# 1. 开发者忘记转换时区
user.created_at = datetime.now()  # 错误:本地时区

# 2. 数据迁移时时区混淆
# 3. 多时区部署时数据不一致
# 4. 夏令时问题无法正确处理

# 使用 TIMESTAMPTZ 的优势:
# 数据库自动处理,即使传入本地时区也能正确存储

变更详情

1. 更新架构文档

文件docs/architecture/datetime-timezone-standards.md

修正前

-- ❌ 错误规范
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP

修正后

-- ✅ 正确规范
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

2. 创建 ADR 文档

文件docs/architecture/adrs/006-timestamptz-for-event-timestamps.md

记录了这个重要的架构决策变更,包括:

  • 背景和问题
  • 决策内容
  • 理由和依据
  • 实施计划
  • 参考资料

3. 更新技术栈规范

文件.claude/skills/jointo-tech-stack/references/database.md

需要更新数据库设计规范,明确:

  • 使用 TIMESTAMPTZ 记录事件时间
  • 使用 now() 作为默认值
  • TIMESTAMP WITHOUT TIME ZONE 的使用场景

迁移计划

阶段 1:文档修正(进行中)

  • 更新 datetime-timezone-standards.md
  • 创建 ADR 006
  • 更新 database.md (jointo-tech-stack skill)
  • 修正所有服务文档(folder-service, user-service, credit-service 等)

阶段 2:数据库迁移(待执行)

-- 迁移脚本示例
-- 1. 修改列类型
ALTER TABLE users 
    ALTER COLUMN created_at TYPE TIMESTAMPTZ 
        USING created_at AT TIME ZONE 'UTC',
    ALTER COLUMN updated_at TYPE TIMESTAMPTZ 
        USING updated_at AT TIME ZONE 'UTC',
    ALTER COLUMN deleted_at TYPE TIMESTAMPTZ 
        USING deleted_at AT TIME ZONE 'UTC';

-- 2. 更新默认值
ALTER TABLE users 
    ALTER COLUMN created_at SET DEFAULT now(),
    ALTER COLUMN updated_at SET DEFAULT now();

-- 3. 更新注释
COMMENT ON COLUMN users.created_at IS '创建时间(自动记录时区)';
COMMENT ON COLUMN users.updated_at IS '更新时间(自动记录时区)';

需要迁移的表

  • users
  • folders
  • folder_members
  • folder_shares
  • folder_export_jobs
  • projects
  • credits_transactions
  • credits_consumption_logs
  • credits_gifts
  • credits_pricing
  • recharge_orders
  • 其他所有包含时间戳字段的表

阶段 3:代码验证(待执行)

# Python 代码无需修改
# asyncpg 自动处理 TIMESTAMPTZ 与 Python datetime 的转换

# 验证点:
# 1. 确保所有 Model 使用 datetime.now(timezone.utc)
# 2. 运行集成测试
# 3. 验证时区转换正确性

阶段 4:性能测试(待执行)

  • 基准测试(确认无性能退化)
  • 索引效率测试
  • 查询性能测试

影响评估

数据库层面

正面影响

  • 数据语义正确
  • 自动处理时区转换
  • 支持多时区查询
  • 符合 PostgreSQL 最佳实践

迁移成本

  • ⚠️ 需要 ALTER TABLE(秒级完成)
  • ⚠️ 需要更新所有表的时间戳列

应用层面

正面影响

  • 代码无需修改(asyncpg 自动处理)
  • 减少时区相关 bug
  • 简化多时区支持

迁移成本

  • 几乎为零(asyncpg 兼容)

文档层面

需要更新

  • ⚠️ 所有服务文档
  • ⚠️ 数据库设计文档
  • ⚠️ API 文档
  • ⚠️ 迁移脚本模板

常见问题

Q: 为什么之前选择 TIMESTAMP WITHOUT TIME ZONE?

A: 基于错误的假设:

  1. 误以为 TIMESTAMPTZ 性能更差(实际相同)
  2. 误以为应用层管理时区更简单(实际更复杂)
  3. 误以为 TIMESTAMPTZ 会增加复杂度(实际简化了)

Q: 迁移会影响现有数据吗?

A: 不会。

-- 使用 AT TIME ZONE 'UTC' 转换
ALTER COLUMN created_at TYPE TIMESTAMPTZ 
    USING created_at AT TIME ZONE 'UTC'

-- 现有数据被正确解释为 UTC 时间
-- 数据值不变,只是类型改变

Q: Python 代码需要修改吗?

A: 不需要。

# asyncpg 自动处理 TIMESTAMPTZ
# Python datetime (aware) ↔ PostgreSQL TIMESTAMPTZ

# 现有代码继续工作
user.created_at = datetime.now(timezone.utc)

Q: 性能会受影响吗?

A: 不会。

  • 存储大小相同(8 字节)
  • 索引效率相同
  • 时区转换开销 < 0.01ms(可忽略)

后续行动

立即执行

  1. 更新所有服务文档

    • folder-service.md
    • user-service.md
    • credit-service.md
    • 其他服务文档
  2. 更新技术栈规范

    • database.md (jointo-tech-stack skill)
    • backend.md (jointo-tech-stack skill)

计划执行

  1. 创建数据库迁移脚本

    • 生成 Alembic 迁移
    • 测试迁移脚本
    • 在开发环境验证
  2. 执行迁移

    • 开发环境迁移
    • 测试环境迁移
    • 生产环境迁移(计划维护窗口)
  3. 验证和测试

    • 运行集成测试
    • 性能基准测试
    • 多时区测试

参考资料


总结

这是一个重要的架构决策修正,纠正了项目初期的错误选择。虽然需要一定的迁移工作,但长期来看:

  1. 提升数据正确性:时区信息由数据库保证
  2. 简化代码维护:减少应用层时区处理
  3. 符合最佳实践:遵循 PostgreSQL 官方推荐
  4. 支持全球化:天然支持多时区部署

迁移成本低,收益高,值得立即执行。