后台配置页写成"单文件数组"三年后,我终于拆出了配置组、版本号与灰度刷新
上周给老项目做配置重构,翻到自己三年前写的后台设置模块,差点没把咖啡喷屏幕上——整个站点的配置全塞在一个 `config.php` 返回的数组里,后台改个客服电话,整个文件重写,前后台同时触发 N 次 `include`。当时还觉得"简单够用",现在看简直是定时炸弹。
这次重构不是炫技,是被逼的。业务多了之后,配置项从二三十个膨胀到四百多个,分成了支付、通知、前端展示、风控规则、第三方对接五大块。原来的单文件模式出了几个要命的毛病:
第一,并发写入直接覆盖。 运营A在改短信模板,运营B在调提现费率,两个人几乎同时点保存,后者把整个文件覆写了,A的改动蒸发。PHP 文件锁 `flock` 我当时根本没加,因为"后台就我一个人用"——flag 立得飞起。
第二,缓存刷新没有版本概念。 以前的做法是保存后 `Cache::clear('site_config')`,然后下次请求重新 `include` 文件写进 Redis。结果某次 CDN 回源节点刚好卡在文件写入中途,读到了半拉数组,前台支付网关配置解析失败,用户下单报错半小时。日志里全是 `Undefined index: merchant_id`,根因却藏在文件 IO 和缓存刷新的时间缝里。
这次我拆成了三层结构,说说现在的做法,给还在用单文件硬撑的兄弟参考:
配置组(Config Group)按业务域隔离。 数据库里一张 `config_group` 表,code 分别是 `payment`、`notification`、`display`、`risk`、`third_party`。每个组独立一张 `config_item` 行记录,key-value + type(string/array/bool/encrypted)+ 环境标记(dev/staging/prod)。后台左侧菜单按组加载,不再一次性拖四百个输入框。
读写走统一入口,加乐观锁。 保存时不是直接 UPDATE,而是先读当前组的 `version` 字段,提交时带版本号做 `WHERE version = ?`,冲突了提示"配置已被他人修改,请刷新后重试"。运营打架的问题从根源解决,比文件锁干净多了。
缓存引入"配置快照 + 版本号"机制。 Redis 里存的不是裸数组,是 `config:snapshot:{group}:{version}` 的哈希结构,同时维护一个 `config:current:{group}` 的字符串记录当前生效版本。保存配置时:
1. 数据库事务写入新记录,version +1
2. 生成新的快照写入 Redis(带 TTL,防脏数据常驻)
3. 原子更新 `config:current:{group}` 指向新版本
4. 旧快照不立即删,设 24 小时过期,万一回滚有退路
前台读配置时只查 `config:current` 指向的快照,完全不走数据库。版本切换是原子操作,不存在读到半拉状态的可能。
灰度刷新是意外收获。 因为有了版本号,我加了个"预览配置"功能:运营保存后可以选"仅预览",生成新版本但不切 `current` 指针,内部测试域名强制指定 `?config_version=xxx` 读取快照验证。确认无误再点"生效",生产环境平滑切换。以前改个配置心脏都要跳出来,现在至少能先给自己看看。
有个细节差点翻车:ThinkPHP 的 `Config::set()` 是运行时内存写入,不会持久化。我一开始在后台控制器里调 `Config::set('site.xxx', $value)`,以为万事大吉,结果请求结束就丢了,前台照样读缓存里的旧值。后来统一封装了 `ConfigService::updateGroup()`,内部完成数据库 + Redis 双写,再清掉本进程的 `Config` 实例缓存,防止同请求内前后读取不一致。
另外,敏感配置(如支付私钥、API Secret)在数据库里用应用层加密,Redis 快照里也是密文,前台使用时在 Service 层解密。虽然多了点 CPU 开销,但审计的时候心里踏实——之前明文塞 PHP 文件,代码仓库一提交,密钥跟着 Git 历史裸奔。
重构完统计了下,后台配置页加载从 2.3 秒降到 180ms,主要还是省掉了那个巨数组的序列化反序列化。运营同事没感觉,但我知道晚上能睡踏实了。
你们后台配置是怎么存和刷的?还在用 PHP 返回数组硬编码的,建议早点拆,越晚越疼。有做过类似版本号快照的,欢迎聊聊回滚策略,我现在的 TTL 兜底感觉还是不够优雅。