一、切了三次村庄,有一次列表是别人的
系统里一共管着二十三个村,账号体系里有十一个账号同时属于两个以上村庄,主要是乡镇干部、驻村干部和几个兼任邻村职务的两委成员。上线两周之后,一位驻村干部反馈说他在村庄切换器里选到第三个村,左侧的通知列表还是上一个村的内容,刷新一次才对。我们按他的操作路径复现,十次里能复现三次,剩下七次正常,属于那种最难查的偶发问题。
同一周还出了两件同类的事。一件是镇里那位干部点开人口统计,看到的数字是单个村的,他自己以为系统坏掉了,因为他要从七个村里合计出一个片区的数。另一件更严重,一位干部被调整了所负责的村庄,调整之前他在原村庄发过的三十多条通知,在新村庄的操作记录里全都不见了,村两委那边一度怀疑记录被人删过,我们翻了半天数据库才排除。
这三件事看着不一样,根子都在同一个地方,我们没有把“当前这个请求属于哪个村”这件事变成一条硬约束,而是让它散落在前端参数、后端拼接和服务端缓存的各个角落里。散落的结果就是,一百次请求里有九十七次对,三次错,而错的那三次发生在最不方便解释的场合,用户看到的是别人的数据。
二、靠前端传参是管不住数据归属的
当时的写法很朴素,前端在每次请求的查询参数里带上 villageId,后端把它当成普通业务参数接住,拼进查询条件。这个写法在单村庄账号上跑得很好,因为参数永远只有一种取值;一旦账号属于多个村,参数就变成了一个可以被随意构造的输入。用户手动改一下地址栏或者用接口调试工具重放一次请求,就能拿到别的村的数据,而我们当时没有任何一层会拦它。
第二个漏洞在缓存。列表接口的缓存键是业务名加查询条件,唯独没有村庄标识,第一个村的数据被缓存下来之后,第二个村的同一个接口会直接命中,返回的还是上一个村的内容。这也解释了为什么刷新一次就对,因为刷新时恰好命中了另一个键,或者缓存刚好过期。
第三个漏洞在写路径。新增通知时 villageId 由前端填,服务端只是照抄,于是同一个账号在切换村庄之后误操作一次,就能把 A 村的通知写进 B 村。读路径上的错还能刷新挽回,写路径上的错要人工订正,代价高得多,这也是我们下决心重构的直接原因。
三、物理隔离和逻辑隔离我们各算了一笔账
第一条路是物理隔离,每个村一套独立的库或者独立的 schema,连数据字典都分开。这条路对数据安全最省心,一个村的运维动作影响不到另一个村,但它有三个我们扛不住的代价。二十三个村意味着二十多套连接配置和二十多份迁移脚本,每次加字段要跑二十三遍;镇级大屏要看片区汇总,跨库聚合要么搬数据要么写一堆临时表;备份和恢复的脚本也要按村写,运维同事一个人维护不过来。
第二条路是共享库加 village_id 列,配上服务端强制注入的写法,也就是逻辑隔离。它的代价很直白,任何一处查询漏掉了村庄条件就会串数据,而这种漏是人力盯不住的,只能靠框架层强制。我们选这条路的理由有两个,跨村统计是镇里每天都要用的刚需,共享一张表算起来最直接;二十三个村的量级放在一套库里,加了索引之后性能完全够用。
第三条路是数据库层的行级安全策略,把可见范围交给数据库引擎去判。理论上它是最不容易漏的一层,但我们对它做过评估之后放弃了,一是团队里没人真正在生产上用过这套东西,二是当时选型的 MySQL 版本对它的支持参差不齐,而项目还要兼容达梦和 Oracle,三套引擎的行为差异会让排查成本翻好几倍。这条路的思路我们记了下来,等到多租户规模再上一个量级时会重新考虑。
四、把村庄标识收进令牌里
改造的第一步是把村庄标识从普通参数升格为身份信息。用户切换村庄时不再改查询参数,而是走一个专门的接口重新签发令牌,把当前村庄编号写进令牌的自定义声明里,声明名取 vid,同时刷新过期时间。前端拿到新令牌之后覆盖本地存储,后续所有请求都只带令牌,不再自己传村庄编号,从源头上断了乱填的可能。
落点在万村乐数字乡村的请求拦截层,做法是拦截器在业务代码执行之前解析令牌,把 vid 和账号的可见范围写进一次请求独有的上下文对象,随请求生命周期存亡。业务代码要拿当前村庄,只能从上下文里取,不从任何 HTTP 参数里取。这样做的额外好处是,同一个方法在多村庄账号和单村庄账号下的行为完全一致,不需要写两套判断。
数据访问层做了统一的条件追加,所有走框架的查询在生成 SQL 之前会被拦一道,自动补上村庄条件,参数从上下文里取。为了让这条约束没法绕开,我们把原来手写的拼接入口全部收敛到框架的查询构造器上,代码评审时只要看到有人在业务代码里手写 village_id 就退回去。这一层改完,读路径的串数据问题在回归用例里彻底消失了。
五、区划码、前缀匹配和缓存分键
村庄之外的区域层级我们统一用行政区划码来标识,省、市、县、镇、村五级各占一段,村级码一共十二位。五级之间的从属关系不用额外维护父子表,靠前缀匹配就能算出来,比如查某个镇下面的所有村,条件写成 region_code like 前缀加通配符即可。这套写法的好处是新增村庄时只要码规整,层级关系自动成立,坏处是前缀匹配用不上索引的等值优化,所以我们在镇村两级上各建了一个前缀索引。
缓存键的规则改成了村庄标识加业务名加查询条件三段,村庄标识放在最前面,切换村庄时用一次前缀删除把该账号在旧村庄下的整段缓存清掉。为了让删除可控,所有与村庄相关的缓存键都由一个统一的构造方法生成,不允许各处自己拼字符串,这样前缀删除时不会误伤其他业务的键,也不会漏掉某个手写的键。
六、三个坑最后都出在边界上
第一个坑是切换村庄之后前端缓存没有清。现象是通知列表、待办数量、通讯录三个入口里总有一两个还是旧村的数据,用户点进去再退回来就好。根因是前端把接口结果缓存在内存里,键名只带业务名,切村时只刷新了当前页面的数据。改法是缓存键统一加上村庄编号,切换动作触发一次以旧村庄编号为前缀的清理,并且把路由重新挂载一遍,让所有组件的初始化逻辑重跑。
第二个坑是跨村统计被自动加上了条件。现象是镇干部在任意一个村的视图下点开片区汇总,看到的永远只有他自己当前所属的那一个村的数。根因是拦截器无脑追加村庄条件,汇总接口也不放过。改法是引入一个显式的跨村声明,打在方法和调用入口上,声明生效后拦截器跳过条件追加,同时要求这个声明必须和权限校验成对出现,声明了跨村的接口如果没做权限检查,启动时的自检会直接报错。
第三个坑是账号换了村庄之后历史数据跟着换。现象是一位干部从甲村调到乙村,甲村的历史操作记录列表里就少了他这个人,村两委查账时对不上。根因是操作记录表只存了用户编号,展示时按用户当前的村庄去关联,等于用今天的状态解释昨天的事实。改法是在记录表里落一份村庄编号的快照,写入时一次定格,展示时按快照查,用户后来调到哪个村都不影响历史记录的归属。
七、这层挡不住什么
村庄隔离解决的是横向的可见范围,它管不了同一个村内部的纵向差异。同村的村民、村民代表、村两委成员和上级监管部门看到的内容本来就不一样,这件事要靠身份和角色的组合去判,村庄上下文只是把它缩小到了一个村的范围。我们当时的做法是在村庄条件之外再叠一层可见范围字段,两个条件同时生效,缺一不可。
有强合规要求、必须做到数据不出独立存储的场景,这套逻辑隔离也不适用,那时候只能回到物理隔离的方案上去,代价前面已经算过。另外还有一个容易被忽略的性能前提,加了村庄条件之后,索引的前缀必须带上 village_id,否则在二十三个村的量级下扫描行数会放大到原来的二十多倍,我们在压测里遇到过慢查询从八十毫秒涨到两秒的情况,最后靠调整联合索引的顺序解决的。
八、小结
把村庄标识从参数提升成身份,是这一整套改造里真正把成本降下来的那个决定。它让数据访问层可以放心地在框架里统一追加条件,让缓存键有一个稳定的前缀,让切换村庄变成一次令牌重签而不是一次参数重填。剩下的细节,包括跨村声明和操作记录的快照,都是在边界上补的洞。
二三十次版本迭代下来,最初的写法里前端想传什么就传什么,现在换成服务端自己说了算。万村乐数字乡村这边现在管着二十三个村、四千多个账号,切换村庄的平均响应时间从最早的一点八秒压到三百毫秒左右,串数据的反馈记录归零,村两委那边也再没因为记录归属的事来问过我们。