前段时间接了个需求,产品经理开口第一句就是“不同角色登录系统后,访问数据库的用户要不一样”。我当时脑子里的第一反应是:这需求听着不算复杂,但你细琢磨一下,系统里的角色可能有十几个,数据库账号总不能也开十几个吧?而且换账号意味着连接断开重连、连接池切换、权限边界重划,搞不好就是一个大坑。等我把需求吃透、把方案落地、把线上问题踩完一轮之后,回头再看,这其实是个非常典型的“数据库连接隔离+权限最小化”问题,在金融类、政务类、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 主流实现思路对比:动态数据源是性价比之王
想实现“不同角色连不同用户”,市面上常见的有四条路:
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 多套数据源硬编码 | 每个角色对应一套DataSource,Service里写if判断去调不同Dao | 简单直接 | 代码膨胀严重,加一个角色要改一遍业务代码 | 角色极少且固定,几乎没有扩展性需求 |
| 动态数据源路由 | 用一个AbstractRoutingDataSource根据上下文切换DataSource | 扩展性好,业务代码无感知 | 需要注意连接池和事务时序,有学习成本 | 大多数角色+数据库用户映射场景 |
| 数据库中间件代理 | 在应用和数据库之间加一层代理,代理根据登录用户路由到不同账号 | 对应用侵入小 | 需要额外维护一套中间件,排查链路长 | 多实例、多租户、大型团队 |
| 分库分表中间件 | 按角色/租户分片到不同物理库 | 隔离最彻底 | 复杂度最高,需要数据迁移和路由规则设计 | 数据量巨大、合规要求极强的系统 |
我做这个需求时选的是“动态数据源路由”方案。原因很简单:改动集中在框架层,业务代码基本不用动;加角色时只需要加数据源配置和路由规则;而且Spring本身就有AbstractRoutingDataSource这个抽象类,不用引入额外组件。后面也验证了选型是对的,遇到的所有问题都集中在“切换时机”和“连接池复用”这两个点上,属于可控范围。
2. 方案选型与整体设计:动态数据源路由的完整思路
2.1 为什么动态数据源路由能同时满足“隔离”和“省事”
很多人一听“每个角色一个数据库用户”,第一反应是写三个数据库连接工具类,每个角色调用不同的工具类。这虽然也能跑,但业务代码里到处是if(role==ADMIN){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_admin'@'192.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,并注册给AbstractRoutingDataSource:
@Configuration public class DynamicDataSourceConfig { @Bean @Primary public DataSource dataSource( @Value("${custom-datasource.routes}") Map<String, DataSourceProperty> routeProps) { Map<Object, Object> targetDataSources = new HashMap<>(); for (Map.Entry<String, 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,重写determineCurrentLookupKey:
public class DynamicRoutingDataSource extends AbstractRoutingDataSource { private static final ThreadLocal<String> 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 USER+GRANT的组合在部分配置下会有延迟。更隐蔽的是: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 问题四:异步线程里路由直接失效,拿到null
Spring的@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的连接池告警,说明运营后台压力大了。这在以前用单一账号时是完全做不到的。
我个人在实际项目里的体会是:这个需求听起来“变态”,本质上是把“用户”和“数据库账号”两个概念彻底解耦,进而获得连接级隔离和权限最小化带来的安全和审计红利。真正难的不是写路由代码,而是理解切换时序、连接池复用和上下文清理这三件事。只要把这几个关键点拿捏住,再叠加一层扎实的数据库授权规范,这个需求就能从“头疼”变成“加分项”。最后再分享一个小技巧:上线前后一定要写几个“负面测试用例”,专门验证低权限角色访问高权限接口时必须报错,这比任何代码评审都管用。