最近一年一直在用ShardingSphere做分库分表,踩了不少坑,今天来总结一下。
ShardingSphere是目前比较主流的分库分表中间件,前身是Sharding-JDBC,后来进入Apache孵化器,功能越来越强大。但是真正用起来,会发现有很多坑,有些是配置问题,有些是SQL兼容问题,有些是性能问题,还有一些是分布式事务的问题。
今天这篇文章就来聊聊我们项目中遇到的那些坑,以及对应的解决方案,希望能给正在用或者准备用ShardingSphere的朋友一些参考。
一、先说说我们的使用场景
先简单介绍一下我们的使用场景。我们是一个电商平台,随着业务增长,订单表数据量越来越大,单表到了上亿条,查询越来越慢,加索引也要锁表很久,影响线上业务。所以我们决定做分库分表,选型的时候对比了几个方案,最终选择了ShardingSphere。
主要原因有几个:第一,它是客户端分片,不需要额外部署代理层,运维成本低;第二,它兼容大部分MySQL语法,对业务代码侵入小;第三,社区活跃,文档比较全,遇到问题能找到解决方案;第四,支持分布式事务,虽然还不够完美,但基本能用。
我们的分片方案是分4个库,每个库8张表,一共32张表,分片键是用户ID,用的是取模算法。用了一年多,整体还算稳定,但是中间也踩了不少坑,接下来一个个说。
二、坑一:分片键查询和非分片键查询性能差异巨大
第一个坑,也是最常见的坑,就是分片键查询和非分片键查询的性能差异巨大。
我们的分片键是用户ID,所以只要查询条件里带了用户ID,ShardingSphere就能直接路由到对应的库和表,查询速度很快,和单表差不多。但是如果查询条件里没有用户ID,比如按订单号查询,或者按时间范围查询,ShardingSphere就会把所有32张表都查一遍,然后把结果合并,性能就会很差。
比如我们有一个按订单号查询的接口,最开始没注意,上线之后发现这个接口特别慢,排查了很久才发现,因为订单号不是分片键,所以每次查询都要扫32张表,数据量一大就慢得不行。
解决方案有几个:第一,如果某个非分片键的查询很频繁,可以做一张映射表,把非分片键和分片键的对应关系存起来,查询的时候先查映射表拿到分片键,再用分片键查主表。比如我们的订单号查询,就做了一张订单号到用户ID的映射表,查询的时候先查映射表,拿到用户ID,再用用户ID查订单表,性能提升了很多。
第二,如果是按时间范围的查询,可以把时间作为分片键的一部分,或者做二级分片,比如先按时间分库,再按用户ID分表,这样按时间查询的时候就能只查部分库。不过这种方案比较复杂,我们没有用。
第三,如果非分片键的查询不多,而且对性能要求不高,可以接受全表扫描,但是要加缓存,避免频繁查询。
所以在做分库分表之前,一定要梳理清楚所有的查询场景,确定哪些是分片键查询,哪些是非分片键查询,提前做好方案,不要等上线了才发现性能问题。
三、坑二:分页查询的坑
第二个坑是分页查询,这个坑很多人都踩过。
在单库单表的时候,分页查询很简单,用limit offset, size就行。但是分库分表之后,分页查询就变得复杂了,因为数据分散在多个库和表里,ShardingSphere需要把所有表的对应页数据都查出来,然后在内存里排序合并,再返回对应的页。
比如你要查第100页,每页10条,ShardingSphere需要在每个表都查前1000条,然后把所有表的数据合并排序,再取第991到1000条。如果有32张表,就要查32000条数据在内存里排序,性能很差,而且很耗内存。
更坑的是,如果你用的是limit 100000, 10这种深分页,性能就更差了,甚至可能把内存撑爆。
我们最开始没注意这个问题,上线之后有一个后台管理的分页查询,翻到后面几页就特别慢,有时候还会超时。排查之后发现就是这个原因。
解决方案有几个:第一,限制最大翻页数,比如最多只能翻到100页,避免深分页。大部分用户也不会翻到那么后面,后台管理系统可以加这个限制。
第二,用游标分页,也就是用上一页的最后一条数据的ID作为查询条件,比如where id > 上一页最后一个id limit 10,这样每次只查一页的数据,性能很好。不过这种方式不支持跳页,只能一页一页翻,适合一些不需要跳页的场景。
第三,如果必须支持跳页而且页数很大,可以做一些优化,比如先查所有表的主键,排序后取对应页的主键,再用主键回表查数据,这样比直接查所有字段性能好一些。
所以分库分表之后,分页查询一定要特别注意,不要想当然地用limit,要根据实际场景选择合适的方案。
四、坑三:分布式事务的坑
第三个坑是分布式事务,这个坑最大,也最头疼。
分库分表之后,原来的单库事务变成了分布式事务,因为一个事务可能涉及多个库。ShardingSphere支持两种分布式事务:XA事务和柔性事务(BASE)。
我们最开始用的是XA事务,因为它强一致,用起来也简单,和单库事务差不多,业务代码不用改太多。但是用了一段时间发现问题很多:第一,性能差,XA事务需要两阶段提交,比单库事务慢很多,高并发下性能下降明显;第二,容易出现锁等待,因为XA事务在准备阶段会锁住资源,如果某个库响应慢,其他库的锁也不会释放,容易导致阻塞;第三,有些情况下会出现事务悬挂,也就是事务状态不一致,需要人工处理。
后来我们换成了柔性事务,用的是ShardingSphere自带的SAGA柔性事务。柔性事务的优点是性能好,没有长时间的锁,因为它是最终一致性,不是强一致。但是缺点也很明显:第一,开发成本高,需要为每个事务写补偿逻辑;第二,一致性是最终的,中间有短暂的不一致窗口,有些业务不能接受;第三,调试和排错困难,因为事务是异步的,出了问题不好排查。
我们现在的方案是,大部分场景用柔性事务,因为性能好,对于一致性要求特别高的场景,比如支付,还是用XA事务,或者尽量避免跨库事务。
这里有一个很重要的经验:尽量避免跨库事务。在设计分片方案的时候,要尽量把相关的数据放在同一个库里,比如同一个用户的订单、支付记录、收货地址都放在同一个库,这样大部分事务都是单库事务,只有少数场景需要跨库事务。我们最开始分片方案设计得不好,导致很多事务都跨库,后来花了很大力气才调整过来。
所以分布式事务是分库分表最大的坑,在做分库分表之前一定要仔细评估,尽量设计好分片方案,减少跨库事务。
五、坑四:SQL兼容性的坑
第四个坑是SQL兼容性,ShardingSphere虽然兼容大部分MySQL语法,但是还有一些不支持或者支持不好的地方。
我们遇到的主要有这几个:
第一,子查询支持不好。有些复杂的子查询,尤其是嵌套子查询,ShardingSphere解析不了,或者解析了但是执行计划不对,性能很差。比如我们有一个统计查询,用了三层嵌套子查询,在单库的时候跑得好好的,分库分表之后要么报错要么特别慢。解决方案是尽量把子查询改成join,或者拆成多个简单查询在应用层做处理。
第二,聚合函数的问题。count、sum、avg这些聚合函数,ShardingSphere是支持的,但是如果group by的字段不是分片键,就需要把所有表的数据都查出来在内存里分组,性能很差。而且avg函数需要特别注意,因为不能直接把各个表的avg再平均,需要sum和count分别合并再计算,ShardingSphere内部是处理了的,但是如果自己写SQL的时候不注意可能会出错。
第三,order by的问题。如果排序字段不是分片键,同样需要把所有表的数据都查出来在内存里排序,性能差。而且如果排序字段和分页一起用,就是前面说的分页问题,性能更差。
第四,一些MySQL的特殊语法不支持,比如insert into ... select ...,在某些版本里不支持,或者支持但是有问题。还有存储过程、触发器、函数这些,ShardingSphere是不支持的,因为它是客户端分片,无法在数据库端执行这些。
第五,跨库join的问题。如果两个表在不同的库,join查询就很麻烦,ShardingSphere会把两个表的数据都查出来在内存里做join,性能很差,而且大表join可能会内存溢出。解决方案是尽量把需要join的表放在同一个库,或者在应用层做join,或者做数据冗余。
所以在做分库分表之前,一定要把所有的SQL都梳理一遍,看看有没有ShardingSphere不支持的,或者支持不好的,提前改造。我们最开始就是没梳理全,上线之后才发现有些SQL跑不了或者跑得慢,又临时改,很被动。
六、坑五:主键生成的坑
第五个坑是主键生成。分库分表之后,自增主键就不能用了,因为每个表的自增ID都是从1开始的,会重复。
ShardingSphere提供了几种主键生成策略:雪花算法(Snowflake)、UUID、以及用户自定义。我们最开始用的是雪花算法,因为它生成的ID是趋势递增的,性能也好,而且是数字类型,查询性能比UUID好。
但是用了一段时间发现雪花算法有坑:第一,时钟回拨的问题。如果服务器的时钟回拨了,雪花算法可能会生成重复的ID,或者生成失败。ShardingSphere做了一些处理,比如等待时钟追上来,但是如果回拨太多还是会有问题。我们遇到过一次服务器NTP同步导致时钟回拨,结果生成了重复ID,还好有唯一索引兜底,但是也导致了一段时间的写入失败。
第二,worker ID的配置问题。雪花算法需要每个实例配置不同的worker ID,如果两个实例配置了相同的worker ID,就可能生成重复ID。我们最开始用的是配置文件写死worker ID,后来扩容的时候忘了改,导致新实例和旧实例worker ID重复,生成了重复ID。后来改成了用IP或者注册中心自动分配worker ID,才解决这个问题。
第三,雪花算法生成的ID太大,是64位的长整型,前端JavaScript处理的时候会有精度丢失的问题,因为JavaScript的数字精度是53位。我们遇到过前端拿到的ID和数据库里的不一样,就是因为这个原因。解决方案是后端把ID转成字符串返回给前端,或者用自定义的ID生成策略生成短一些的ID。
后来我们把主键生成改成了号段模式,也就是用一个号段表,每次从数据库取一个号段,在内存里分配,用完了再取下一个。这种方式的优点是ID是连续递增的,而且不依赖时钟,没有时钟回拨的问题,性能也很好。缺点是需要额外的号段表,而且如果号段分配不合理可能会有热点问题。不过总体来说比雪花算法稳定,我们现在用的就是这个方案。
所以主键生成也是一个坑,不要想当然地用自增ID或者雪花算法,要根据实际情况选择合适的方案,并且考虑各种异常情况。
七、坑六:数据迁移的坑
第六个坑是数据迁移,也就是从单库单表迁移到分库分表,这个过程也很容易出问题。
我们最开始想的是停机迁移,也就是找一个凌晨业务低峰期,停服,然后把数据导出来,按照分片规则导入到分库分表中,然后切换。但是我们的数据量太大,有上亿条,停机迁移需要好几个小时,业务不能接受这么长时间的停机。
后来我们改成了双写迁移,也就是先把分库分表的环境搭好,然后应用层同时写旧库和新库,读还是读旧库。同时用一个数据同步工具把旧库的历史数据同步到新库。等历史数据同步完,并且双写一段时间数据一致之后,再把读切到新库,然后观察一段时间,没问题了再停掉旧库的写入。
这个方案的优点是不停机,业务无感知。但是坑也很多:第一,双写的一致性问题。因为同时写两个库,如果一个成功一个失败,就会数据不一致。我们的方案是写旧库成功就返回成功,新库的写入异步做,如果失败了记录日志人工补偿,因为旧库是主,新库是从,最终一致就行。
第二,历史数据同步的一致性。我们用的是Canal监听旧库的binlog做增量同步,同时做全量同步。但是全量和增量衔接的时候容易出问题,比如全量同步到一半的时候有新的写入,增量同步可能会重复或者遗漏。我们的方案是先记录一个binlog位置点,然后做全量同步,全量同步完之后从记录的位置点开始做增量同步,这样就能保证不重复不遗漏。
第三,数据校验。迁移完之后一定要做数据校验,确保新旧库的数据一致。我们写了一个校验程序,分批对比新旧库的数据,包括数据量和每条数据的内容,发现不一致就修复。这个过程花了不少时间,但是很有必要,不然迁移完了数据不一致就麻烦了。
第四,切换的时候要灰度。不要一下子全量切到新库,先切一小部分流量,观察一段时间没问题了再逐步增加,最后全量切换。我们最开始差点直接全量切,还好有人提醒,先切了10%观察,发现了一个小问题,修复了之后才全量切,不然后果不堪设想。
所以数据迁移是分库分表过程中风险最高的环节,一定要做好方案,做好回滚准备,做好数据校验,灰度切换,不能掉以轻心。
八、坑七:运维和监控的坑
第七个坑是运维和监控。分库分表之后,数据库从一个变成了几十个,运维复杂度大大增加。
最明显的就是备份和恢复。原来一个库备份很简单,现在几十个库都要备份,而且恢复的时候要按分片规则恢复,很麻烦。我们最开始还是用原来的备份脚本,结果发现恢复的时候数据对不上,因为没有考虑分片的问题。后来专门写了分库分表的备份恢复脚本,并且定期做恢复演练,确保备份可用。
然后是监控。原来监控一个库就行,现在要监控几十个库,每个库的连接数、慢查询、主从延迟、磁盘使用率等等,监控项翻了几十倍。我们最开始监控没跟上,有一次某个库的磁盘满了导致写入失败,过了好久才发现。后来我们把所有库的监控都加上了,并且做了汇总大盘,能一眼看到所有库的状态,设置了合理的告警阈值,才解决这个问题。
还有是DDL的问题。分库分表之后,加索引、加字段这些DDL操作要在几十个库上执行,很麻烦,而且如果用alter table直接改,大表会锁表很久,影响业务。我们的方案是用gh-ost或者pt-online-schema-change这种在线DDL工具,写了一个批量执行的脚本,能在所有库上并行执行DDL,并且监控执行进度。即便如此,每次DDL还是要小心翼翼,选在业务低峰期执行,并且做好回滚准备。
还有是慢查询排查。原来慢查询只有一个库的,现在几十个库都可能有慢查询,而且有些慢查询是因为ShardingSphere路由到了多个表导致的,不是单个库的问题,排查起来很麻烦。我们的方案是在应用层加了SQL日志,记录每个SQL的路由情况和执行时间,并且和数据库的慢查询日志关联起来,这样排查的时候能看到完整的链路。
所以分库分表之后运维成本会大大增加,在做分库分表之前一定要评估运维能力,做好相应的工具和监控,不然上线之后会很被动。
九、一些建议和总结
说了这么多坑,最后给一些建议:
第一,不要为了分库分表而分库分表。很多时候单库单表加索引、加缓存、读写分离就能解决问题,不需要分库分表。分库分表会带来很多复杂度,只有当数据量真的很大,其他方案都解决不了的时候再考虑。
第二,如果确定要分库分表,一定要提前做好规划。包括分片键的选择、分片算法、分片数量、查询场景梳理、SQL兼容性检查、分布式事务方案、数据迁移方案、运维监控方案等等,都要提前想清楚,不要等上线了才发现问题。
第三,分片键的选择是重中之重。分片键选得好,大部分查询都是单库单表查询,性能好,事务也少。分片键选得不好,到处都是跨库查询和跨库事务,性能差,维护成本高。选择分片键的时候要结合业务场景,尽量让大部分查询都能带分片键,尽量让相关的数据在同一个库。
第四,尽量减少跨库事务和跨库查询。这是分库分表性能和稳定性的关键,在设计分片方案的时候就要考虑,业务设计的时候也要注意。
第五,做好灰度和回滚。不管是上线分库分表,还是后续的变更,都要灰度,做好回滚准备,不要一下子全量上,出了问题就麻烦了。
第六,持续优化。分库分表不是一劳永逸的,随着业务发展,数据量增长,查询场景变化,可能需要调整分片方案,优化SQL,优化配置,要持续关注和优化。
总的来说,ShardingSphere是一个不错的分库分表中间件,功能强大,社区活跃,但是用起来确实有不少坑。不过只要提前做好规划,避开这些坑,还是能很好地支撑业务的。
希望这篇文章能给大家一些参考,少踩坑,少走弯路。也欢迎大家在评论区分享自己使用ShardingSphere的经验和遇到的坑,一起交流讨论。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录