一、 背景
基于成本和数据安全的考虑,需要将XX的存储数据源从Oracle切成MySQL。券系统作为XX中心最底层系统,不仅数据极其重要而且数据量庞大,如何设计一套完整迁移方案保证准确无误的实现数据(历史数据和增量数据)迁移且让上层业务无感知成为挑战。基于以上分析可以看出有以下两个问题需要解决:
- 历史数据如何同步?
- 如何平滑切换让业务无感知?
二、迁移方案
1. 历史数据
历史数据同步需借助BDP(Big Data Process,BDP)提供的ETL工具实现,如果目标库为单库则直接利用ETL完成。如果目标库为分片库,则需要先通过ETL将数据推送到kafka,应用服务消费kafka后再插入或更新到分片库。
数据同步完成后需验证数据正确性,先把新老库中的数据同步到hive库,然后创建对比hql脚本进行对比,再通过对比结果判断是否完全达成一致。如果是历史数据不一致则再同步历史数据,如果是增量数据不一致则检查双写是否成功。
2.增量数据
ETL数据同步只能解决历史数据迁移,增量数据需要通过双写实现,经过分析有以下两种双写方案可供选择:
双写方案一通过在原来写入Oracle的mapper上添加自定义注解,然后通过aop拦截自定义注解,在切面中加载写入MySQL的新mapper,通过反射调用实现写入mysql。
优点:调用方无代码污染;
缺点:mybatis-plus自动装配时只能配置单数据源,需要改造代码定义双数据源,改造成本大风险高;
双写方案二在写入Oracle的mapper处通过开关控制调用写入MySQL的mapper,从而实现双写两个数据源。
优点:简洁;
缺点:在原有业务调用处需添加新调用逻辑;
经过以上分析选择双写方案二,梳理出整体的迁移方案流程如下:
三、收益
通过双写加ETL同步迁移Oracle表格21张,共计数十亿条数据,为XX券系统下线Oracle做好铺垫。
四、采坑总结
1、采坑1、事务导致切换数据源失败
1.1、现象![](https://i-blog.csdnimg.cn/blog_migrate/911b5d59e4a773d5fba16da44ff35d74.png)
代码逻辑如下:
@Transactional(rollbackFor = {Exception.class})
public Result<Object> saveActivityEventOut(ActivitySavaOutDto activitySavaOutDto){
//省略非关键代码
ScsActivityEvent activityEvent = activityToChange(activitySavaOutDto); scsActivityEventMapper.insert(activityEvent); if(SystemProperty.writeCouponEvent2mysql()){ try { scsBaseActivityEventMapper.insertSelective(activityEvent); }catch (Exception e){ log.error("write2mysql error,param:{}",JSONObject.toJSONString(activityEvent),e); } } //省略非关键代码
目的:此处通过给scsActivityEventMapper和scsBaseActivityEventMapper标注不同的数据源,通过DynamicDataSrouce切换数据源双写Oracle和MySQL。
1.2、定位过程
- 检查xml中sql语句,未发现任何错误;
- 怀疑是sql中插入条件判断有误,尝试改成直接插入,同样报错;
- 尝试精简sql,只保留id
此处逻辑是先执行scsActivityEventMapper.insert(activityEvent)插入oracle,然后返回自增id填充到activityEvent,再将此id插入mysql,怀疑mysql是不是已经存在此id,因此debug代码查看:
可以看到MySQL不存在此数据但依然报重复key。
- 再次检查报错信息
cause: java.sql.SQLException: ORA-00001: 违反唯一约束条件 (SCS.SYS_C0013672)
这几个关键字引起注意,目前仅插入id但MySQL并不存在275483这个Id,难道是数据源切换失败写入了Oracle?
- 验证
基本实锤切换数据源失败导致此问题,将本应写入mysql的数据再次写入Oracle。回到代码发现此处与其他地方不同的是该方法上标注事务注解@Transactional(rollbackFor = {Exception.class}),因此猜想肯定是事务造成数据源的切换失败,下面深入源码一探究竟。
1.3、源码分析
可以看到在执行mapper接口调用时解析当前注解得到数据源标识,然后再通过数据源获取连接时查询线程变量栈顶元素得到当前数据源标识,再从dataSrouceMap中获得数据源。如果获取不到,会将设置成primary的数据源作为默认数据源。
线程变量LOOKUP_KEY_HOLDER结构为什么用Dequeue?
为了支持嵌套切换,如ABC三个service都是不同的数据源,其中A的某个业务要调B的方法,B的方法需要调用C的方法。一级一级调用切换形成链。传统的只设置当前线程的方式不能满足此业务需求,必须模拟栈,后进先出。
事务和aop的集成可以拆解成以下两步:
步骤1:先通过TransactionAutoConfiguration配置导入BeanFactoryTransactionAttributeSourceAdvisor通知器:;
步骤2:在容器生成ActivityEventManager bean时通过beanPostProcessor后置处理器生成代理Bean。先找出所有Advisor,然后遍历所有的Advisor判断是否与当前bean匹配,如果匹配则加入通知器数组中,以此作为拦截器生成代理对象。
可见在调用此方法时会被TransactionInterceptor中invoke方法拦截。
TransactionSynchronizationManager线程变量:
ThreadLocal<Map<Object, Object>> resources =new NamedThreadLocal<>(“Transactional resources”);
ThreadLocal actualTransactionActive =new NamedThreadLocal<>(“Actual transaction active”);
ThreadLocal<Set> synchronizations =new NamedThreadLocal<>(“Transaction synchronizations”);
上述逻辑可以拆解成以下四个步骤:
步骤1: 在执行带事务注解方法前先执行增强逻辑,通过DruidDataSource#getConnection()获取连接并绑定在resources中,同时将actualTransactionActive设置成true;
步骤2:sql执行时调用代理方法MybatisMapperProxy#invoke,被SqlSessionInterceptor拦截获取SqlSession,根据SqlSessionFactory优先从resources 查询,如果存在则返回;如果不存在则跳转步骤3;
步骤3:sessionFactory开启sqlSession,根据Exector、SpringManagedTransactionFactory生成DefaultSqlSession。如果actualTransactionActive为true,以sessionFactory作为Key,sqlSession作为value注册到resources上,同时将事务信息注册到synchronizations ;
步骤4:获取到sqlSession后即可执行sql语句,如果出现在执行业务方法时出现失败(包含sql)则在TransactionAspectSupport执行回滚;
通过上述逻辑保证同一个线程同一个事务里面是同一个连接。
1.4、失败原因总结及解决办法
执行带事务注解saveActivityEventOut方法时会被TransactionInterceptor拦截,此时未调用mapper接口,则获取默认oracle数据源并绑定在当前线程的resources线程变量上并激活当前事务(actualTransactionActive设置true)。
执行scsActivityEventMapper.insert(activityEvent)时,生成sqlSession时会根据resources中的数据源获取连接生成,然后以sqlSessionFactory作为Key,sqlSession作为value保存到resource上;
继续执行scsBaseActivityEventMapper.insertSelective(activityEvent),在获取sqlsession时优先从resource中查询,此时已经存在则直接返回,sqlSession中数据源仍然是oracle的数据源,切换失败。
@Transactional(rollbackFor = {Exception.class}) public Result<Object> saveActivityEventOut(ActivitySavaOutDto activitySavaOutDto){ //省略非关键代码 ScsActivityEvent activityEvent = activityToChange(activitySavaOutDto); scsActivityEventMapper.insert(activityEvent); if(SystemProperty.writeCouponEvent2mysql()){ CouponEventBusFactory.build().registerAsynWriteEvent(AsynWriteEventListener.class); CouponEventBusFactory.build().saveCouponAsyncEvent(new CouponAsynEvent(activityEvent)); } //省略非关键代码 }
事务是通过将dataSource和sqlSession绑定在线程上,另起线程执行写入mysql,原有线程上下文变量即失效,即会重新生成sqlSession和获取数据源。
2、采坑2、自增主键Id
为了实现主键id自增,Oracle在插入之前需要通过以下代码实现,但MySQL可以直接将字段设置成autoIncrement即可。
<selectKey keyProperty="id" resultType="String" order="BEFORE"> select SEQ_ACTIVITY_COUPON.nextval from dual </selectKey>
3、采坑3、日期函数不一致
Oracle使用to_char(created_time, ‘yyyy-MM-dd’),sysdate() ;
Mysql对应的使用date_format(created_time,’%Y-%m-%d’),now(),curdate(),sysdate()。
3、采坑3、排序方式不一致(特别注意)
Oracle对字段升序时,null值会放在最后;倒序时,null值会放在最前面;非null值区分大小写排序;
MySql对字段升序时,null值会放在最前面;倒序时,null值会放在最后面;非null值不区分大小写排序。
4、采坑4、分页不一致
Oracle分页是依赖ROWNUM的,所以代码中一般都会嵌套2~3层来实现分页;rownum > startNum and rownum <= endNum,rownum默认是从1开始的,其中endNum = startNum + pageSize,startNum和endNum都会根据偏移量改变;
MySql分页是通用limit实现:limit startNum, endNum,startNum从0开始的,startNum会根据分页起始偏移量改变,endNum = pageSize不会改变。
5、采坑5、日期比较
Oracle不能直接使用Date类型字段与字符串进行比较;
MySql可以直接使用Date类型字段与字符串进行比较。
6、采坑6、分组时查询字段必须是分组字段或统计函数字段
Oracle在使用group by时,select的字段必须是group by中的字段或统计函数字段;
MySql在使用group by时,select的字段允许为非gooup by中的字段。
7、采坑7、更新语句
Oracle更新后均返回1;
MySql更新成功后,如果更新的字段没有发生变化,返回0,否则返回1。
四、 总结
本文首先分析券基础数据迁移存在的困难和挑战,然后针对问题提出历史数据迁移方案和双写方案,同时对实际迁移中踩过的坑进行总结形成经验。