十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot双数据源动态切换实战:AOP+注解与常见坑解析

Spring Boot双数据源动态切换实战:AOP+注解与常见坑解析 1. 双数据源需求从哪来先搞明白你要解决什么做Java后端开发的兄弟应该都有过这种经历项目跑着跑着业务方突然提了个需求——“这个查询接口不走主库去查另一个库”。我第一次接到这个需求的时候脑子里第一反应是直接改数据源不就行了结果一动手发现事情远没有那么简单。先说一下典型场景。你负责的是一个订单系统主库是MySQL里面存了核心业务数据订单、用户、支付流水都在这里。但公司后来上了一套报表系统数据在另一个独立的PostgreSQL库里或者是一个专门做读写分离的从库甚至可能是第三方合作方提供的只读库。这时候业务方说“我要在订单列表页展示一些报表字段比如客户等级、历史消费频次这些数据在报表库里。”如果只有一个数据源这事太简单了一个Mapper的事儿。但多了一个数据源之后问题就来了Spring Boot默认的数据源配置怎么处理MyBatis的Mapper扫描怎么区分事务怎么控制更重要的是查询接口怎么做到“该走A库走A库该走B库走B库”而且还不能把代码写得一团糟。这篇文章就基于我实际做过的一个双数据源项目把整个思路、踩坑、代码实现一条龙讲清楚。适合已经会Spring Boot MyBatis基础操作、但还没碰过动态数据源的兄弟也适合正被多数据源折磨到掉头发的同行。核心思路说白了就一句话通过AOP切面 自定义注解在方法调用前动态切换数据源查完再切回来。听起来简单但里面涉及的细节非常多下面逐一拆开说。2. 技术选型思路注解AOP vs 分包方案我为什么选前者2.1 两种主流方案的对比实现双数据源接口查询网上搜一圈方案五花八门但真正靠谱的主流方案其实就两类。第一类分包方案。把不同数据源的Mapper接口放在不同的包路径下比如com.example.mapper.db1和com.example.mapper.db2然后配置多个MapperScan分别指定不同的SqlSessionFactory。这种方案的特点是配置清晰代码里不需要任何切换逻辑包结构本身就隐含了数据源的归属。第二类注解AOP方案。通过自定义注解比如DS(slave)标记在Service方法或Mapper方法上然后用AOP拦截注解在方法执行前把数据源切换到指定的那个执行结束后再恢复默认数据源。我当时纠结了很久最后选了注解AOP方案原因主要有三条第一分包方案虽然配置简单但业务代码里如果要在同一个Service里查两个库就得注入两套Mapper代码耦合度高。比如订单列表接口需要先查订单主库再根据订单里的客户ID去查报表库的客户等级这时候你就要在Service里手动调两套Mapper逻辑稍微一复杂代码就变得很难看。第二注解方案天然契合“接口级别切换”这个需求。我的需求是“不同的接口走不同的数据源”在Controller或Service方法上加一个注解语义极其清晰。后边维护代码的人看到DS(report)马上就知道这个方法是查报表库的。第三注解方案扩展性强。今天需要双数据源明天可能就三数据源了加一个枚举值的事不需要动结构。分包方案也有它的优势比如完全靠Spring容器管理SqlSessionFactory没有AOP的反射开销。但如果你的项目本身就是中大型系统接口数量多、业务逻辑复杂我个人更推荐注解方案代码可读性高后期维护成本低。2.2 为什么不用JTA分布式事务方案这里插一句当初有人建议我用Atomikos之类的JTA方案来做分布式事务。但我仔细想了一下这个场景根本不需要。我的查询场景是“查主库拿到订单列表再查报表库拿到额外字段”两步操作都是只读查询没有跨库写入压根不需要强一致性的分布式事务。引入JTA反而带来一堆麻烦XA协议性能损耗、事务管理器配置复杂度、对连接池的兼容性问题。你要是真敢在只读查询场景里上JTA线上性能分分钟教你做人。记住一个原则分布式事务是最后的手段能不用就不用。双数据源查询如果不需要跨库写入尽量通过编码方式组装数据或者干脆做数据的冗余同步。我这个项目里报表库的数据本身就是从主库异步同步过去的两边数据一致性要求并不高所以完全没必要上重武器。3. 双数据源核心实现从依赖配置到动态切换源码3.1 项目基础结构说明我用的技术栈是Spring Boot 2.7.x MyBatis Plus 3.5.x Druid连接池。为什么用这套因为公司老项目就是这么搭的新项目可以直接照搬省去踩坑时间。项目结构大概是这样的src/main/java/com/example/demo ├── config │ ├── DataSourceConfig.java # 数据源配置类 │ ├── MybatisPlusConfig.java # MyBatis Plus配置 │ └── DynamicDataSourceAspect.java # AOP切面 ├── datasource │ ├── DynamicDataSource.java # 动态数据源核心类 │ └── DataSourceContextHolder.java # 数据源上下文持有者 ├── annotation │ └── DS.java # 自定义注解 ├── controller │ └── OrderController.java ├── service │ ├── OrderService.java │ └── impl │ └── OrderServiceImpl.java └── mapper ├── OrderMapper.java # 主库Mapper └── CustomerLevelMapper.java # 报表库Mapper至于为什么要有DataSourceContextHolder和DynamicDataSource这两个类下面会详细解释。3.2 数据源配置两个真实数据源一个路由入口先看配置文件application.yml。核心思路是定义两个真实的数据源主库和报表库然后把它们丢给一个路由数据源去管理。spring: datasource: type: com.alibaba.druid.pool.DruidDataSource dynamic: primary: master strict: false datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.1.100:3306/order_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 report: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://192.168.1.200:5432/report_db username: report_user password: report123注意上面用的是dynamic-datasource-spring-boot-starter这个第三方包的配置格式这玩意儿是真的好用。但如果你不想引入第三方依赖也可以用纯手工方式配置。两种方式我下面都会讲。如果不想引入第三方包传统的Spring Boot配置方式是这样写两个独立的数据源BeanConfiguration public class DataSourceConfig { Bean ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean ConfigurationProperties(prefix spring.datasource.report) public DataSource reportDataSource() { return DataSourceBuilder.create().build(); } }然后每套数据源单独配置一个SqlSessionFactory和SqlSessionTemplateMapper扫描也分开。这种方式也可以但代码量会多一些。我推荐直接使用dynamic-datasource-spring-boot-starter原因有三一是它内置了AOP切面你只需要加注解连切面都不用自己写二是它支持嵌套切换方法里先切到A库调另一个方法切到B库执行完还能正常切回来三是它对MyBatis Plus有很好的兼容性社区活跃度高遇到问题网上基本都能搜到答案。3.3 动态数据源的核心原理如果你坚持要自己实现理解原理比直接用现成框架更重要。动态数据源的原理其实不复杂核心就两个类。DataSourceContextHolder一个ThreadLocal用来保存当前线程应该使用哪个数据源。public class DataSourceContextHolder { private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); public static void setDataSource(String dataSourceType) { CONTEXT_HOLDER.set(dataSourceType); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clearDataSource() { CONTEXT_HOLDER.remove(); } }DynamicDataSource继承Spring的AbstractRoutingDataSource通过determineCurrentLookupKey()方法动态决定用哪个数据源。public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }就这么简单。Spring在每次获取数据库连接的时候都会先调用determineCurrentLookupKey()拿到一个key然后从内部的targetDataSources这个Map里找到对应的真实数据源。你想要切换数据源本质上就是提前往ThreadLocal里塞一个key让Spring能拿到正确的数据源。这个设计有一个关键点ThreadLocal是线程隔离的每个请求一个线程不会互相干扰。但也因此带来一个问题——线程池环境下ThreadLocal必须清理否则复用线程的下一个请求会读到上一个请求设置的数据源导致数据错乱。这个坑后面细说。3.4 基于AOP实现自动切换用AOP的好处是业务代码不需要手动调用DataSourceContextHolder.setDataSource()只需要在方法上加注解就够了。自定义注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DS { String value() default master; }有了注解之后可以手动写一个AOP切面Aspect Component public class DynamicDataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { String dataSourceKey ds.value(); DataSourceContextHolder.setDataSource(dataSourceKey); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clearDataSource(); } } }注意finally块里一定要清空ThreadLocal这是最重要的一步。如果不清理线上环境在Tomcat线程池复用的场景下极大概率会出现“这次请求明明查主库结果数据从报表库返回了”这种诡异问题。我在测试环境就踩过一次跑了半天才定位到是ThreadLocal没清。但如果你用了dynamic-datasource-spring-boot-starter这部分它已经帮你处理好了你只需要关注注解本身即可。3.5 注解放在哪个层级Service还是Mapper这是个非常实际的问题。我一开始图省事直接把DS(report)加在Mapper上后来发现一个问题一个Mapper如果有多个方法其中大部分查主库、只有一两个查报表库那注解要么加在类上全部走报表库要么就得拆Mapper非常不灵活。后来改为放在Service方法上效果好了很多。Service方法是完整业务逻辑的单元粒度适中一个方法里就算调多个Mapper只要最终都是查同一个库一个注解就能覆盖全部。而且Service层的语义更明确queryOrderDetailWithReport()这个方法明显就是要查两边的数据看一眼注解就知道它的数据源依赖。如果你用的是dynamic-datasource-spring-boot-starter它支持注解加在类上时被方法上的注解覆盖也支持嵌套切换。也就是说你在Service A方法上加DS(report)在这个方法内部调用另一个加DS(master)的Service B方法B方法执行期间会切到主库执行完自动回到报表库。这个能力非常实用。4. 双数据源接口查询实战一个完整的业务场景4.1 业务场景描述说的都是我实际做过的。需求是这样的电商后台的订单列表页需要展示基础订单信息和客户的会员等级、历史消费总额。前者在主库order_db后者在报表库report_db的customer_level表里。接口返回的JSON结构大概是{ orderId: ORD20250115001, orderAmount: 299.00, orderStatus: PAID, customerId: CUS10086, customerLevel: GOLD, lifetimeSpend: 18888.00 }这里有个很重要的设计决策是查完主库再去查报表库还是通过一次关联查询搞定由于两个库是物理隔离的不可能有跨库JOIN所以只能分两步查然后在Service里组装。4.2 完整代码实现Controller层RestController RequestMapping(/api/order) public class OrderController { Resource private OrderService orderService; GetMapping(/list) public ResultListOrderVO listOrders(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { return Result.success(orderService.listOrdersWithReport(pageNum, pageSize)); } }Service层Service public class OrderServiceImpl implements OrderService { Resource private OrderMapper orderMapper; Resource private CustomerLevelMapper customerLevelMapper; Override DS(master) // 默认就是master写出来是为了明确语义 public ListOrderVO listOrdersWithReport(Integer pageNum, Integer pageSize) { // 第一步从主库查订单列表 ListOrder orders orderMapper.selectPage( new Page(pageNum, pageSize), new QueryWrapperOrder().eq(order_status, PAID) ).getRecords(); if (CollectionUtils.isEmpty(orders)) { return Collections.emptyList(); } // 收集客户ID列表 ListString customerIds orders.stream() .map(Order::getCustomerId) .distinct() .collect(Collectors.toList()); // 第二步切到报表库批量查询客户等级信息 return queryCustomerLevelAndAssemble(orders, customerIds); } DS(report) public ListOrderVO queryCustomerLevelAndAssemble(ListOrder orders, ListString customerIds) { ListCustomerLevel customerLevels customerLevelMapper.selectList( new QueryWrapperCustomerLevel().in(customer_id, customerIds) ); MapString, CustomerLevel levelMap customerLevels.stream() .collect(Collectors.toMap(CustomerLevel::getCustomerId, Function.identity())); return orders.stream().map(order - { OrderVO vo new OrderVO(); BeanUtils.copyProperties(order, vo); CustomerLevel level levelMap.get(order.getCustomerId()); if (level ! null) { vo.setCustomerLevel(level.getLevel()); vo.setLifetimeSpend(level.getLifetimeSpend()); } return vo; }).collect(Collectors.toList()); } }注意几个细节第一queryCustomerLevelAndAssemble和listOrdersWithReport如果写在同一个类里面AOP切面默认是不生效的因为Spring AOP基于代理机制同一个类内部方法调用不会经过代理。这个问题的解决办法是把查报表库的逻辑单独拆到一个独立Service类里或者自己注入自己不推荐或者用AopContext.currentProxy()。我实际项目里就是把查报表库的方法独立到了ReportQueryService这个类里干净利落。第二批量查询一定要用IN而不是循环单查。假如一页有10条订单循环查10次数据库性能和批量查一次完全不是一个量级。批量查询的SQL参数数量也要注意MySQL默认max_allowed_packet有限制客户ID太多时要分批查一般每批500个比较安全。第三BeanUtils.copyProperties用的是Spring自带的那个属性类型和名称不一致时会静默失败所以要先确认两个类的字段命名是一致的。我这边的经验是先写个单元测试把转换逻辑验证一遍再上线避免测试环境没发现、上线后字段全为空。Mapper层也很简单就是普通的MyBatis Plus BaseMapperpublic interface OrderMapper extends BaseMapperOrder { } public interface CustomerLevelMapper extends BaseMapperCustomerLevel { }这里要特别说明一下如果用的是dynamic-datasource-spring-boot-starterMapper接口不需要加任何注解也不需要额外的MapperScan配置框架会自动把所有Mapper用动态数据源的SqlSessionFactory管理起来。切换数据源只在Service层用注解控制就够了。4.3 排序查询接口怎么办用户提到“排序查询接口”这个其实是双数据源场景里很容易被忽略的点。比如订单列表需要按orderAmount降序排序这个字段在主库的order表里直接在查询时加ORDER BY就行不存在跨库排序的问题。但另一种情况如果排序字段在报表库里比如按customer_level排序金卡优先、银卡其次那逻辑就复杂了。我的做法是先从报表库按规则查出排好序的客户ID列表再拿着这个ID列表回主库按指定顺序查订单。这里有个小技巧查到ID顺序后SQL里用FIELD()函数保持顺序ListString sortedCustomerIds reportQueryService.getCustomerIdsSortedByLevel(); orderMapper.selectList(new QueryWrapperOrder() .in(customer_id, sortedCustomerIds) .last(ORDER BY FIELD(customer_id, String.join(,, sortedCustomerIds) )));但要注意FIELD()函数在MySQL里能用如果你主库是别的关系型数据库语法可能就变了。而且要防SQL注入customerId是内部数据不是用户输入问题不大但如果是拼接外部入参必须用参数绑定。我实际项目里的做法更简单粗暴直接在内存里排序。因为一页就10条数据把报表库查出来的等级信息组装到VO里然后comparator排一下就行。性能几乎无感代码也更好懂。4.4 分页查询时的性能注意分页查询加双数据源性能问题要格外小心。一个很容易犯的错误是先查主库订单分页再查报表库客户信息时忘了SQL本身要分页结果一次IN查了几万个客户ID数据库直接卡死。正确做法是第一步主库分页查询已经拿到了当前页的订单此时把这一页订单里的customerId去重后再拿着这个较小的ID集合去查报表库。因为分页后每页就10条客户ID最多也就10个查询速度极快。如果业务上需要统计报表库的客户数据来做列表的总数展示那就没法避免查全表了这时候建议给报表库的查询条件加索引SQL里也尽量用覆盖索引避免回表。5. 常见问题与排查技巧实录5.1 问题一数据源切换后连接还是指向旧库这是最常见的坑。表现是日志里看到切到了report数据源但实际执行的SQL还是落到了主库。排查步骤先确认DS注解是否真的生效。如果注解加在Controller的方法上且Controller不是Spring管理的Bean不太可能或者AOP没有正确配置注解就不会生效。再确认是否有多个DataSource类型的Bean存在。Spring Boot的自动配置在检测到只有一个DataSource时会自动注入但如果有两个以上就必须明确指定Primary否则MyBatis拿到的是不确定的数据源。最后检查是不是Druid连接池的缓存问题。Druid会缓存物理连接切换数据源后同一个DruidDataSource实例里的连接不会变但AbstractRoutingDataSource本身就是管理多个DruidDataSource实例的每个实例的连接是独立的不存在缓存串库的问题。所以问题大概率出在前两步。5.2 问题二同一个事务里切换数据源失败如果你在方法上加了Transactional然后又用DS切换数据源可能就会遇到切不过去的情况。原因是Spring事务管理器的机制一个事务开启时会先从数据源获取一个数据库连接整个事务期间都复用这个连接直到事务提交或回滚。你在事务执行中途换数据源但事务持有的连接还是旧数据源的自然不生效。解决办法查询场景尽量不加Transactional只读事务本身意义不大还限制了切库能力。如果确实需要事务把事务边界控制在一个数据源内部不要跨数据源。真的需要跨数据源事务一致性就要上分布式事务方案了这个前面说过最好避免。5.3 问题三多线程下数据源串了前面提到过ThreadLocal的问题。在Spring Boot的默认线程池里请求线程处理完并不会立刻销毁而是归还给线程池复用。如果你在方法里设置了ThreadLocal但忘了在finally里清理下一个请求复用这个线程时就会读到脏数据。排查方法在切换数据源的地方加日志把线程ID和数据源key打出来复现问题后看日志里有没有“同一个线程第二次执行时key还是上一次的”。我的习惯是在项目里加一个filter每个请求结束时强制清理ThreadLocalComponent public class DataSourceClearFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(request, response); } finally { DataSourceContextHolder.clearDataSource(); } } }双保险再也不用担心线程串数据源。5.4 问题四接口查询速度变慢双数据源接口不一定慢但有两个地方容易变成性能瓶颈。第一个是连接池配置。如果报表库的连接池初始大小太小高并发时来不及创建连接接口就会阻塞等待。Druid配置里可以设置initialSize5, minIdle5, maxActive20再加上maxWait60000避免无限等待。第二个是跨库网络延迟。如果主库和报表库不在同一个机房每次查询都要走一次专线延迟可能几十毫秒到上百毫秒。减少查询次数是最有效的优化手段能批量查就批量查绝对不要循环单查。如果报表数据变化不频繁还可以加一层Redis缓存查一次缓存住5分钟后再失效性能提升非常明显。5.5 问题五MyBatis二级缓存导致数据源错乱如果你的项目启用了MyBatis的二级缓存查询结果会按照Mapper的namespace缓存起来。双数据源场景下同一个Mapper如果既查主库又查报表库缓存命中时不会真正执行SQL也就不会触发数据源切换返回的数据可能是错的。解决办法很简单双数据源场景下直接关掉MyBatis二级缓存或者至少不要给可能跨库查询的Mapper开缓存。MyBatis Plus默认二级缓存是关闭的但如果你手动开启过记得注意这个问题。6. 工程化落地从Demo到生产环境还要做什么6.1 多环境配置管理生产环境的主库和报表库地址肯定和开发环境不一样。我的习惯是在application.yml里用占位符然后每个环境一个profile文件spring: config: activate: on-profile: prod datasource: dynamic: datasource: master: url: ${MASTER_DB_URL} username: ${MASTER_DB_USERNAME} password: ${MASTER_DB_PASSWORD} report: url: ${REPORT_DB_URL} username: ${REPORT_DB_USERNAME} password: ${REPORT_DB_PASSWORD}数据库密码不要写死用环境变量注入。哪怕内网环境也不排除有配置文件泄露的风险。6.2 连接池参数有哪些讲究连接池参数不是越大越好要结合数据库本身的承载能力来评估。以Druid为例我通常这样配置主库maxActive50, initialSize10, minIdle10因为主库核心业务流量大。报表库maxActive20, initialSize5, minIdle5查询频率低一些没必要占太多数据库连接。另外testWhileIdle设置为truevalidationQuery设置为SELECT 1确保空闲连接被定期检测避免数据库层断开连接后应用还在用死连接。6.3 监控报警怎么加双数据源引入后监控的复杂度也上去了。我的做法是在切面里加上埋点统计每个数据源的查询次数和耗时接入Prometheus Grafana。语法上就是切面里around方法记录开始时间和结束时间然后把数据源key和耗时作为标签上报。报警规则可以设两条报表库查询耗时超过1秒的比例超过10%时报警。某个数据源连接获取失败次数连续3分钟超过50次时报警。有了监控线上问题不再是用户先发现而是监控先报警体验完全不一样。6.4 代码规范与团队协作双数据源的代码规范一定要写在项目文档里否则后来接手的人很容易在Mapper层乱加注解导致数据源配置一团糟。我给自己所在的团队定的几条规矩第一DS注解只能出现在Service层方法上禁止在Mapper层使用。原因是Service层是业务逻辑边界数据源切换跟着业务走而不是跟着SQL走。第二每个数据源的用途必须写清楚。主库用来干嘛报表库用来干嘛哪些表在哪些库维护一份最新的表格放到项目Wiki里。第三新增查询接口时默认走主库只有明确知道数据在报表库时才允许加DS(report)。业务上不确定数据源归属时先问清楚再动手。第四所有跨数据源查询的Service方法命名上最好带上数据源信息比如listOrdersWithReport、getCustomerLevelFromReport一看到方法名就知道涉及到报表库。7. 结合潮流为什么这个主题在Java八股文里越来越火最近刷Java面试题和行业社区发现双数据源相关的面试题出现频率明显变高了。原因也不难理解现在的业务系统几乎没有单库打天下的哪怕是一个中等规模的系统也会因为读写分离、业务隔离、历史库归档等各种原因拆出不止一个数据库。面试官问双数据源的实现本质上是在考察候选人有没有处理过真实业务复杂度的经验。面试的时候如果只背出AbstractRoutingDataSource和ThreadLocal这两个词面试官会觉得你背过八股文但实际项目没做过。但如果你能讲清楚实际业务场景、为什么选注解方案而不是分包方案、切换数据源后线程池复用会有什么坑、事务和数据源切换怎么共存面试官对你的技术深度和项目经验评估会完全不同。从技术成长路线的角度来说双数据源是个很好的切入点它能把你对Spring AOP、事务、连接池、线程模型的分散知识点串起来。我之前带过不少实习生做这个需求之前他们对Spring的理解停留在“背了Bean生命周期”做完之后才算真正开始建立起“Spring容器如何管理对象与资源”的全局认知。所以不管是实际项目需要还是为了面试升级花时间把双数据源搞透绝对不亏。手开始自己写个Demo然后逐步加上事务、分页、批量组装这些真实业务场景你会发现自己对Java后端的理解能上一个台阶。8. 最后分享几个小技巧做双数据源项目半年多我自己的体会是技术实现的复杂度并不高真正难的是对边界情况的设计和预判。一个小技巧是开发阶段可以在切换数据源的方法里临时加一条日志打印出当前拿到的连接URLAround(annotation(ds)) public Object around(ProceedingJoinPoint joinPoint, DS ds) { String key ds.value(); DataSourceContextHolder.setDataSource(key); try { Connection connection DataSourceUtils.getConnection( Objects.requireNonNull(dataSource)); log.info(当前数据源: {}, 连接URL: {}, key, ((DruidDataSource) connection).getUrl()); return joinPoint.proceed(); } finally { DataSourceContextHolder.clearDataSource(); } }这样每次请求都能在日志里确认数据源切换是否真的生效排查问题效率翻倍。上线前记得去掉不然日志会刷得很猛。另一个技巧是关于代码可读性的。双数据源需求一多Service方法上的DS注解就容易散落各处。建议做一个项目级的代码扫描规则比如用ArchUnit测试或者写一个简单的AST检查禁止DS出现在Controller和Mapper层这样能提前拦截一半以上的错误用法。最后想说的是双数据源只是一个工具核心还是对业务的理解。数据查出来要组装、要排序、要分页这些永远是业务逻辑的主线数据源切换只是实现手段。不要为了炫技把代码写复杂能用最简单的方式解决就不要引入额外的复杂度。把基础功打牢遇到问题多看看运行时日志这套思路放到任何数据库中间件上都成立。
返回列表