1 理论知识
1.1 分库分表是否必要
第一是分库分表方案本身具有的复杂性。第二是本地事务失效问题,原本在同一个数据库中可以保证强一致性业务逻辑,分库之后事务失效。第三是难以聚合查询问题,因为分库分表后查询条件中必须带有shardingKey,所以限制了很多查询场景。
删是指删除历史数据并进行归档。换是指不要只使用数据库资源,有些数据可以存储至其它替代资源。分是指读写分离,增加多个读实例应对读多写少的互联网场景。拆是指分库分表,将数据分散至不同的库表中减轻压力。异指数据异构,将一份数据根据不同业务需求保存多份。热是指热点数据,这是一个非常值得注意的问题。
1.2 分库分表两大维度
1.2.1 纵向拆分
纵向拆分就是按照业务拆分,我们将电商数据库拆分成三个库,订单库、商品库。支付库,订单表在订单库,商品表在商品库,支付表在支付库。这样每个库只需要存储本业务数据,物理隔离不会互相影响。
1.2.2 横向拆分
这时我们就要对数据表进行横向拆分,所谓横向拆分就是根据某种规则将单库单表数据分散到多库多表,从而减小单库单表的压力。
(1 范围分片
如果我们选择的ShardingKey是订单创建时间,那么分片策略是拆分四个数据库,分别存储每季度数据,每个库包含三张表,分别存储每个月数据:
(2 查表分片
查表法是根据一张路由表决定ShardingKey路由到哪一张表,每次路由时首先到路由表里查到分片信息,再到这个分片去取数据。我们分析一个查表法思想应用实际案例。
SLOT = CRC16(key mod 16384
一个key请求过来怎么知道去哪台Redis节点获取数据?这就要用到查表法思想:
(1 客户端连接任意一台Redis节点,假设随机访问到节点A
(2 节点A根据key计算出slot值
(3 每个节点都维护着slot和节点映射关系表
(4 如果节点A查表发现该slot在本节点,直接返回数据给客户端
(5 如果节点A查表发现该slot不在本节点,返回给客户端一个重定向命令,告诉客户端应该去哪个节点请求这个key的数据
(6 客户端向正确节点发起连接请求
查表法方案优点是可以灵活制定路由策略,如果我们发现有的分片已经成为热点则修改路由策略。缺点是多一次查询路由表操作增加耗时,而且路由表如果是单点也可能会有单点问题。
(3 哈希分片
db_index = 100 % 4 = 0
第二步确定路由到哪一张表:
table_index = 100 % 3 = 1
第三步数据路由到0号库1号表:
2 分库分表准备工作
2.1 计算库表数量
分几个库和几张表是在分库分表工作开始前必须要回答的问题,我们首先看看阿里巴巴开发手册的建议:单表行数超过500万行或者单表容量超过2GB才推荐进行分库分表,如果预计3年后数据量根本达不到这个级别,请不要在创建表时就分库分表。
日增量60万计算3年后数据总量:
三年数据总量 = 60 * 365 * 3 = 65700
随着后续业务发展日增量会超过60万,所以我们要对数据总量进行冗余,冗余指数是多少根据业务情况而定,本文按照3倍冗余:
三年数据总量三倍冗余 = 65700 * 3 = 197100
按照单表500万并向上取整至2的幂次计算表数量
表数量 = 197100 / 500 = 394.2 向上取整 = 512
所有表放在一个库并不合适,因为随着数据量增大,访问并发量也会呈正相关增大,一个数据库实例是难以支撑的。本文按照一个数据库实例包含32张表计算库数量:
库数量 = 512 / 32 = 16
2.2 shardingKey
确定shardingKey非常关键,因为作为分片指标,当数据拆分至多个库表之后,代理层只能根据shardingKey进行表路由。假设我们设置了userId作为shardingKey,那么后续DML操作都必须包含userId字段。但是现在有一种场景只有orderId作为查询条件,那么我们应该如何处理这种场景呢?
订单号 = 毫秒数 + 版本号 + userId后六位 + 全局序列号
第二种方案是数据异构,核心思想是以空间换时间,一份数据根据不同维度存储到多个数据介质,数据异构一般分为如下类型。
数据异构至ES:如果每一个维度都新建一个数据库实例也是不现实的,所以我们可以将数据同步至ES满足多维度查询需求。
现在又引出一个新问题,业务不可能每次都将数据写入多个数据源,这样会带来性能问题和数据一致性问题,所以需要一个管道进行各数据源之间同步,阿里开源的canal组件可以解决这个问题。
3 分库分表实例
3.1 停服拆分
停服是指停止服务,系统不再接收新业务数据,那么旧数据在分库分表这个时间段内是静止不变的,数据全部变为了存量数据。停服拆分一般分为三个阶段。
3.2 不停服拆分
第一阶段首先编写代理层和新DAO,代理层通过开关决定访问旧表还是新表,此时流量还是全部访问旧表:
不停写旧表有两个原因:第一是因为如果读新表出现问题,还可以将读流量切回旧表。第二是因为可以进行数据校对,例如新表和旧表数据都同步至Hive,选取几天的数据进行校对,从而验证数据同步的准确性。
3.3 代理层实现
// 订单数据对象
public class OrderDO {
private String orderId;
private Long price;
public String getOrderId( {
return orderId;
}
public void setOrderId(String orderId {
this.orderId = orderId;
}
public Long getPrice( {
return price;
}
public void setPrice(Long price {
this.price = price;
}
}
// 旧DAO
public interface OrderDAO {
public void insert(OrderDO orderDO;
}
// 业务服务
public class OrderServiceImpl implements OrderService {
@Resource
private OrderDAO orderDAO;
@Override
public String createOrder(Long price {
String orderId = "orderId_123";
OrderDO orderDO = new OrderDO(;
orderDO.setOrderId(orderId;
orderDO.setPrice(price;
orderDAO.insert(orderDO;
return orderId;
}
}
引入新数据源访问对象:
// 新数据对象
public class OrderNewDO {
private String orderId;
private Long price;
}
// 新DAO
public interface OrderNewDAO {
public void insert(OrderNewDO orderNewDO;
}
适配器模式减少业务代码侵入性:
// 代理层
public class OrderDAOProxy implements OrderDAO {
private OrderDAO orderDAO;
private OrderNewDAO orderNewDAO;
public OrderDAOProxy(OrderDAO orderDAO, OrderNewDAO orderNewDAO {
this.orderDAO = orderDAO;
this.orderNewDAO = orderNewDAO;
}
@Override
public void insert(OrderDO orderDO {
if(ApolloConfig.routeNewDB {
OrderNewDO orderNewDO = new OrderNewDO(;
orderNewDO.setPrice(orderDO.getPrice(;
orderNewDO.setOrderId(orderDO.getOrderId(;
orderNewDAO.insert(orderNewDO;
} else {
orderDAO.insert(orderDO;
}
}
}
// 业务服务
public class OrderServiceImpl implements OrderService {
@Resource
private OrderDAO orderDAO;
@Resource
private OrderNewDAO orderNewDAO;
@Override
public String createOrder(Long price {
String orderId = "orderId_123";
OrderDO orderDO = new OrderDO(;
orderDO.setOrderId(orderId;
orderDO.setPrice(price;
new OrderDAOProxy(orderDAO, orderNewDAO.insert(orderDO;
return orderId;
}
}
4 文章总结
分库分表具有三个必须面对的问题:方案本身复杂性、本地事务失效问题、难以聚合查询问题,所以分库分表方案并非解决海量数据问题的首选。
5 延伸阅读
一种简单可落地的分布式事务方案
面试官问单表数据量大是否必须分库分表