首页 / 资讯中心 / 文章详情

多村庄数据隔离怎么做?服务端上下文切换的一次工程复盘

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