抖快电商业务与京东电商供应链能力之间的连接器,用于承载抖、快京东官方店铺业务。
项目初期为了快速适配业务开发,数据都存储在MySQL中,使用京东数据库中间件团队提供JED弹性库按照店铺维度的做的数据库分片。随着业务快速发展,存储数据越来越多,我们在MySQL面临这如下痛点:
随着达人直播场次和拉新活动的增加,出现部分店铺订单量爆涨,由于当前数据库分片策略是按照店铺维度进行分片,存在数据倾斜,系统吞吐量预估到2000 QPS即达到性能瓶颈。按当前的订单量增长速度,半年内部分店铺的订单量可能超千万级,单表数据量过大。
业务模式变化快,为了快速响应业务需求,表结构经常调整。在对一些数据在百万级别以上的大表做 DDL 的时候,修改的时间较长,对存储空间、IO、业务有一定的影响。
随着订单量增长,部分店铺的订单量超过千万之后,运营端订单列表查询会超时,运营端运营人员经常使用查询近7天、近30天的订单列表数据超时现象增多,运营端查询体验变差,同时订单列表功能导出也耗时严重。
抖音、快手订单明细数据超过6个后,历史订单不再支持查询,需要将抖音、快手订单明细数据落地存储。
要提升系统吞吐量,需要调整数据库分片策略,要调整分片策略,首先要先解决运营端列表业务人员查询问题,所以必须首先且迫切的选择一种存储中间解决来解决列表查询问题。
面对以上痛点,我们开始考虑对订单数据存储的架构进行升级改造,我们根据业务方的诉求和未来数据量的增长,将一些常见数据存储技术方案做来一些对比:
TiDB 具有水平弹性扩展,高度兼容 MySQL,在线 DDL,一致性的分布式事务等特性,符合当前系统数据量大,业务变更频繁,数据保存周期长等场景。结合团队成员知识储备和在不影响业务需求迭代情况下,以较少人工成本完成数据异构和数据库分片键的切换,通过调研发现公司数据库团队提供已TiDB中间件能力和支持,我们经过对 TiDB 的内部测试后,确认可以满足现有业务需求。我们最终选择了 TiDB 做为这类需求的数据存储,并通过数据同步中件件DRC平台完成MySQL异构到TiDB。
除了引入一些分库分表组件,Spring自身提供了AbstractRoutingDataSource的方式,让多数数据源的管理成为可能。同时分库分表组件使用上限制很多,使用之前需要了解去学习使用方法和忍受中间件对SQL的苛刻要求,对比中间件以及当前项目使用的Spring技术栈,反而使用Spring自身提供了AbstractRoutingDataSource的方式能够让代码的改动量尽量的减少。
Spring提供的多数据源能进行动态切换的核心就是spring底层提供了AbstractRoutingDataSource类进行数据源路由。AbstractRoutingDataSource实现了DataSource接口,所以我们可以将其直接注入到DataSource的属性上。
我们主要继承这个类,实现里面的方法determineCurrentLookupKey(),而此方法只需要返回一个数据库的名称即可。
public class MultiDataSource extends AbstractRoutingDataSource {
@Getter
private final DataSourceHolder dataSourceHolder;
public MultiDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
//设置默认数据源,在未指定数据源情况下,则使用默认的数据源访问
super.setDefaultTargetDataSource(defaultTargetDataSource);
//多数据源配置
super.setTargetDataSources(targetDataSources);
this.dataSourceHolder = new DataSourceHolder();
}
@Override
protected Object determineCurrentLookupKey() {
//获取数据源上下文对象持有的数据源
String dataSource = this.dataSourceHolder.getDataSource();
//如果为空,则使用默认数据源resolvedDefaultDataSource
if (StringUtils.isBlank(dataSource)) {
return null;
}
return dataSource;
}
数据源上下文切换存储,使用ThreadLocal绑定这个透传的属性,像Spring的嵌套事务等实现的原理,也是基于ThreadLocal去运行的。所以,DataSourceHolder.本质上是一个操作ThreadLocal的类。
public class DataSourceHolder {
/**
* 保存数据源类型线程安全容器
*/
private final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
/**
* 设置数据源类型
*
* @param dataSource 数据源
*/
public void putDataSource(String dataSource) {
CONTEXT_HOLDER.set(dataSource);
}
/**
* 获取数据源类型
*
* @return
*/
public String getDataSource() {
return CONTEXT_HOLDER.get();
}
/**
* 清空数据源类型
*/
public void clear() {
CONTEXT_HOLDER.remove();
定义数据源配置自定义注解:
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DataSourceAnnotation {
/**
* 数据源名称
*
* @return
*/
String value();
}
多数据源选择AOP切面
@Slf4j
@Aspect
public class MultiDataSourceAspect {
/**
* 多数据源
*/
@Setter
private MultiDataSource multiDataSource;
/**
* 定义切入点
*/
@Pointcut("execution(* com.jd.mkt.oms.mapper.order.*.*(..))")
public void aspect() {
}
/**
* 方法执行前-选择数据具体的数据源并放入到数据源上下文中
*/
@Before("aspect()")
public void beforeExecute(JoinPoint joinPoint) {
if (!(joinPoint.getSignature() instanceof MethodSignature)) {
return;
}
MethodSignature methodSignature = ((MethodSignature) joinPoint.getSignature());
Method method = methodSignature.getMethod();
//选择具体的数据源
selectDataSource(method, joinPoint);
}
/**
* 方法执行后-清空数据源上下文
*/
public void afterExecute(JoinPoint joinPoint) {
getDataSourceHolder().clear();
}
/**
* 选择具体的数据源
*/
private void selectDataSource(Method method, JoinPoint joinPoint, String aspectType) {
//1.获取被aop拦截方法的数据源自定义注解,若有,则使用方法上标注的数据源
DataSourceAnnotation dataSourceAnno = method.getAnnotation(DataSourceAnnotation.class);
String dataSourceStr = "";
if (dataSourceAnno != null) {
dataSourceStr = dataSourceAnno.value();
getDataSourceHolder().putDataSource(dataSourceStr);
return;
}
//2.获取被aop拦截方法所在类上的数据源自定义注解,若有,则使用类上标注的数据源
Class<?> declaringClass = method.getDeclaringClass();
dataSourceAnno = declaringClass.getAnnotation(DataSourceAnnotation.class);
if (dataSourceAnno != null) {
dataSourceStr = dataSourceAnno.value();
log.debug("{}--final method.getDeclaringClass()={}", aspectType, dataSourceStr);
getDataSourceHolder().putDataSource(dataSourceStr);
return;
}
//3.获取被aop拦截方法被代理的目标类上的数据源自定义注解,若有,则使用目标类上标注的数据源
Class<?> targetClass = AopUtils.getTargetClass(joinPoint.getTarget());
dataSourceAnno = targetClass.getAnnotation(DataSourceAnnotation.class);
if (dataSourceAnno != null) {
dataSourceStr = dataSourceAnno.value();
log.debug("{}--final AopUtils.getTargetClass={}", aspectType, dataSourceStr);
getDataSourceHolder().putDataSource(dataSourceStr);
return;
}
//4.获取被aop拦截方法被代理的泛型上的数据源自定义注解,若有,则使用泛型类上标注的数据源,支持tk.mybatis等泛型接口上声明的数据源配置
Type[] genericInterfaces = targetClass.getGenericInterfaces();
if (genericInterfaces.length > 0) {
if (genericInterfaces[0] instanceof Class) {
Class genericInterface = (Class) genericInterfaces[0];
log.debug("genericInterface:{}", genericInterface.getName());
Annotation annotation = genericInterface.getAnnotation(DataSourceAnnotation.class);
if (annotation instanceof DataSourceAnnotation) {
dataSourceAnno = (DataSourceAnnotation) annotation;
dataSourceStr = dataSourceAnno.value();
log.debug("final genericInterface={}", dataSourceStr);
getDataSourceHolder().putDataSource(dataSourceStr);
}
}
}
log.debug("final selectDataSource {}", dataSourceStr);
}
private DataSourceHolder getDataSourceHolder() {
return multiDataSource.getDataSourceHolder();
}
3.1项目中多数据源配置
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="mySqlDataSource" parent="abstractDataSource">
<property name="url" value="${mysql.url}"/>
<property name="username" value="${mysql.username}"/>
<property name="password" value="${mysql.password}"/>
</bean>
<bean id="tiDbDataSource" parent="abstractDataSource">
<property name="url" value="${tidb.url}"/>
<property name="username" value="${tidb.username}"/>
<property name="password" value="${tidb.password}"/>
</bean>
<bean id="orderMultiDataSource" class="MultiDataSource" lazy-init="false">
<constructor-arg index="0" ref="mySqlDataSource"/>
<constructor-arg index="1">
<map>
<entry key="MySQL" value-ref="mySqlDataSource"/>
<entry key="TiDB" value-ref="tiDbDataSource"/>
</map>
</constructor-arg>
</bean>
<bean id="orderMultiDataSourceAspect" class="MultiDataSourceAspect">
<property name="multiDataSource" ref="orderMultiDataSource"/>
</bean>
<bean id="orderTransactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="orderMultiDataSource"/>
</bean>
<!--基于注解进行事物管理-->
<tx:annotation-driven transaction-manager="orderTransactionManager"/>
<bean id="orderSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="orderMultiDataSource"/>
<property name="typeAliasesSuperType" value="com.jd.mkt.oms.infrastructure.po.base.PO"/>
<property name="mapperLocations" value="classpath:sqlmap/order/*.xml"/>
</bean>
<bean class="tk.mybatis.spring.mapper.MapperScannerConfigurer">
<property name="markerInterface" value="com.jd.mkt.oms.infrastructure.mapper.base.ISqlMapper"/>
<property name="sqlSessionFactoryBeanName" value="orderSessionFactory"/>
<property name="basePackage" value="com.jd.mkt.oms.infrastructure.mapper.order"/>
</bean>
</b
3.2 数据访问层Dao类或方法上增加数据源配置注解
dao层接口方法增加数据源选择注解
/**
* 根据店铺、平台、订单号查询订单列表
*
* @param extShopId 店铺id
* @param platform 平台
* @param orderIds 订单号列表
* @return
*/
@DataSourceAnnotation("TiDB")
List<CtpOrderSkuPO> selectOrderList(@Param("extShopId") String extShopId, @Param("platform") int platform, @Param("orderIds") List<String> orderIds);
dao层接口增加数据源选择注解
@DataSourceAnnotation("TiDB")
@Repository
public interface OmsOrderLogMapper {
/**
* 查询订单操作日志列表数据
*
* @param platform
* @param orderId
* @return
*/
List<OmsOrderLogPO> selectOmsOrderLogs(@Param("platform") int platform, @Param("orderId") String orderId);
}
4.1 SCHEMA的KV映射原理
•聚簇表KV的映射规则
假设 Column_1 为 Cluster Index
Key: tablePrefix{ TableID }_recordPrefixSep{ Col1 }
Value: [col2,col3,col4]
•非聚簇表KV的映射规则
Key: tablePrefix{ TableID }_recordPrefixSep{ _TiDb_RowID }
Value: [col1,col2,col3,col4]
KV 存储中Value存储真实的行数据
4.2 唯一索引 & 非聚簇表的主键
Key: tablePrefix{ TableID }_indexPrefixSep{ IndexID }_indexedColumnsValue
Value: RowID
4.3 二级索引
Key: tablePrefix{ TableID }_indexPrefixSep{ IndexID }_indexedColumnsValue_{ RowID }
Value: null
基于TiDB索引和MySQL索引映射原理,根据业务处理特性,业务流程处理中需要根据订单号查询业务数据,运营端列表查询和数据导出根据店铺、订单号、时间等多条件组合完成业务数据查询,我们分别在MySQL中创建订单号索引,在TiDB创建基于店铺+时间d额二级索引和基于订单号的唯一索引。
由于我们项目采用DDD领域驱动设计思想搭建的项目代码结构,所以我们只需要在基层设施层完成分片键的路由键的适配切换即可,并借助DRC平台完成MySQL数据库数据迁移,切换后避免了数据热点倾斜和提升系统处理性能。
1.系统处理性能,根据压测数据,数据库单分片处理QPS约400左右。
2.避免数据倾斜,按订单号分库后,可保证单表数据量在500万以下,数据量在合理区间。
3.运营端列表查询和数据导出运营体验,千万级订单数据量查询性能提升了5倍。
1.对帐数据由原来使用JED直接替换成TiDB
2.抖快历史订单详情数据直接写入TiDB
|