ulid比uuid v4更适合作为主键,因其时间戳前置实现天然可排序、base32编码仅26字符(uuid为36字符)、不依赖系统熵池且生成稳定;symfony uid组件开箱即用,支持直接new ulid()并存为字符串,需手动配置doctrine字段类型为string/length=26。

ULID 是比 UUID 更适合做业务主键的选择,它天然可排序、字符串更短、生成不依赖随机数,且 Symfony UID 组件对它开箱即用。
ULID 为什么比 UUID v4 更适合作为主键
UUID v4 是纯随机生成的,数据库插入时会造成 B-tree 索引频繁分裂(因为新值总在中间位置),写入性能差;而 ULID 以时间戳开头,生成顺序基本与时间一致,能保持索引局部性。它的 Base32 编码长度固定为 26 字符(UUID 是 36),存储和传输更省空间。另外,ULID 不依赖系统熵池,生成速度稳定,不会在容器或低熵环境中卡住。
- ULID 字符串示例:
01hwkzqxjf8yvzqxjf8yvzqxjf(小写,26 位) - UUID v4 示例:
f47ac10b-58cc-4372-a567-0e02b2c3d479(含 4 个连字符,36 位) - ULID 的时间精度是毫秒级,同一毫秒内靠随机部分保证唯一,实际业务中极少冲突
用 Ulid 类生成并存入 Doctrine 实体
不需要额外配置,symfony/uid 安装后即可直接 new Ulid。注意 Doctrine 默认不识别 Ulid 类型,需手动指定字段类型为 string 并禁用自动 ID 生成。
- 实体定义中不要用
@ORM\GeneratedValue,改用构造时赋值:$this->id = (string) new Ulid(); - 字段类型必须设为
type="string", length=26,否则 MySQL 会截断 - 若需从数据库读回 ULID 对象,可用
Ulid::fromString($value)解析,但建议只存字符串、业务层再包装 - 避免在 getter 中反复 new
Ulid:构造时生成一次,持久化为字符串,读取时不重建对象
Ulid::isValid() 和校验边界场景
ULID 字符串不区分大小写,但 Ulid::isValid() 接受大小写混合输入,而 Ulid::fromString() 在解析失败时抛 InvalidUidException。生产环境建议统一转小写存储,避免 ORM 或缓存层因大小写不一致导致重复键。
- 校验字符串是否合法:
Ulid::isValid('01HWKZQXJF')→ true(即使大写) - 但
Ulid::fromString('01HWKZQXJF')成功返回对象,Ulid::fromString('01HWKZQXJF!')抛异常 - 注意:空字符串、null、超长/超短字符串都会使
isValid()返回 false - 如果前端传参可能带空格,记得 trim:
Ulid::isValid(trim($input))
ULID 时间部分被篡改的风险与应对
ULID 的时间戳是编码在字符串前缀里的,理论上可被人工构造出“未来时间”的 ID 来扰乱排序逻辑。这不是 Symfony UID 的缺陷,而是 ULID 规范本身的设计权衡——它不提供签名防篡改能力。
- 如果你的业务要求强时间可信度(如金融流水号),不能仅靠 ULID,得加一层服务端时间戳校验
- 常见做法:生成 ULID 后,记录当前
microtime(true),入库时比对两者偏差是否超过阈值(如 ±2 秒) - 不要把 ULID 当作时间凭证直接展示给用户,需要展示时间时应查数据库 timestamp 字段
- ULID 的优势在于工程效率,不是密码学安全,这点容易被忽略











