配置页我做了"双写"策略,结果缓存和数据库各说各话,用户反馈开关"薛定谔式生效"
上周有个用户群里@我,说后台明明关了"强制手机验证",注册时还是弹短信。我登后台看了一眼,开关确实灰着,再试一次——好了。让用户再试,又不行了。
这熟悉的随机感,让我瞬间回到三个月前那次重构。
当时为了解决"配置改完前台不生效"的老毛病,我搞了个自认为很稳的方案:表单提交先写数据库,再同步刷Redis,最后记个版本号时间戳。理论上三重保险,实际上三个数据源各跑各的。
问题出在"异步刷新"这四个字上。我为了省那几十毫秒响应时间,把Redis更新扔进了队列。队列消费失败会重试,但重试逻辑没做幂等。结果某次网络抖动,同一条配置刷了两次Redis,第二次把旧值覆盖了新值——因为数据库读和缓存写之间,又插进了另一个保存请求。
更坑的是我的版本号。我用的`microtime(true)`,精度到微秒,本以为不会撞。但PHP在FastCGI下多进程请求,同一毫秒内进来两个保存,版本号可能完全相等。前台读缓存时比较版本号,相等的直接返回,结果就是谁覆盖谁全看运气。
排查那两天我写了段调试脚本,循环读一百次配置,输出值和版本号。看着终端里true/false随机闪烁,我理解了什么叫"分布式系统的噩梦"。
现在的方案土是土了点,但稳:配置表加了个`lock_version`字段,更新时用乐观锁`WHERE lock_version = :old`。Redis不再异步刷,改成事务内同步写,响应慢点就慢点,后台保存本来也不差这50ms。最重要的是加了条兜底——每次读缓存前对比数据库最新版本号,不一致直接穿透读库并重建缓存。
那个"薛定谔开关"最后查日志发现,是用户A保存时队列卡住,用户B紧跟着改了另一项配置,把A的脏值带进了缓存。两个用户互不相识,却在我的配置系统里完成了一次精妙的"竞态合谋"。
现在我的配置页保存按钮旁边多了行小字:"上次生效时间 2024-XX-XX XX:XX:XX",不是给用户看的,是给我自己排查时定位用的。有时候最可靠的监控,就是一行肉眼可见的时间戳。