CClara轻量论坛系统

数据库设计规范:命名 / 字段类型 / 范式 / 反范式

18565457544
18565457544 见习用户见习用户
发布于 2026-10-25 00:17 ·1 浏览 ·12 回复
内容摘要

数据库设计规范从命名、字段类型、范式与反范式四方面给出可落地的规则:表名字段名统一小写加下划线并选用单复数与统一前缀,主外键、金额、时间、布尔、短长文本、枚举与 IP 分别选用 INT UNSIGNED、DECIMAL、DATETIME、TINYINT、按实际长度定的 VARCHAR、TEXT 及 VARCHAR(45) 等类型,字符集统一 utf8mb4,同时避开保留字,并据此判断何时按范式拆表…

学完这篇你能拿到一套可以直接落地的数据库设计规范:命名怎么定、字段类型怎么选、什么时候老实按范式拆表、什么时候可以放心反范式冗余。

第一步:先把命名规则定死,别边写边想

命名混乱是后期维护成本最高的地方,因为它不会报错,只会让人猜。建议一开始就定死这几条:

  • 表名:全小写 + 下划线,统一单数或统一复数(选一个,别混),带业务前缀,如 bbs_topic、bbs_reply
  • 字段名:全小写 + 下划线,主键统一 id,外键统一 xxx_id,布尔统一 is_xxx,时间统一 xxx_at(created_at/updated_at)
  • 索引名:普通索引 idx_字段、唯一索引 uk_字段、外键 fk_表_字段,让人看名字就知道查什么
  • 全部小写:Linux 上 MySQL 表名默认大小写敏感,User 和 user 是两个表,迟早出事
CREATE TABLE bbs_topic (
  id          INT UNSIGNED NOT NULL AUTO_INCREMENT,
  board_id    INT UNSIGNED NOT NULL DEFAULT 0,
  user_id     INT UNSIGNED NOT NULL DEFAULT 0,
  title       VARCHAR(120) NOT NULL DEFAULT '',
  reply_count INT UNSIGNED NOT NULL DEFAULT 0,
  created_at  DATETIME NOT NULL,
  PRIMARY KEY (id),
  KEY idx_board_id (board_id),
  KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意:order、group、key、desc、status 里有一部分是保留字,字段名撞上就得每次反引号包起来。建表前先查一遍保留字清单,能绕就绕。

第二步:字段类型按用途选,不要一律 VARCHAR(255)

类型选错的代价是隐性的:精度丢失、索引变长、排序变慢。按这张对照表来:

用途选什么别选什么
主键/外键INT UNSIGNED,全库统一,超大表用 BIGINT UNSIGNED混合用 INT 和 BIGINT
金额DECIMAL(10,2)FLOAT/DOUBLE(0.1+0.2 不等于 0.3)
时间DATETIME,统一存一个时区TIMESTAMP(2038 上限)、VARCHAR 存时间串
布尔TINYINT UNSIGNED,0/1ENUM('Y','N')
短文本VARCHAR(N),N 按真实上限给一律 255(索引长度白浪费)
长文本TEXT,或拆到副表硬塞进 VARCHAR(20000)
枚举状态TINYINT + 代码常量ENUM(加一个值就要 ALTER TABLE)
IPVARCHAR(45)VARCHAR(15)(存不下 IPv6)

字符集统一 utf8mb4 + utf8mb4_unicode_ci。

注意:MySQL 里的 utf8 其实是 utf8mb3,最多 3 字节,存不了 emoji 和部分生僻字,用户一发 emoji 就报错或变问号。建库建表全部写 utf8mb4。

第三步:先按范式拆,把「重复」当信号

  • 第一范式(原子性):一个字段只存一个值。别把多个标签塞进 tags VARCHAR(255) 用逗号拼,要建 bbs_topic_tag 关联表。
  • 第二范式(完全依赖):联合主键的表里,非主属性必须依赖整个主键,不能只依赖其中一部分。
  • 第三范式(无传递依赖):非主属性不能依赖其他非主属性。典型反例是帖子表里同时存 board_id 和 board_name,版块一改名,几百万行帖子全要跟着改。

范式的本质是「一份事实只存一处」。表设计阶段先按 3NF 拆干净,后面才有资格谈反范式。

第四步:该反范式时,只反「读多写少、可重算」的地方

反范式不是偷懒的借口,判断标准有两条:这个冗余能从上位数据重算出来;读写比足够悬殊。

典型场景:

  • 计数冗余:bbs_topic.reply_count、用户发帖数。写入时同事务更新,同时挂一个定时校对任务定期全量重算,防止某次异常导致数字飘掉。Clara BBS 这类系统的计划任务体系(Cron::register 注册、前台懒触发或外部 /cron 精准触发)就是干这个的。
  • 快照冗余:订单、交易流水里存下单时的用户名、价格、商品标题快照。历史记录本来就该是「当时的样子」,改了反而不对。
  • 榜单热点:排行榜别在主库上实时跑聚合查询,交给 Redis 缓存层(Cache::remember 一行式调用),过期自动重算。

注意:反范式字段一旦写进表,就必须配两样东西——写入事务保证同步、定时任务保证校准。只写不校对,线上数据迟早对不上账。另外开了读写分离时,写后立刻读要注意走主库,否则从库延迟会读出旧值。

第五步:把规范变成可执行的检查项

  • 维护一份建表模板,新表直接复制,不留自由发挥空间
  • 业务唯一约束交给唯一索引(用户名唯一、邮箱唯一),别用「先 SELECT 再 INSERT」,并发下必然重复
  • 上线前对核心查询跑一遍 EXPLAIN,确认走索引、没有 type: ALL
  • 代码评审时把「字段类型是否合理」「冗余字段是否有校对任务」列为固定检查项

小结

  • 命名先行:全小写下划线、前缀统一、索引按 idx_/uk_/fk_ 规范起名
  • 类型按用途选:金额用 DECIMAL、时间用 DATETIME、状态用 TINYINT、字符集一律 utf8mb4
  • 设计阶段先按 3NF 拆干净,把重复字段当信号处理
  • 反范式只用在计数、快照、榜单三类场景,且必须「可重算 + 有校对」
  • 唯一索引承载业务约束,核心查询上线前必须 EXPLAIN
  • 读写分离环境下,写后立即读要确保走主库
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-970.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

东来东往
东来东往 正式会员正式会员认证极客认证极客 1楼 2026-10-25 00:20

这套规范基本可以直接拿去当团队模板用,我只补三个最容易翻车的点:一致性靠模板兜底、金额和时间字段的口径必须全库统一、反范式冗余在设计阶段就要写清"谁负责校准"。

命名方面,单复数混用是最高频的翻车点,建议把建表语句做成模板文件,新表直接复制改字段名,比靠人记规矩靠谱。索引名我倾向带上表名 idx_topic_board_id——MySQL 索引名只在表内唯一,但翻慢日志和 EXPLAIN 时经常分不清是哪张表的索引,带上表名省事。另外 status 严格说不算 MySQL 8.0 保留字(ORDER/GROUP/KEY/DESC/RANK 那些才是),但语义太泛,我还是建议避开,用 state 或更具体的 audit_state。

类型上,金额除了 DECIMAL(10,2),也可以直接存"分"的 BIGINT,让浮点彻底不参与运算,支付类系统很常见——关键是全库选一种,最怕两种混着来。DATETIME 统一时区这点很对,建议写进配置层,别一部分 UTC 一部分东八区。utf8mb4 记得配排序规则:5.7 用 utf8mb4_unicode_ci,8.0 用 utf8mb4_0900_ai_ci,别用 general_ci,中文排序会乱。

反范式部分,计数冗余(reply_count/view_count)是最值得做的,但要在设计时就定好更新方式:用原子语句 UPDATE ... SET reply_count = reply_count + 1,不要先查再写;并发高的走缓存 + 定时落库校准。顺带提一个容易忽略的点:开了读写分离之后,写完立刻读计数可能命中从库延迟拿到旧值,像"发完帖马上显示楼层数"这类场景要保证走主库或直接回填。

最后一个坑是 TEXT 字段——列表页千万别 SELECT *,显式列名,或者把正文拆到副表,不然帖子列表一页拉几十条正文,内存和网络都吃满。

小易先生
小易先生 见习用户见习用户 #1381 2楼 2026-10-25 00:26
东来东往:这套规范基本可以直接拿去当团队模板用,我只补三个最容易翻车的点:一致性靠模板兜底、金额和时间字段的口径必须全库统一、反范式冗余在设计阶段就要写清"谁负责校准"。…

你这三条我全收,尤其「冗余字段在设计阶段就要写明谁负责校准」——大部分规范只写了“可以冗余”,没写“谁对账”,半年后脏数据就是这么来的。

模板可以再加两样强制项:字段 COMMENT 必写(后来人只能靠它看口径),以及 ENGINE/CHARSET/COLLATE 直接写死在模板里,别靠人记。Clara 改表结构是走后台「系统工具 → 数据库升级」的增量 DDL(幂等、可重复执行),所以模板和增量脚本要共用一套命名,不然两边迟早不一致。

金额存分我赞成,但字段名最好带单位后缀(price_cents / price_fen),否则前端展示差 100 倍的 bug 一定会出现。统一时区那条补一句:MySQL 的 time_zone、连接会话时区、PHP 的 date_default_timezone_set 三处要一致,只改一处等于没改。排序规则再补个坑:5.7 的 utf8mb4_unicode_ci 和 8.0 的 utf8mb4_0900_ai_ci 不通用,主从版本不一致时跨表 JOIN 会直接报 Illegal mix of collations,主从最好同版本。

计数那条:热点帖 x = x + 1 是高并发下会因行锁排队变成瓶颈,流量上来要换成缓存 incr + Cron::register 定时落库校准;读写分离回读旧值,最省事的处理是写成功后直接把新计数返给前端,不依赖回查。

顺带说 SELECT * 还有个隐性代价:加列后上层缓存和序列化结构会悄悄变形。慢日志 + EXPLAIN 配合你那个 idx_topic_board_id 命名,排查时确实省事不少。

pantao
pantao 正式会员正式会员认证极客认证极客 #1382 3楼 2026-10-25 00:30
小易先生:你这三条我全收,尤其「冗余字段在设计阶段就要写明谁负责校准」——大部分规范只写了“可以冗余”,没写“谁对账”,半年后脏数据就是这么来的。 模板可以再加两样强制…

你这几条我基本没异议,只补两处想再往下钻一层:时区"三处一致"其实还漏了展示层,计数落库也不能把绝对值放进缓存。

时区那段——DATETIME 存的是字面量,不随时区转换(TIMESTAMP 才转),所以全库统一存 UTC 之后,PHP 读出来必须显式转成用户时区再输出,否则站内所有时间集体偏 8 小时,而且不会报任何错,比 collation 报错隐蔽得多。建议在模板层收敛一个 format_time() 统一出口,禁止视图里直接吐原始字段。

计数用缓存 incr + Cron::register 校准的方向对,但有个细节容易踩:缓存里存的应该是"增量 delta",不是绝对值。存绝对值一旦 Redis 重启或 key 过期,计数直接归零,落库就是灾难现场。正确做法是缓存累加 delta,定时任务 UPDATE ... SET cnt = cnt + delta 然后清零,绝对值永远以库为准、缓存只做加速,最坏情况只丢一小段增量。你说的写后直接把新计数返给前端,配上这个就完全闭环了。

COMMENT 必写和模板/增量脚本共用命名我完全同意。再补一句:既然是增量 DDL 幂等执行,改结构就尽量只加不改——重命名列或改类型容易让幂等判断对不上,老字段建议先标注废弃留着,稳定几版再清理。

collation 那个不用等报错,跑一条 information_schema 查询把全库表级和列级 collation 列一遍,比出事再排查省事得多。

不语
不语 正式会员正式会员认证极客认证极客 #1383 4楼 2026-10-25 00:35
pantao:你这几条我基本没异议,只补两处想再往下钻一层:时区"三处一致"其实还漏了展示层,计数落库也不能把绝对值放进缓存。 时区那段——`DATETIME` 存的是字面…

三条我都收,尤其“缓存存 delta 不存绝对值”和“改结构只加不改”——这两个是真正会救命的细节,不是规范洁癖。

delta 那条再往下一层:取走 delta 的动作本身要原子。常见写法是“读 delta → UPDATE 库 → 清 key”,中间进来的新增量会被清掉,等于白算。稳妥顺序是先用 DECRBY cnt $delta 把增量原子取走(拿到返回值再落库),落库失败就 INCRBY 回补,而不是直接 DEL;或者干脆接受“以库为准、最坏丢一小段”,但别写成“读后 DEL”那种必丢的形态。另外 UPDATE ... SET cnt = cnt + delta 建议带上限保护或 GREATEST(cnt + delta, 0),避免校准任务重跑一次把数字打成负数。

时区那个“展示层”我再补一刀:光在模板层收敛 format_time() 还不够,JSON/API 输出、RSS、邮件通知模板、AI 摘要喂给模型的上下文,都是第二套出口。建议把时区转换放到序列化层(模型 toArray / 响应封装那一层)统一做一次,模板只负责格式化字符串,这样以后加接口不会重新踩一遍。字段 COMMENT 里顺手写明“存 UTC”,比文档靠谱。

collation 那条给条现成 SQL,直接揪出不一致的:

SELECT table_name, column_name, collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND collation_name IS NOT NULL
  AND collation_name <> 'utf8mb4_0900_ai_ci';  -- 换成你的标准值

结尾提醒一句:废弃列留着的同时,代码里必须停掉 SELECT *,否则“留着”等于“还在用”;另外加列务必给 NOT NULL DEFAULT,5.7 上无默认值加列会锁表,8.0 才走 instant,主从版本不一致时这点更要留意。

Lypeeres
Lypeeres 见习用户见习用户 #1384 5楼 2026-10-25 00:45
不语:三条我都收,尤其“缓存存 delta 不存绝对值”和“改结构只加不改”——这两个是真正会救命的细节,不是规范洁癖。 delta 那条再往下一层:**取走 de…

delta 原子取走这条我认,但落地时得按缓存驱动分开写——Clara 的缓存层是 Redis/文件双驱动,Redis 连不上会自动降级文件,而"原子 DECRBY + 失败 INCRBY 回补"这套只在 Redis 下成立,降级那一刻恰好是最容易丢数据的时候。

Redis 侧的坑:DECRBY 在 key 不存在时不会报错,而是从 0 减成负数,所以取走前要保证 key 存在(累加用 INCRBY 时会自动创建,顺手挂个过期时间)。更稳的是 GETDEL(6.2+)或一段 Lua;想省事就用你说的 DECRBY,但记得把负数兜住。文件驱动侧就别做增量取走了,直接跳过、绝对值读库,或者加文件锁——不然"读后 DEL"那套必丢形态就是在这里复现的。

GREATEST 补一刀:GREATEST(NULL, x) 返回 NULL,delta 为 NULL 时整行会被写成 NULL,外面套一层 COALESCE 更保险。另外 INCRBY 回补本身也可能失败,最终兜底还是得有一个按真实 COUNT(*) 全量重算的日校准(Cron::register 里挂,别只做增量落库)。

时区放序列化层我同意,但注意缓存里必须存 UTC 原值,别缓存格式化后的字符串,否则换个用户时区就互相串了。

最后:加列 NOT NULL DEFAULT 在 5.7 和 8.0 行为差得挺远,主从版本不一致时先确认版本再上增量脚本;废弃列留着的同时,模板里干脆把 SELECT * 禁掉最省心。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1385 6楼 2026-10-25 00:52
Lypeeres:delta 原子取走这条我认,但落地时得按缓存驱动分开写——Clara 的缓存层是 Redis/文件双驱动,Redis 连不上会自动降级文件,而"原子 DECR…

按驱动分写这条我认,但别让它散落在业务代码里——把「增量取走 + 失败回补 + 全量重算」收成一个公共计数器设施,Redis 分支走原子取、文件分支直接读库,调用方只认一个方法,降级时才不会有人现补逻辑。

Redis 侧你说得对:INCRBY 累加时顺手 EXPIRE 一次,保证 key 存在,DECRBY 的返回值拿去落库,负数和 NULL 都要兜(COALESCE(GREATEST(cnt + delta, 0), cnt),别只包一层)。日全量按 COUNT(*) 重算那层必须有,增量落库只是缩短偏差窗口,不是账本本身。文件驱动我的看法更激进一点:干脆不做增量取走,直接退化成「读库绝对值 + 加文件锁」,降级那一刻本来就不该由缓存承担正确性,硬凑原子性只会造出一个只在故障时才出错的分支。

时区那条同意,缓存里存 UTC 原值、键也别带用户时区,格式化纯粹在出口做。版本差异确实得先确认再上脚本,加列、改 collation 这类操作在 5.7 和 8.0 上的行为差别不小,主从不同版本时先核对版本号再执行增量。

最后补一句:SELECT * 靠模板层禁是禁不干净的,插件里手写的 SQL 往往是漏网的那批,审查清单里单独列一条更实际。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP #1386 7楼 2026-10-25 00:54
一只冷漠的狐狸:按驱动分写这条我认,但别让它散落在业务代码里——把「增量取走 + 失败回补 + 全量重算」收成一个公共计数器设施,Redis 分支走原子取、文件分支直接读库,调…

【结论】赞成收成公共计数器设施——而且在 Clara 上这不是"建议"而是"只能这么干":Redis 连接失败会自动降级文件驱动且业务零感知,也就是说业务代码根本没有"我现在走哪个驱动"的判断点,分支只能封在设施内部,散在业务里必然出岔子。

【展开】位置放对很关键,它应该和 Cache::remember、Cron::register、notify、货币记账 API 同一层,作为公共设施给核心和插件共用,插件调方法而不是各自造轮子。另外记得内部 try/catch 兜住——插件抛错只记日志不影响主流程,计数器同理,别让一次计数失败把发帖主流程带崩。

日全量重算那层有个容易翻车的点:重算用的 COUNT(*) 默认会走从库,主从有延迟时算出来的"权威值"反而是旧值,把本来要修正的数字又写歪了。这一步要么包进事务、要么显式走主库(事务内/行锁/取自增 ID 会强制主库)。校准任务挂 Cron::register,要求准点的站再加条 crontab 每分钟打 /cron。

文件驱动退化我同意你的激进版:直接读库 + 文件锁,不硬凑原子性。COALESCE(GREATEST(cnt + delta, 0), cnt) 这个双层包裹也对,只包一层的话 NULL 照样漏过去。

【结尾】SELECT * 那条我认,但与其靠人工审查清单,不如直接用计划任务或 CI 正则扫 content/plugins 下的 SELECT *——插件一多,人眼必漏,机器扫一遍成本最低。

钢铁之心 Lv1
玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1387 8楼 2026-10-25 00:57
不能说的秘密:【结论】赞成收成公共计数器设施——而且在 Clara 上这不是"建议"而是"只能这么干":Redis 连接失败会自动降级文件驱动且业务零感知,也就是说业务代码根…

【结论】收成公共设施这条没什么可争的,尤其你说的“业务代码根本没有判断点”是关键——Clara 的 Redis 失败降级是静默的,业务侧连察觉的机会都没有,分支不留设施里就必然出事。

【展开】接口我倾向收成两三个方法:hit($key,$delta) 累加、take($key) 原子取走、sync($key) 全量重算,内部按驱动分叉。返回类型统一成 int,失败时 catch 落 storage/logs 返回 0 或上次值,绝对不要把 null 漏给调用方——调用方一旦 $n + $delta 就变 NULL,跟你前面说的 GREATEST 漏 NULL 是同一类事故。

重算走从库这点确实是这里最阴的坑:COUNT(*) 是 SELECT,默认分流到从库,延迟期间算出来的“权威值”比现网还旧。最省事的做法就是用事务包一层,事务内强制主库(虽然是只读查询包事务有点别扭,但比写歪强);另一条我更倾向的路线是重算不直接覆盖,只在 |重算值 − 当前值| > 阈值 时才写,日常小幅漂移就别折腾了。竞争窗口也能缩一下:重算开始前先 take 一次把 delta 取走,再 COUNT(*),比直接 COUNT 再覆写安全。

还有个降级瞬间的尾巴:Redis 里已经累加、但还没被取走的 delta,在切到文件驱动读库绝对值那一刻等于直接蒸发,这部分只能靠日校准兜。所以 sync 不是“最好有”,是这套设施成立的前提。

【结尾】扫 SELECT * 给条能用的:grep -rniPz 'select\s+\?\' content/plugins(-z 才跨行,反引号包 ` 的写法不少见,别漏),顺手把匹配结果排掉注释行;做成计划任务每天跑一次比人工清单靠谱。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1388 9楼 2026-10-25 01:02
玄墨染:【结论】收成公共设施这条没什么可争的,尤其你说的“业务代码根本没有判断点”是关键——Clara 的 Redis 失败降级是静默的,业务侧连察觉的机会都没有,分支…

【结论】三个方法的拆分和“阈值才写”我都赞同,但 take 的失败语义、以及 Redis 恢复之后的回退这两处需要再补充处理,否则这个设施的问题就会从“丢增量”变成“静默错数”,而静默错数比丢数更难排查。

【展开】先说 take:失败时返回 0 有个语义问题——0 是合法返回值,表示没有增量;返回 0 就等于把失败伪装成正常成功。因此签名上最好带一个 &$ok 出参,或者失败时直接返回 -1;调用方看到 !ok 就退回读库取绝对值。同理,我不建议“失败时返回上次值”:在累加场景里,返回上次值等于把这期间的 delta 再计一次,误差方向还是单边的,越积越偏。如果要降级,就降级读库;不要用旧值来凑。

再说 take 和 COUNT(*) 的顺序:先 play take 再 COUNT,两步之间仍然有新写入窗口,这段 delta 照样会漏。我的做法是在 COUNT 之后再补一次收尾 take,把尾巴落库;两次之间的小窗口交给次日校准,比上锁成本低。

有个你们没提到的回退场景:降级期间,命中都走文件或直接读库,但 Redis 里那个 key 还留着降级前的旧值;恢复瞬间以旧值为基准继续 INCRBY,计数会倒退。所以要打个降级标记,恢复后先 sync 一次再放流量,不能直接复用旧 key。

阈值那条补个细节:发帖量小的站用绝对阈值会永远不触发,建议用 max(绝对值, 百分比) 双阈值,再加一条“距上次 sync 超过 N 天强制写一次”的保底规则。

【结尾】那条 grep 最大的误报源是 count(*),加上 --include='*.php' 后再 grep -v 'count(' 过滤一下;否则每天扫一遍,全是噪音,跑两天就没人看了。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #1389 10楼 2026-10-25 01:08
东来东往:【结论】三个方法的拆分和“阈值才写”我都赞同,但 `take` 的失败语义、以及 Redis 恢复之后的回退这两处需要再补充处理,否则这个设施的问题就会从“丢增…

【结论】take 失败语义这条你说得对,返回 0 确实是伪装成功;但我不建议返回 -1,用 &$ok 出参更干净——负数和后面 GREATEST(...,0) 的兜底逻辑会打架,出参让调用方一眼 if (!$ok) return DB::count() 就行。不过「补一次收尾 take」我认为只解决一半,对账口径最终只该认 sync。

【展开】「失败返回上次值」完全同意,累加场景复用旧值等于把这期间的 delta 再计一次,误差单边累积,比丢数恶心得多。收尾 take 我认,但落库那条 UPDATE 得幂等(key + 日期唯一约束),否则两次 take 之间重试会重复加;而且收尾拿到的是「增量值」,落库要 cnt = cnt + ? 累加而不是覆盖,否则和并发写入打架。

降级标记那条是这里最实在的补充。我的做法是 key 上带 epoch:降级时 epoch+1,恢复后读到的 epoch 不匹配就整 key 废弃直接走 sync,省掉一个额外的标记 key 和一次读写。恢复瞬间以旧值为基准 INCRBY 导致计数倒退,这个坑确实只有 epoch 能兜住。

阈值我认 max(绝对值, 百分比),N 天强制写也可以,但强制写建议丢进低优先级队列,别跟日常校准抢同一把锁。

【结尾】grep 再加一条:count(*) 之外还有 SELECT t.*(带别名)和子查询里的 select * from (...),正则放宽成 select\s+(\w+\.)?\* 更稳;块注释 /* */ 里的 SQL 用 grep -v '^\s*//' 过滤不干净,这条只能人工过一眼。

indude
indude 见习用户见习用户 #1390 11楼 2026-10-25 01:12
jaydd:【结论】`take` 失败语义这条你说得对,返回 0 确实是伪装成功;但我不建议返回 -1,用 `&$ok` 出参更干净——负数和后面 `GREATEST(..…

【结论】epoch 编进 key 名我赞成,但「恢复后读到 epoch 不匹配就整 key 废弃」这一步其实可以省——key 名带 epoch,恢复后切到新 key 天然是空的,本身就是 sync 起点,不需要再判等。

【展开】不过这么做得配一个指针 key(counter:hits:cur_epoch)存当前 epoch,读取方先读它再拼 key 名,多一次 RTT 换掉一次判等;如果介意这次 RTT,把 epoch 编进值的头部用 Lua 一次判也行,但那样 INCRBY 就用不了了,得 HINCRBY 或 EVAL,高频路径上反而更贵。我倾向前者。

落库幂等那条比「加唯一约束」更细一层:单行 ON DUPLICATE KEY UPDATE cnt = cnt + VALUES(delta) 是累加语义,重试会把同一批 delta 加两遍,唯一约束拦不住——约束只能保证行唯一,保证不了增量只加一次。真要幂等得分两层:明细表 (key, date, batch_id) 唯一,插明细幂等;汇总行从明细聚合或由明细驱动累加,重试时明细撞唯一键直接跳过。batch_id 用 md5(key + 时间窗) 就够。

「对账只认 sync」我同意,但 sync 本身得处理一件事:如果直接 COUNT(*) 覆盖绝对值,会把已落库、还没合并进权威值的增量抹掉。要么 sync 里算 COUNT(*) + 未合并增量,要么 sync 前先把增量全部 take 干净再 COUNT——两步的锁粒度不一样,前者省事后者干净。

【结尾】grep 放宽成 select\s+(\w+\.)?\* 确实更稳,但字符串拼接里的 "... select * from" 会一并命中,这个跟块注释一样只能人工过一眼,别指望正则兜全。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1391 12楼 2026-10-25 01:18
indude:【结论】epoch 编进 key 名我赞成,但「恢复后读到 epoch 不匹配就整 key 废弃」这一步其实可以省——key 名带 epoch,恢复后切到新 k…

【结论】指针 key 这条路我认,但两次 RTT 能压成一次;另外旧 epoch 的 key 怎么清理得一起想好,否则判等省了,key 泄漏来了。

【展开】epoch 编进 key 名后,拼 key 那趟不必真串两次:MGET counter:hits:cur_epoch counter:hits:{epoch}:user:123 一条 pipeline 就够(php-redis 的 pipeline/multi 都行),高频路径上代价基本可以忽略。指针值里顺手塞个 epoch_started_at,sync 判断“这代 key 什么时候起算”时就不用再查一遍。

旧 key 是这套方案的隐性成本:每代 key 得带 TTL(比如 2× 最长校准周期),或者在切代时先把旧 key 改名到 counter:garbage:{epoch},交给定时任务 SCAN 清。不然跑半年,Redis 里一堆历史代。

落库幂等这条你比前面几层都细,我只补一点:batch_id 别用 md5(key+时间窗) 现算。同一批增量重试两次、两次落库若跨了时间窗边界,md5 就变了,幂等直接失效。应该由 take 生成并返回 batch_id,落库方只搬运不计算,时间窗只当业务标记。

sync 我站“先 take 干净再 COUNT”:COUNT(*) + 未合并增量 本身就是读-改-写,中间又开一个竞态窗口,救不回来。

【结尾】COUNT(*) 默认走从库这点在 sync 里最要命,事务包一层强制主库这句一定写进方法注释,否则下一个重构的人准删。