
前段时间接了个需求产品经理开口第一句就是“不同角色登录系统后访问数据库的用户要不一样”。我当时脑子里的第一反应是这需求听着不算复杂但你细琢磨一下系统里的角色可能有十几个数据库账号总不能也开十几个吧而且换账号意味着连接断开重连、连接池切换、权限边界重划搞不好就是一个大坑。等我把需求吃透、把方案落地、把线上问题踩完一轮之后回头再看这其实是个非常典型的“数据库连接隔离权限最小化”问题在金融类、政务类、SaaS多租户系统里几乎天天见。这篇就围绕这个“角色不同访问数据库的用户不同”的需求把从需求拆解、方案选型、落地方案到常见坑位排查的完整过程记录下来。适合正在做多角色权限系统、多数据源切换、或是被类似“变态需求”折磨的后端开发者参考尤其是用Spring Boot做数据库路由的同学可以直接抄作业。1. 需求拆解到底是产品脑子进水还是我们想简单了1.1 先搞明白“数据库用户不同”到底指什么这个需求的第一层意思是登录系统的用户是一个概念连数据库的账号是另一个概念。用户登录后系统知道他是“管理员”、“运营人员”还是“只读报表人员”但程序访问数据库时用的是同一个连接串、同一个数据库用户所有业务操作都共用一套账密。现在产品要求的是不同角色走不同的数据库账号。比如管理员角色连数据库时用app_admin这个账号运营角色用app_operator报表角色用app_report_read。这样做最直白的好处是数据库层面的权限能按角色隔离管理员能增删改运营只能改自己业务域的数据报表账号干脆只能SELECT从数据库层面就把越权操作堵死了。这里要特别提醒一句千万不要把“业务用户”和“数据库用户”混为一谈。业务用户是系统里的登录身份比如工号、user_id数据库用户是连接数据库时的技术身份比如app_admin。一个系统里可能有上万个业务用户但数据库用户通常是几个到十几个就够否则账号管理和连接配置会变成灾难。这个毛病我在第一次做这个需求时犯过后面会细说。1.2 三种诉求缠在一起不拆开就会做歪把产品的话翻来覆去听几遍你会发现里头至少裹着三件完全不同的诉求第一是认证隔离。不同角色用不同数据库账号本质上是在数据库层做一次身份背书防止应用层被脱库或者被恶意注入后攻击者拿到的是一个“万能账号”。第二是数据权限隔离。这其实是很多产品经理没说出口的真需求他们真正想要的是“运营不能看到财务数据、财务不能改运营配置”。这种行级、表级的访问控制靠切换数据库账号只能做到“库表权限”这一层实现不了“同一张表里不同行”的细粒度过滤。第三是审计追溯。按角色切数据库用户后数据库的information_schema、binlog、审计日志里会自然记录“哪个数据库用户在哪个时间干了什么”给合规审计提供基础数据。所以我在动手前先和产品对齐了一件事如果只是“不能让运营看到敏感字段”这种行级权限别用切数据库账号来做用查询时加WHERE条件或字段脱敏更合适。切账号适合的是“不同角色操作不同表、不同库、不同实例”的场景。对齐完这个边界后面的设计才没有跑偏。1.3 主流实现思路对比动态数据源是性价比之王想实现“不同角色连不同用户”市面上常见的有四条路方案核心思路优点缺点适用场景多套数据源硬编码每个角色对应一套DataSourceService里写if判断去调不同Dao简单直接代码膨胀严重加一个角色要改一遍业务代码角色极少且固定几乎没有扩展性需求动态数据源路由用一个AbstractRoutingDataSource根据上下文切换DataSource扩展性好业务代码无感知需要注意连接池和事务时序有学习成本大多数角色数据库用户映射场景数据库中间件代理在应用和数据库之间加一层代理代理根据登录用户路由到不同账号对应用侵入小需要额外维护一套中间件排查链路长多实例、多租户、大型团队分库分表中间件按角色/租户分片到不同物理库隔离最彻底复杂度最高需要数据迁移和路由规则设计数据量巨大、合规要求极强的系统我做这个需求时选的是“动态数据源路由”方案。原因很简单改动集中在框架层业务代码基本不用动加角色时只需要加数据源配置和路由规则而且Spring本身就有AbstractRoutingDataSource这个抽象类不用引入额外组件。后面也验证了选型是对的遇到的所有问题都集中在“切换时机”和“连接池复用”这两个点上属于可控范围。2. 方案选型与整体设计动态数据源路由的完整思路2.1 为什么动态数据源路由能同时满足“隔离”和“省事”很多人一听“每个角色一个数据库用户”第一反应是写三个数据库连接工具类每个角色调用不同的工具类。这虽然也能跑但业务代码里到处是if(roleADMIN){adminDao.query()} else {operatorDao.query()}一旦加角色、改连接配置、调数据库密码那场面基本就是灾难。动态数据源路由的思路完全不一样。它维护一个“路由键 → 数据源”的映射表每次要从连接池获取连接时先根据当前线程里的角色上下文选出一个对应的数据源然后从那个数据源的连接池里拿连接。业务代码里依然是userMapper.selectById(1)它不知道底层连接到底来自管理员账号还是运营账号对业务是无感知的。这样做的好处非常明显业务代码零侵入所有切换逻辑收敛在框架层数据源数量可控一个角色对应一个数据源加了新角色只需要加配置和映射权限收口统一路由逻辑是全局唯一的不会出现某个Service漏改导致越权连接池天然隔离每个角色的数据源有独立的连接池一个角色的连接池耗尽不会拖垮其他角色。2.2 整体架构三个核心组件一个都不能少我把整个方案分成了三层每一层干一件事逻辑特别清晰第一层角色上下文传递层。用户请求进来后从Token、Session或SSO里解析出当前用户的角色拿到一个角色标识比如ADMIN、OPERATOR、REPORT然后把它放到一个ThreadLocal里。这里为什么要用ThreadLocal因为一次请求通常由一个线程从头跑到尾ThreadLocal可以保证这个角色在同一个线程内处处可见又不会跨线程污染。第二层路由决策层。这一层就是AbstractRoutingDataSource的核心。Spring在每次getConnection()时会调用determineCurrentLookupKey()这个方法返回一个键框架拿着键去查数据源映射表决定本次连接用哪个数据源。我的实现就是从ThreadLocal里取角色然后通过角色到数据库用户名的映射表返回对应的键。第三层连接管理层。每个数据源都是独立的HikariDataSource有自己的连接池、用户名、密码、连接超时时间等配置。底层连接的真实账号就是我们在数据库里创建的那些用户比如app_admin、app_operator、app_report_read。这三层各司其职缺一不可。如果没有第一层路由决策层连“当前是谁”都不知道如果没有第二层角色和数据库用户永远对不上如果没有第三层前面两层只是普通的字符串替换毫无意义。2.3 一个最要命的时序问题切换必须早于事务开启这整个方案里最容易踩、也是最隐蔽的坑就是“事务时序”。Spring的事务管理器会在事务开启时从数据源获取一个数据库连接并且在整个事务周期内这个连接都绑定在当前线程上事务内的所有SQL都复用这一个连接。什么意思呢如果你已经在事务里执行了一条SQL这个时候再去切换数据源是无效的因为连接已经拿到手了后面所有操作都在旧连接上执行。举个具体例子运营角色调了一个Transactional方法方法第一步去查订单表用的是app_operator连接方法内部某个逻辑突然调了DataSourceRouter.switchTo(ADMIN)然后去查财务表你以为用的是管理员账号实际上还是那个app_operator连接。如果app_operator没有财务表权限直接报错如果它有权限那你的权限隔离就形同虚设。所以设计时必须保证路由切换发生在事务开启之前而且整个事务内角色不允许再变化。我的做法是在进入Service方法之前、事务还没有开启时就把角色设置到ThreadLocal同时约定业务代码里不允许中途调用切换方法如果有需求那必须拆成两个事务方法。3. 落地实操从建数据库账号到跑通全链路3.1 数据库侧三个角色账号和最小权限授予方案定下来之后第一步是在数据库里把用户建出来。这里以MySQL为例假设系统里有三种角色管理员、运营、只读报表。数据库叫business_db需要三个账号-- 管理员账号拥有business_db下所有表的全部权限 CREATE USER app_admin% IDENTIFIED BY StrongAdminPass123!; GRANT ALL PRIVILEGES ON business_db.* TO app_admin%; -- 运营账号只拥有订单、商品相关表的增删改查权限 CREATE USER app_operator% IDENTIFIED BY StrongOperatorPass123!; GRANT SELECT, INSERT, UPDATE, DELETE ON business_db.orders TO app_operator%; GRANT SELECT, INSERT, UPDATE ON business_db.products TO app_operator%; -- 只读报表账号只允许查询连数据都不让改 CREATE USER app_report_read% IDENTIFIED BY StrongReportPass123!; GRANT SELECT ON business_db.* TO app_report_read%; FLUSH PRIVILEGES;这几条SQL看似简单但有几个细节值得注意账号的Host别乱用%。如果应用服务器IP相对固定强烈建议写成应用服务器的IP比如app_admin192.168.1.10这样即使密码泄露别的机器也连不上。这个细节在合规要求高的项目里几乎是硬指标。最小权限原则。运营角色只授予它真正需要的表权限别图省事给它整个库的权限。只读报表账号严格只授SELECT它连INSERT的权限都没有即使应用层逻辑出了漏洞攻击者想通过报表通道写数据也写不进去。密码强度要够。既然是不同角色不同用户密码就不能搞成123456这种至少16位混合大小写数字特殊字符。我在交付时特意写了个脚本定时提醒团队改密码数据库账号密码半年一轮换这个后面可以专门写一篇运维实践。3.2 应用侧Spring Boot多数据源配置与路由核心代码数据库用户在MySQL里建好之后回到Spring Boot项目里核心工作是三块配置多数据源、实现路由逻辑、做上下文传递。先看配置。我在application.yml里用一个自定义前缀custom-datasource来管理多数据源避免和Spring Boot自动配置冲突spring: datasource: # 默认数据源可以指向一个公共库或配置库 url: jdbc:mysql://localhost:3306/business_db username: app_admin password: StrongAdminPass123! custom-datasource: routes: ADMIN: url: jdbc:mysql://localhost:3306/business_db username: app_admin password: StrongAdminPass123! OPERATOR: url: jdbc:mysql://localhost:3306/business_db username: app_operator password: StrongOperatorPass123! REPORT: url: jdbc:mysql://localhost:3306/business_db username: app_report_read password: StrongReportPass123!然后写一个配置类把custom-datasource.routes下的每个路由项构造成一个HikariDataSource并注册给AbstractRoutingDataSourceConfiguration public class DynamicDataSourceConfig { Bean Primary public DataSource dataSource( Value(${custom-datasource.routes}) MapString, DataSourceProperty routeProps) { MapObject, Object targetDataSources new HashMap(); for (Map.EntryString, DataSourceProperty entry : routeProps.entrySet()) { DataSourceProperty prop entry.getValue(); HikariDataSource ds new HikariDataSource(); ds.setJdbcUrl(prop.getUrl()); ds.setUsername(prop.getUsername()); ds.setPassword(prop.getPassword()); ds.setMaximumPoolSize(10); targetDataSources.put(entry.getKey(), ds); } DynamicRoutingDataSource routingDataSource new DynamicRoutingDataSource(); routingDataSource.setDefaultTargetDataSource(targetDataSources.get(ADMIN)); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } }接着是路由核心类继承AbstractRoutingDataSource重写determineCurrentLookupKeypublic class DynamicRoutingDataSource extends AbstractRoutingDataSource { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setRole(String role) { CONTEXT.set(role); } public static void clearRole() { CONTEXT.remove(); } Override protected Object determineCurrentLookupKey() { return CONTEXT.get(); } }这套代码看起来很短但它是整个方案的命门。determineCurrentLookupKey()返回的角色名就是数据源映射表的键Spring每次getConnection()都会调用它从而按当前线程的角色拿到对应的连接池。这里有个重要的细节只有ADMIN被设置为默认数据源。这意味着万一某个请求的角色没有正确设置到ThreadLocal里它默认会用管理员账号去查库这在权限上是有风险的。更稳妥的做法是默认数据源指向一个权限最小的只读用户比如app_report_read宁可功能暂不可用也不能把越权暴露出去。我后来就把默认源改成了只读用户安全第一不是说着玩的。3.3 请求侧角色上下文如何从登录态一路透传路由核心写好了接下来最关键的环节是每个请求到底是怎么把自己的角色塞进ThreadLocal的。如果系统用的是Spring MVC我选择用一个HandlerInterceptor拦截所有需要鉴权的请求在进入Controller之前解析当前登录用户角色并设置路由上下文public class RoleContextInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从Token或Session里解析登录态 LoginUser loginUser getUserFromSession(request); if (loginUser ! null) { String role loginUser.getRole(); // 例如 ADMIN / OPERATOR / REPORT DynamicRoutingDataSource.setRole(role); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后必须清理否则线程池复用时角色会串 DynamicRoutingDataSource.clearRole(); } }这里afterCompletion里的clearRole()绝对不能不写。我见过太多人把ThreadLocal设置完就忘掉清理结果Tomcat线程池复用同一个线程处理下一个请求时下一个用户明明没有权限却拿到了上一个用户的角色直接串库。这是血泪教训。如果系统里有登录标志比如JWT那就更简单写一个OncePerRequestFilter从Token里解析出角色设置到ThreadLocal在finally里清理。思路和拦截器完全一样只是落点不同。3.4 验证环节怎么确定连接真的切换了代码写完最怕的是“看似生效实际上根本没切”。我有一次就是路由配好了日志打上去全是同一个账号排查了半天才发现是切换顺序不对。所以验证这一步必须严格做两件事。第一件事看日志里的连接账号。在租用数据源的地方加一段临时日志打印当前连接的用户名Connection conn dataSource.getConnection(); DatabaseMetaData meta conn.getMetaData(); System.out.println(当前连接用户: meta.getUserName());分别用管理员、运营、报表三个角色发起请求确认三次打印出的用户名分别是app_admin、app_operator、app_report_read。这一步通过了才能证明路由决策链路是通的。第二件事主动制造权限差异来做功能验证。给app_report_read只授权SELECT然后在报表角色下故意调用一次插入数据的接口预期报错“Access denied for user app_report_read... to database business_db”。如果真报了这个错那就证明底层账号确实切到了只读账号权限隔离真的生效了。几年后回看这个“故意踩雷”的验证方法反而是最有力的证据。4. 常见问题与排查实录这些坑一个比一个经典4.1 问题一一个请求里查出了别人的数据有位同事遇到的现象是用户A登录后访问自己的数据结果返回了用户B的数据集。查了一圈代码发现路由切换的逻辑都正常但问题出在ThreadLocal的清理上。Tomcat的线程池会复用线程如果上一次请求结束时没有clearRole()线程复用时残留的角色就会贴到下一个请求身上。具体场景是线程T先处理了管理员A的请求ThreadLocal里残留ADMIN下一次线程T被分配给只读用户B的请求拦截器因某种原因没有覆盖角色设置比如用户信息解析失败走了默认分支于是B的请求实际查库用的是管理员账号数据自然就串了。修复方法就是在afterCompletion或finally中无条件clearRole()哪怕角色解析失败也要清理宁缺毋滥。这也是为什么我每次写路由上下文都会把“清理”放在比“设置”更重要的位置上。4.2 问题二偶发性Access denied时好时坏运营角色偶尔会翻车报Access denied for user app_operator但多刷新几次又好了。这种“时好时坏”的问题十有八九是数据库权限没配置对或者配置刷新延迟。排查时我习惯先看两个地方。第一个是GRANT语句是否真的生效用SHOW GRANTS FOR app_operator%;确认授权范围。第二个是MySQL的FLUSH PRIVILEGES是否执行了虽然大多数情况grant后即时生效但用CREATE USERGRANT的组合在部分配置下会有延迟。更隐蔽的是GRANT只给了SELECT, INSERT, UPDATE, DELETE四种权限但业务里跑了SHOW VIEW或EXPLAIN这些操作需要额外权限结果就是偶发报错。所以我在授权时直接按业务实际需要的查询类型给全常见的SELECT, INSERT, UPDATE, DELETE, SHOW VIEW再根据项目情况加EXECUTE、REFERENCES等。这是经验之谈别等到线上报警才来补。4.3 问题三连接池被“串味”了动态数据源如果配置不当会出现一种诡异场景一个请求明明切到了报表账号但实际跑的SQL却带着写权限。根源在于数据源对象被Spring容器管理错了。我一个朋友踩过一次他把三个数据源都命名为dataSource结果Spring装配时后一个bean覆盖了前一个导致所有角色拿到的都是最后一个数据源。另一个版本是把DynamicRoutingDataSource和自己手写的某个DataSource混在一起注册Primary没标注事务管理器取到了错误的数据源。解决这个问题的关键是路由数据源必须是整个应用里唯一被业务使用的DataSource另外那些真实数据源只能作为target被它内部引用不能直接暴露给Service或Mapper。我习惯给真实数据源起不同的Bean名称比如adminDataSource、operatorDataSource并给路由数据源加Primary然后在application.yml里把默认spring.datasource指向路由数据源。这样Spring容器和业务层看到的永远是那一个入口。4.4 问题四异步线程里路由直接失效拿到nullSpring的Async注解会开启新线程去跑任务新线程里的ThreadLocal默认是空的于是异步任务里的所有SQL都会走默认数据源。如果默认数据源是管理员账号那异步任务里跑的数据权限就等于失控了。解决方式有两种。第一种偏保守在提交异步任务之前手动把角色信息作为参数传给异步方法方法内部自己维护ThreadLocal。第二种是引入上下文传递组件比如用TransmittableThreadLocal或者把角色塞进MDC、自定义请求头里再到异步线程里取回来。我实际项目里用的是第一种简单可靠代码也容易审计。异步方法内部开头写一行DynamicRoutingDataSource.setRole(role)方法结尾finally里clearRole()不要依赖外部拦截器去清理。4.5 常见问题速查表现象根本原因快速处理请求间数据串号ThreadLocal未清理在拦截器afterCompletion或finally中clearRole()偶发Access denied授权不全或Flush延迟用SHOW GRANTS检查授权按业务类型补全权限所有角色都是同一个连接数据源Bean被覆盖给路由数据源加Primary真实数据源用独立Bean名异步任务路由失效拿null新线程没有ThreadLocal内容异步方法内部手动设置角色并务必finally清理只读角色也能写数据只读用户被赋予了写权限收紧GRANT只授SELECT事务里切换无效连接在事务开启时已固定保证角色设置早于事务开启事务内不变更角色5. 这需求还能怎么扩展从角色路由到租户路由5.1 多租户场景租户角色双路游如果你做的是SaaS系统需求往往会变成“不同租户的不同角色访问不同的数据库用户”。比如租户A的管理员和租户B的管理员虽然是同一个业务角色但数据必须物理隔离。这种场景下路由键就不能只有角色而应该是租户ID 角色的组合。我在一个项目里的做法是setContext(租户A_ADMIN)然后配置里把租户A_ADMIN、租户A_OPERATOR、租户B_ADMIN分别指向不同数据库实例的账号。本质上和单角色路由是一样的只是把键从一维变成了二维。这样设计的收益是隔离性极强每个租户的数据源头就分开了。代价是数据库账号数量和数据源数量会线性增加所以只建议用在租户数量少、但对隔离要求极高比如金融监管的场景如果是几千个小租户还是建议用共享库行级权限控制。5.2 千万别把“切账号”当成数据权限的银弹这一点我想单独提出来说一下因为我见过太多团队把这个需求做偏。切换数据库用户解决的是“连接身份”的隔离它控制的是你能连哪张表、能执行哪种操作SELECT/UPDATE/DELETE。但你没法用它实现“同一张订单表里运营只能看华东区的单子财务只能看金额大于1000的单子”——那是行级数据权限得靠业务SQL里的WHERE条件、数据权限框架、或者物化视图/安全视图来实现。如果把行级权限也硬塞进“切数据库用户”里你会发现自己要建无数个数据库用户权限配置复杂到没人敢动最后成了整个系统的定时炸弹。所以正确的姿势是连接隔离用切账号数据过滤用业务逻辑审计追溯两边都留痕。5.3 顺手收获审计和监控更好做了这个方案落地后其实还有两个隐形福利。一个是审计日志清晰了。因为每个角色的操作都落在不同的数据库账号下MySQL的general log或审计插件可以直接按user字段过滤哪个角色干了什么一目了然。不用再去应用日志里翻“操作人是谁”。另一个是监控告警能按角色拆分了。连接池使用率、慢查询、锁等待这些指标现在可以按数据源分别统计。比如app_report_read的慢查询突然飙升说明报表查询出问题了app_operator的连接池告警说明运营后台压力大了。这在以前用单一账号时是完全做不到的。我个人在实际项目里的体会是这个需求听起来“变态”本质上是把“用户”和“数据库账号”两个概念彻底解耦进而获得连接级隔离和权限最小化带来的安全和审计红利。真正难的不是写路由代码而是理解切换时序、连接池复用和上下文清理这三件事。只要把这几个关键点拿捏住再叠加一层扎实的数据库授权规范这个需求就能从“头疼”变成“加分项”。最后再分享一个小技巧上线前后一定要写几个“负面测试用例”专门验证低权限角色访问高权限接口时必须报错这比任何代码评审都管用。