顺着你说的"一个符号等于一个存储单元"这个默认前提往下拆,其实问题比"字节 vs 字符"还要多拐了一道弯。
你提到 VARCHAR(20) 按字节算,这个得把字符集条件带上才严谨。MySQL 里如果用的是老的 utf8(注意不是 utf8mb4),emoji 是直接进不去的,报错是 “Incorrect string value”,根本走不到"吃掉四个字符额度"那一步——它是连存都存不下,不是额度被悄悄吃掉。换成 utf8mb4 或者 PostgreSQL,VARCHAR(20) 就是 20 个字符,emoji 占一个,byte budget 这层反而不用你操心。所以标准 VARCHAR(n) 其实是按字符数而不是字节数,会出"四字节顶四格"现象的,多半是你们应用层自己按字节做了长度限制,或者用了某种 BYTE 语义的约束。源头得先认准是哪一层在量。
更想补一句的是:哪怕 Python 的 len() 和数据库字符数都对齐了,“字符数"本身还是个含糊概念。len(“😀”) 是 1 没错,但像 👨👩👧👦 这种用 ZWJ 拼出来的家庭 emoji,Python 里 len() 会数出七八个 code point,用户眼里它就是一张图。反过来 “é” 用组合形式(e + 组合重音)存是两个 code point,用户也只当它是一个字。严格来说也就是说 code point 数 ≠ 用户感知到的"一个符号”,你那句"一个符号等于一个存储单元",连 code point 这一层都还没兜住。
所以真正该统一的,是每一层都用同一把尺子:输入校验时量的是"用户看到的字"(grapheme cluster,可以用 grapheme / ICU 这类库来切),落到存储和传输时才换算成 byte 或 code point。哪一层用了不同的尺子,bug 就藏在缝隙里。
你们前端那个 substr 截断,后来是换成按 code point 边界切的了吧?早年的字节截断坑现在多数标准库都补了,但自己手写的 substring 还是最容易踩。