分享一次 ShardingJDBC 亿级数据分表真实经验
前言
从入职以来写了一年的业务代码,突然接到来自领导的技术需求,说要给我们的借款、还款申请单分表。查看了一下借款表,只有几千万数据,再看还款表已经两亿多了,为了提高数据查询效率,降低数据库的压力。确实可以考虑分表了。另外……这是入职一年以来第一个非业务需求!
设计方案
开始编码实现之前我们需要先做系统设计,主要是以下几块内容要跟领导开会对齐颗粒度!
分表数据库基本信息
- 分表数据库使用新的实例,独立数据源
- 是否需要分库:不需要,只分表,
50
张表足矣 - 分表数据库名
tcbiz_ins_cash_mas_split
(领导定的名字,无特殊含义) - 分表总数
50
张表 - 分表表名
CashRepayApplySplit0${0..9}
,CashRepayApplySplit${10..49}
(对,你没看错,我司数据库表名是驼峰,字段也是驼峰) - 分表列
memberId
,分表策略memberId
截取后两位% 50
,余数小于10
左边补0
,因为我们表名是两位数字后缀。 - 表结构,和原表结构完全一致,包括字段、索引等。哎等等,发现原表
uptTime
更新时间字段竟然没有索引,这里我们分表需要加上,注意主表不要动,几个亿数据的表不能随便加索引。
历史数据同步
这一点是非常重要的,分了五十张表之后,不仅新的业务数据要根据分表策略落入分表,也要使用手段将存量数据以分表策略优先写入 CashRepayApplySplit0${0..9}
,CashRepayApplySplit${10..49}
。
有一件比较尴尬的事情,我司早几年已经做过一次还款申请单表的数据迁移,最原始的表名是 CashRepayInfo
,四年前迁移后的表名是 CashRepayApply
,也就是当前主业务表,目前我们的业务数据都是进行双写,先写到 CashRepayApply
,然后同步到 CashRepayInfo
,保证 CashRepayInfo
里面是最全的数据,因为虽然 CashRepayApply
是主业务表,但是很多历史业务代码、报表等查询包括外部部分还是使用 CashRepayInfo
的。
这是我们当前的双写方式。但是有一部分最古老的历史数据存在于 CashRepayInfo
,不在 CashRepayApply
。当时迁移的时候没有完全同步,三者的关系如下图
上图可以知道,CashRepayInfo
是全量数据的表,包含 CashRepayApply
,我们的目标是将全量数据同步到分表。所以现在要实现的是
- 先把
CashRepayApply
缺少的那部分最早的CashRepayInfo
数据同步到CashRepayApplySplit
- 将
CashRepayApply
剩余的数据全部同步到CashRepayApplySplit
疑问解答
这里可能会有一个疑问,既然 CashRepayInfo
是全量的数据,为什么不直接从 CashRepayInfo
同步到分表 CashRepayApplySplit
呢?这是因为数据量有两亿多,我们不可能全部让研发用代码同步,那就只能交给 DBA
。但是 DBA
同步的话存在一个问题就是他需要两边表的字段结构一致,但是 CashRepayInfo
和 CashRepayApply
是存在字段差异的,字段名称不同、字段个数也有略微差异。综合考虑之后使用上述方案。
具体细节
我们把下面这张图的两个箭头看做两张表的自增数据,如果要实现上述第一点,就需要找到垂直的黑色虚线与 CashRepayInfo
交点的 id
是多少。 CashRepayInfo
表这个 id
之前的数据就是我们需要使用代码完全同步到 CashRepayApplySplit
的数据。
用这个 id
来找到对应的 CashRepayApply
表的主键 id
。CashRepayApply.id
以后的全部数据就是 DBA
需要帮我们同步的。
现有后台查询改造
目前现在公司的客服/运营后台管理系统全都是用单表去直接 join
的,如果分表之后,肯定没办法再以原来的展示维度去 join
查询了,那么需要定一个方案来解决这个问题。通过与领导沟通,暂定的方案是老表 CashRepayApply
只保留两~三年的数据,这部分数据可以像原来一样不指定 memberId
去 join
查询。
再历史(三年以前)的数据必须通过 memberId
查询,管理系统提供新的查询页面,必填条件 memberId
。
外部部门通知
全量数据同步到分表之后,最老的 CashRepayInfo
逐步等待下线、废弃。所以要和其他部门比如风控、大数据部门沟通,告知他们后续报表等逻辑要用新的表 CashRepayApplySplit
查询,现在可以开始逐步切换了。
DBA 操作过程中新产生业务数据同步方案
前面我们已经定好了同步步骤,第一步是研发自己同步一部分,第二步给到一个起始 id
给 DBA
,DBA
从这个 id
开始同步 CashRepayApply
表剩余的数据到分表。这里有个问题就是 DBA
的结束 id
是不确定的,因为 CashRepayApply
这张表在 DBA
操作同步的过程中一直都有新的业务数据写入。DBA
同学在开始操作之前必须要给定一个结束 id
给到同步工具,但是新业务的一直写入导致 DBA
同步必定会漏一部分数据。
我们总不能为了这个数据同步,停止用户的还款对不对,所以我给 DBA
的方案是,让 DBA
同步的最晚数据是 operatorDate - 5 Day
。筛选数据库 uptTime < 操作时间减去五天
的数据,这样得到一个确定性的结束 id
。当 DBA
操作结束后还会剩一部分刚刚操作过程中产生的最新的业务数据(下图最右边的虚线数据,我色弱不太认识颜色),那这部分数据依然是研发自己用代码同步,等晚上 23:00
关闭还款之后研发用功能代码同步。
这样一来我们所有存量数据就按照创建时间排序,全量的同步到分表了。然后就可以开启三写的开关,完美完成这次数据库分表迁移!
数据三写同步
表的下线需要时间,其他部门改造业务切换分表也需要时间,所以在未来的一段时间内,我们仍然要保证 CashRepayInfo
数据的完整性,我们三张表要同步三写,先写 CashRepayApply
、再写 CashRepayInfo
、再写 CashRepayApplySplit
同步的时候要注意,由于分表的库是不同的数据源,需要声明指定的事务管理器。
1 | @Transactional(propagation = Propagation.REQUIRES_NEW, transactionManager = "transactionManagerSplit") |
另外,不要问我为什么在代码中实时同步数据,而不用一些中间件?比如监听 MySQL
的 binlog
去同步?因为 DBA
告诉我不好实现(我严重怀疑是这个 DBA
小姐姐不想帮我弄……对,我司 DBA
是个小姐姐)那就只能研发自己来了。
同步数据的动态开关
注意我们需要提供一个动态开关去控制开启和关闭新的业务数据从 CashRepayApply
同步到 CashRepayApplySplit
分表,也就是双写的开关,因为需求上线之后肯定是先同步一阶段古老的数据,再同步二阶段 DBA
可以同步的数据,然后三阶段研发同步新产生的部分业务数据,全部完毕之后开启同这个开关完成无缝对接。
最终的目的除了完全完成数据同步之外,还有一点就是让越早的数据越在表的前面。
定时任务扫描
由于我们是在代码中去双写数据到分表,分表数据库是新的实例,和原业务表的的操作不能控制在一个事务中,所以这就有潜在的隐患导致数据写到 CashRepayApply
表,未成功/正确写入到 CashRepayApplySplit
分表。尽管概率很小,我们也要预防。
所以前面在我们双写的时候一定要捕捉写入到分表的异常,确保即使写入分表失败,也不能影响主业务流程。然后每天用定时任务扫描今日产生的还款申请单数据,CashRepayApply
和 CashRepayApplySplit
做比对,是否存在差异字段,如果有,推送告警出来研发排查。
艰难的 Demo 之路
因为公司的项目比较老,shardingsphere
的版本也比较低,为了紧贴社会潮流,这篇文章的 Demo
我是自己选了一个相对比较新的版本 SpringBoot3
去整合,然后呢发现官方有 sharding-jdbc-spring-boot-starter
我就拿过来用了,二话不说直接上了最新版本,我想着最新版本肯定能兼容 SpringBoot3
呀。
然而不出意外的话意外就出现了,启动一直报错,于是我去 github
上找 issue
。 第一次没找到类似的报错。于是我专门提了一个 issue
,第二天我又去看 issue
发现了有人提过的 # Is ShardingSphere 4.1.1 version compatible with Spring Boot 3.0.5 version? 在这里可以看到官方回复说因为 SpringBoot
版本的迭代导致他们为了维护 starter
会消耗很多人力财力。所以关于 shardingsphere
的 spring-boot-starter
从 5.3.0
版本就不更新了。官方推荐使用 5.3.0
以上的版本去适配 SpringBoot3
,并且使用 ShardingSphereDriver
的方式去集成 shardingsphere
。官网也有配置示例。
于是我按照官方的文档引入 Maven
坐标,照着文档配置,但中途还是遇到了很多问题……各种版本兼容问题,我想吐槽一下官网的配置文档,下面会一一列出,这个 Demo
做的还是蛮艰难的!
分表策略 & 代码实现
上一节已经和领导开会评审了我们的设计,领导给出赞赏的目光,设计考虑的很全面很周到!那么接下来我们开始示例代码整合 shardingsphere
完成数据库分表。
各技术组件版本
1 | SpringBoot 3.2.4 + JDK19 + MySQL8 + shardingsphere 5.4.1 + MyBatisPlus 3.5.5 |
Maven 依赖
1 | <dependency> |
application.yml 配置文件
1 | spring: |
sharding-config.yaml 文件
1 | dataSources: |
到这其实配置就结束了,接下来我们写个方法访问数据库
1 | @Mapper |
调用这个方法即可,shardingsphere
会自动帮我们创建分表数据源,路由对应的分表。
实测发现这里 SpringBoot3.2.4
会报以下错
1 | java.lang.NoSuchMethodError: org.yaml.snakeyaml.representer.Representer: method 'void <init>()' not found |
这是因为 jar 包版本问题导致的,从网上看到一个很简单的解决办法,直接把报错的这个类拷贝,然后粘贴到本项目中,包名要和它完全一致,然后添加一个无参构造方法覆盖原 jar 包中的类
1 | public Representer( ) { |
再次启动发现已经可以自动根据 member_id
路由到分表了。
踩坑之路
第一个坑是当前 SpringBoot 3.2.4
版本 Maven
需要引入
1 | <dependency> |
第二个坑是 org.yaml.snakeyaml.representer.Representer
的无参构造不存在
第三个坑是 algorithm-expression
分片表达式的脚本,在使用 sharing-column % 50
的时候可能会报一个错
1 | java.sql.SQLException: Inline sharding algorithms expression `cash_repay_apply_${member_id % 50}` and sharding column `member_id` do not match. |
这是因为 groovy
脚本工具解析这个表达式报错了,断点打过去发现异常里面给我们提示一句话
1 | groovy.lang.MissingMethodException: No signature of method: java.lang.String.mod() is applicable for argument types: (Integer) values: [50] |
猜测可能是 groovy
的解析工具版本有什么升级,去官方 github
下找到了别人提的 issue
官方让用 Long
解析一下 member_id
的类型 。改成下面的写法
1 | algorithm-expression: cash_repay_apply_$->{Long.parseLong(member_id) % 50} |
因为我这里数据库里面 member_id
是 varchar
,我们是后两位对 50
取模,可能会存在小于 10
的数据,所以为了映射表名要在前面补 0
。使用 groovy
脚本就是我上面的配置代码。
Demo 源码下载
Demo
已分享到 github
点击去 GitHub 下载
研发同步数据的代码
上面咱们已经说过了有一部分数据是需要研发自己去同步的,这部分同步数据的代码应该如何写呢。最早我是想用 ForkJoinPool
工具类实现的,因为这种大数据量的分治太适合了。可以参考这篇文章 记录一次发送千万级别数量消息的定时任务优化。
但是考虑到以下三点:
- 我们核销业务交易项目比较古老了,使用的还是架构组提供的自研的定时任务,没法做分片参数
- 核心交易服务不能出现差错,尽量不要在这个项目中过多使用线程池,消耗
CPU
资源 - 尽量让产生的数据落入到分表时,创建时间早的分布在表的前面
最终我还是选择了使用朴实无华的方式,只用一个线程去跑批同步数据,每次跑 500
条,依次循环往下跑,直到结束。
1 | /** |
组装分表实体代码
1 | /** |
结语
回想在进公司之前,面试的时候经常会有问到分库分表的面试官,问的完全不知道怎么回答。因为没有过实际的经验,那时候总感觉分库分表是个很难很难,很高大上的东西。不知道是不是我们公司的分库分表太简单了,实际经历之后发现其实也就是看看官方文档配一些配置,调用 API 即可。
其实自己亲身经历之后才发现这种需求难得根本就不是代码,而是给到我们这样一个需求之后,我们怎样去设计方案。抽象到更大的一个团队业务架构层面、甚至公司级别的业务架构层面,协调外部多部门,保证方案不影响现有业务,又能较好的完成需求。
最后,不管是不是我司分表业务简单,但是至少咱也算有了亿级数据分表经验是不?
Java代码优化奇技淫巧
Java编码优化是一个持续的过程,涉及到代码的可读性、性能、资源使用等多个方面。以下是一些常见的Java编码优化技巧,以及相应的业务场景化案例和代码示例。客官,看完点赞关注收藏起来备孕。
1. 避免不必要的对象创建
业务场景:在处理大量数据的业务场景中,频繁创建对象会增加垃圾收集的负担。
优化前:
1 | public void processLargeCollection(List<String> list) { |
优化后:
1 | public void processLargeCollection(List<String> list) { |
2. 使用合适的数据结构
业务场景:在需要频繁查找、添加、删除操作的场景中,选择合适的数据结构可以提高效率。
优化前:
1 | List<String> list = new ArrayList<>(); |
优化后:
1 | Set<String> set = new HashSet<>(); |
3. 缓存结果
业务场景:对于重复计算且计算成本高的逻辑,可以使用缓存来存储结果。
优化前:
1 | public int getFibonacci(int n) { |
优化后:
1 | public int getFibonacci(int n, Map<Integer, Integer> cache) { |
4. 避免使用全局变量
业务场景:全局变量可能导致不必要的内存占用,并在多线程环境中引发同步问题。
优化前:
1 | public class AppConfig { |
优化后:
1 | public class AppConfig { |
5. 使用局部变量
业务场景:局部变量的生命周期更短,可以减少内存占用。
优化前:
1 | public class Service { |
优化后:
1 | public class Service { |
6. 避免使用过宽的访问权限
业务场景:限制变量或方法的访问权限可以减少不必要的外部访问。
优化前:
1 | public class User { |
优化后:
1 | public class User { |
7. 使用StringBuilder/Buffer
业务场景:在字符串拼接频繁的场景中,使用StringBuilder可以提高性能。
优化前:
1 | String result = ""; |
优化后:
1 | StringBuilder sb = new StringBuilder(); |
8. 避免使用finalize方法
业务场景:finalize方法的执行时机不确定,可能导致资源释放不及时。
优化前:
1 | public class Resource { |
优化后:
1 | public class Resource implements AutoCloseable { |
9. 并发优化
业务场景:在需要处理大量并发请求的场景中,合理使用线程池可以提高系统响应速度。
优化前:
1 | ExecutorService service = Executors.newCachedThreadPool(); |
优化后:
1 | ExecutorService service = Executors.newFixedThreadPool(10); |
10. 避免使用同步代码块
业务场景:在高并发场景中,过度同步会降低性能。
优化前:
1 | synchronized (this) { |
优化后:
1 | Lock lock = new ReentrantLock(); |
11. 利用现代Java的Stream API
业务场景:处理集合数据时,Stream API可以提供更简洁、更高效的代码。
优化前:
1 | List<String> results = new ArrayList<>(); |
优化后:
1 | List<String> results = list.stream() |
12. 减少不必要的类型转换
业务场景:频繁的类型转换会影响性能,特别是涉及到基础数据类型和包装类之间的转换。
优化前:
1 | Integer integerValue = 100; |
优化后:
1 | int intValue = 100; // 使用原始类型 |
13. 使用更精确的数据类型
业务场景:根据实际需要选择最合适的数据类型,避免使用过大的数据类型。
优化前:
1 | Integer count = 0; |
优化后:
1 | int count = 0; // 使用更精确的原始类型 |
14. 避免使用过于复杂的正则表达式
业务场景:正则表达式是强大的字符串处理工具,但过于复杂的正则表达式会严重影响性能。
优化前:
1 | Pattern pattern = Pattern.compile(".*some complex pattern.*"); |
优化后:
1 | Pattern pattern = Pattern.compile("some simpler pattern"); |
15. 避免在循环中使用I/O操作
业务场景:I/O操作通常是非常耗时的,应该避免在循环中进行。
优化前:
1 | for (int i = 0; i < 1000; i++) { |
优化后:
1 | List<String> dataBatch = new ArrayList<>(); |
16. 利用懒加载
业务场景:懒加载可以延迟对象的创建,直到真正需要它们的时候。
优化前:
1 | class MyService { |
优化后:
1 | class MyService { |
17. 避免过早优化
业务场景:过早优化可能会让代码变得复杂且难以维护。
优化建议:
- 首先关注代码的清晰性和正确性。
- 使用性能分析工具确定性能瓶颈。
- 根据性能分析的结果有针对性地进行优化。
最后
V哥想说的是,优化技巧的选择和应用需要根据具体的业务场景和性能瓶颈来决定。在进行优化时,应该首先识别瓶颈所在,然后有针对性地应用优化策略。同时,优化也应该以不牺牲代码的可读性和可维护性为前提。在某些情况下,过度优化可能会使代码变得复杂而难以维护。因此,优化应该是一个权衡的过程,需要根据实际情况进行选择。此外,利用现代Java语言的特性和库,如Stream API、Lambda表达式等,可以编写出更简洁、更高效的代码。