目前国内流行的开源数据库分片实现都基于 SQL 的改写、分发与结果归并。但这种实现存在一些无法避免的缺陷,本文试图说明白这些缺陷的由来及提供一个传统数据库分片实现的新思路。
流行分片框架实现分析
目前国内流行的开源 RDB(Relation Database) 分片框架,无论是在 应用端 进行分片还是利用 中间件 进行分片, 其都基于 SQL 进行,主要的流程是:
- 解析上层传入的 SQL
- 结合对应的分表分库配置,对传入的 SQL 进行改写并分发到对应的单机数据库上
- 获得各个单机数据库的返回结果后,根据原 SQL 归并结果,返回用户原 SQL 期待的结果
这种实现希望从 SQL 层提供一个屏蔽底层分片逻辑的解决方案,对上层应用来说,只有一个 RDB,这样应用可以 透明地访问多个数据库。
然而,这仅仅只是一个美丽的目标。因种种原因,目前流行的开源 SQL 层分片方案无法提供跟原生数据库一样的功能:
- ACID 里的 A(原子性)无法保证
- ACID 里的 C(一致性)可能被打破
- ACID 里的 I(隔离性)与原生不一致
- 由于 SQL 解析复杂,性能等考虑,很多数据库 SQL 不支持
除了上面与原生数据库的差异外,读写分离在基于 SQL 的框架也无法完美实现。
以下我们浅析一下这类基于 SQL 的分片框架不能达到上面要求的原因。
注:
一些经过大量魔改的商业数据库中间件能实现与单机数据库一致的 AC 特性,I 特性也能达到 RR(Reapeatable Read)级别,但其实现较为复杂,大量处理逻辑都迁移到了中间件中,也不开源,本文暂不讨论。
原子性
分片框架如果要保证跨分片的原子性,有两个选择。
2PC(Two-phase Commit)
其利用 WriteAheadLog 及锁之类的阻塞等待机制确保分布式事务的强一致性。
最终一致
其不要求多个分布式系统对于某个跨库事务马上达到一致的状态,只要最终某 个时间点达到一致即可。
若使用 2PC,就像大家都听说的,因其需要同步协调处理数据,维护使用的锁,性能会降低(据说 20% 左右)。
若存在分片节点宕机的情况,会导致与这个分片相关的事务都变慢(其他分片等待这个宕机分片的反馈,超时才回滚),甚至引起雪崩效应,导致整个集群不可用。
同时 2PC 实现复杂度也较高,据我了解目前开源的实现中没有提供 2PC 的事务支持。
最终一致可以实现跨分片大事务的最终的原子性。只要发起方分片能正常运作,那么客户操作就能进行。然而,在 SQL 层实现最终一致是不合适的。
首先,能否使用最终一致是由 业务决定 的,其跟业务设计强耦合;其次基于 SQL 层做最终一致时要求跨分片的 SQL 必须可重复执行(幂等),如
update account set account.money=1000;
而不能是
update account set account.money = account.money + 1000;
同时若未达到最终一致的情况下,读取了记录,并依赖于此进行了判断及记录修改的话,则会产生错误的数据。单机数据库 ACID 的意义荡然无存。
我们至少可以推断出,这类在 SQL 层做的最终一致性 越位 了,在 SQL 层无法优雅的实现最终一致,最终一致是服务层(业务层次)的事情。
因此开源分片框架基本都不保证 A。为了实现跨库的 Update 操作,基于 SQL 的分片框架们创造了一个概念“弱 XA”。其主要实现流程如下:
- 执行 A 分片的 SQL
- 执行 B 分片的 SQL
- 执行 A 库的提交
- 执行 B 库的提交
其可以做到:整体事务 COMMIT 前,若有任何异常,都可完美回滚。
但其存在的缺陷是:当 A 库提交后,B 库失败的话,整个事务就会处于不一致的状态。
当然,交易量少的时候,这个出错的概率就比较少。但交易量变多的话,这里总会因为网络抖动等各种原因产生错误。
一致性
其实一致性是跟原子性有所关联的,只要原子性没能保证,那么一致性肯定是无法保证的。
隔离级别
隔离级别在 SQL92 里定义的是这么四个:脏读、读已提交、可重复读、可序列化。
它们并非无关痛痒的几个数据库参数。隔离级别不一样,对应的在我们的程序里要实现业务一致性的代码逻辑也不一样。
然而基于 SQL 的分片框架在跨分片访问时提供的事务隔离级别并非原生数据库里定义的,而是一个新的,未被广泛理解认可的事务隔离级别。
程序员如果没能领悟这个区别,程序执行过程中很有可能就会出现程序员自己也无法理解的错误。
SQL 兼容性
由于 SQL 解析的复杂性及性能等因素,基于 SQL 的分片框架有很多 SQL 的解析是不支持的。并且由于 SQL 解析确实很复杂,对于其声明的支持的 SQL 存在 BUG 的可能性也很大。
至于具体哪些 SQL 不支持,本文就不再分析,大家可以阅读相关框架文档得知。
读写分离
读写分离并非原生数据库里支持的功能,但一个较好的读写分离实现应该是能在读库里跑读事务的。
然而很多人,认为进行只读操作时,加事务是没意义的。如果有这么认识的人,我觉得可能并没有透彻理解 ACID 中的 I。
举一个例子,一个界面上,需要展示客户的余额以及他的转账记录。如果余额跟转账记录的查询不在一个 RR 级别的事务里进行的话,那么查询出来的结果就有可能出现余额跟转账记录对不上的情况!
如果是客户 UI 查询还好,刷新一下就好了。但如果是为了生成对账文件,那不就出现大问题了?
然而,在基于 SQL 层的分片实现中,是没有办法做到完美的读写分离的。因其无法知道这个事务究竟是读事务,还是写事务,因此在第一条 SQL 执行时无法确定要走读库还是写库。
因此,在基于 SQL 层的分片框架中的做法基本都是:
- 若有事务,直接走写库
- 若无事务,直接走读库,若后续发生了 Update 操作,则线程后续操作都走写库
由此可见,这种读写分离,是不完善的,要实现 RR 级别的读,只能走主库,或者只能返回一个隔离级别混乱的数据集。
分片新思路――服务层分片
正因为基于 SQL 的分片框架提供的 ACID 特性与原生的不一样,所以,在上层应用 SQL 编码过程中,必须明确经过分片框架封装后的 ACID 特性是怎样的,并根据该框架的特性,写出对应调整后的 SQL 才能得到正确可靠的代码逻辑。分片框架的特性一直影响着 SQL 的实际形式。
因此,基于 SQL 的分片方案对应用层并不透明。
如果要基于 SQL 层的框架写出正确可靠的代码的话,大多数情况下需要遵循的一条原则是:所有事务(包括读、写)都不能跨库。(某些对隔离级别要求不是很高的业务可以允许读跨库)
如果要遵循上面的指导原则的话,那么,实际上,大多数情况下,我们需要的分片框架提供的仅仅只是选择一个合适的数据源而已。
结合之前关于读写分离的分析,如果分片实现改在 Service 层,可以清晰地看到会有以下两点好处:
- Service 层有明确的读 / 写事务信息,能实现理想的 RR 级别读写分离
- Service 层的入参里就有供选择分片的信息,无需经过复杂的 SQL 解析就可以选定分片,完成我们对分片框架的绝大多数需求
基于以上两点好处,本人尝试编写了一个基于 Service 层的分片框架,以下展示本人在 Service 层实现分片的一个框架的使用方法,后续我们将更详细地分析该实现的优缺点。
基本使用方法
Service 层
@Service @ShardingContext(dataSourceSet="orderSet", shardingKeyEls="[user].userId", shardingStrategy="@modUserId", generateIdStrategy="@snowflaker", generateIdEls="[user].userId") public class UserServceImpl { @Autowired private UserDaoImpl userDao; @Transactional @SelectDataSource public void updateUser(User user){ userDao.updateUser(user); } @Transactional @SelectDataSource @GenerateId public void saveUser(User user){ userDao.saveUser(user); } @Transactional(readOnly=true) @SelectDataSource(keyNameEls="[userId]") public User findUser(int userId){ return userDao.findUser(userId); } public List<User> findAllUsers(){ return userDao.findAllUsers(); } public double calcUserAvgAge(){ List<User> allUsers = userDao.findAllUsers(); return allUsers.stream().mapToInt(u->u.getAge()) .average().getAsDouble(); }
@ShardingContext 表示当前的 Service 的分片上下文,就是说,如果有选择数据源、Map 到各数据库 Reduce 出结果、生成 ID 等操作时,如果某些参数没有指定,都从这个 ShardingContext 里面的配置取
@SelectDataSource 表示为该方法内执行的 SQL 根据分片策略选择一个分片数据源,在方法结束返回前,不能更改分片数据源
@GenerateId 表示生成 ID,并将其赋值到参数的指定位置
@GenerateId 对应的逻辑会先执行,然后到 @SelectDataSource 然后到 @Transaction
@Transactional(readOnly=true) 标签指定了事务是只读的,因此框架会根据 readOnly标志自动选择读库(如果有的话)
从方法 calcUserAvgAge 可以看到在 JDK8 的 LAMBADA 表达式及 Stream 功能下,JAVA 分析处理集合数据变得极为简单,复杂度基本与 SQL 语句一致,这会大大减少我们自行加工分片数据的复杂度。
DAO 层
@Component public class UserDaoImpl { @Autowired private JdbcTemplate jdbcTemplate; public void updateUser(User user){ int update = jdbcTemplate.update ("UPDATE `user` SET `name`=? WHERE `user_id`=?;",user.getName(),user.getUserId()); Assert.isTrue(update == 1,"it should be updated!"); } public User findUser(int userId){ return jdbcTemplate.queryForObject ("SELECT * FROM user WHERE user_id = ?", new Object[]{userId}, rowMapper); } @Transactional @MapReduce public List<User> findAllUsers(){ return jdbcTemplate.query ("SELECT * FROM user", rowMapper); } @Transactional(readOnly=true) @MapReduce public void findAllUsers (ReduceResultHolder resultHolder){ List<User> shardingUsers = jdbcTemplate.query ("SELECT * FROM user", rowMapper); resultHolder.setShardingResult(shardingUsers); } }
@MapReduce 表示该方法将会在每个数据分片都执行一遍,然后进行数据聚合后返回。
对于聚合前后返回的数据类型一致的方法,调用时可以直接从返回值取得聚合结果。
对于聚合前后返回的数据类型不一致的方法,需要传入一个对象 ReduceResultHolder,调用完成后,通过该对象获得聚合结果。
默认情况下,框架会提供一个通用 Reduce 策略,如果是数字则累加返回,如果是Collection 及其子类则合并后返回,如果是 MAP 则也是合并后返回。
如果该策略不适合,那么用户可自行设计指定 Reduce 策略。
@Transaction 表示每一个分片执行的 SQL 都处于一个事务中,并不是表示整个聚合操作是一个整体的事务。所以,MapReduce 最好不要进行更新操作(考虑框架层次限制 MapReduce 只允许 ReadOnly 事务,目前仍没有做限制)。
@MapReduce 执行的操作会在 @Transaction 之前。
优缺点分析
针对上述实现,我们可以分析一下如此实现的优缺点。
优点
-
框架实现简单,无 SQL 解析改写等复杂逻辑,BUG 理论上更少,也容易排查 BUG
个人认为这至为重要,至少这个简单的框架在使用者的可控范围内。
-
全数据库、全 SQL 兼容
SQL 层分片无法做到。
-
能完美实现读写分离
Service 层分片在 Service 开始前就能确定该事务是读事务,整个读事务都在一个读库中完成,隔离级别与数据库一致,能实现完美 RR 级别读写分离。
-
使用注解完成分片,无业务逻辑入侵
如果注解也算的入侵的话,那么 Spring 就彻底浸入了我们的业务代码里了。
使用注解能显式的提醒程序员及 REVIEWER,这是在访问单库还是跨库访问,而不像基于 SQL 的分片,隐式完成了跨库这一不安全的操作。
-
隔离级别及事务原子性等特征与使用的数据库一致,无额外学习负担,易于写出正确的程序
框架限制了所有事务都在单库进行。
-
无额外维护 DBProxy 可用性的负担
-
无 SQL 解析成本,性能更高
缺点
-
跨库查询需要自行进行结果聚合
- 是劣势也是优势
- 劣势:需要完成额外的聚合代码
- 优势:显式提示程序员这是聚合操作,且其能能更好地调优, 使用 JDK8 的 Stream 及 Lambada 表达式,能像写 SQL 一样简单地完成相关集合处理。同时合理设计的代码里,聚合操作应该只存在于少量的非核心操作中,因此不会增加太多的工作量。聚合计算出结果这部分内容实际上是可以上升成 Service 层的逻辑的(若不考虑严格的领域设计模型那一套的话)。
-
跨库事务需要自行保证
- 是劣势也是优势
- 劣势:需要额外自行实现跨库事务
- 优势:目前所有的开源分片框架实现的跨库事务都有缺陷或者说限制。因此自行采用显式的事务控制,结合业务实现最终一致性或许是更好的选择。可参考使用本人写的另外一个框架 EasyTransaction。
-
无法实现单库分表
-
其实,单库分表并不是必须的,这可以用数据原生的表分区来实现,性能一样,使用更便捷
-
如果你拒绝使用数据库原生的表分区,而使用改写表名的形式做库内分片时,你最好能清楚意义在哪里,而不是想当然。
结语
实际上,我对基于 SQL 进行分片的框架并非拒绝,我也衷心佩服对这些开源框架做出贡献的大神们,此文仅仅给大家一些新的分片思路。若本文能给大家一些小小的启发,那么希望大家能帮忙给我的两个项目加加星,感谢。
作者介绍
许德佑,中信银行信用卡中心任云平台架构师,曾任职于招行软件中心(招银科技)、齐牛互联网金融。一直是金融码农,技术方面较为擅长微服务,分布式事务,数据分片,数据一致性理论等。
感谢雨多田光对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论