途牛原创库存系统-有效数据和无效数据分离

本次项目的实现过程就是遇到问题和解决问题的过程,因此本次分享的形式,并不想像通常那样——列出提纲1,2,3……的一种贯宣模式,而是想用一种启发式即用问题+方法的讨论方式进行 问题 •1. 随着库存系统业务量的增长,库存DB数据量也越来越大,DB压力上升,影响对于外系统的响应表现,如价格中心,确认管理等。 •2. 产品工程师根据业务人员要求,需要系统可以查询1年之内(当前日期之前365天)的数据。而之前为了尽量减小DB中单个表的存储空间,归档完成后只能查询90天的数据。 方法 •1.从应用代码上进行改进:(1)压缩合并非控位(FS,现询的)的批次数量,对于属性相同的资源(如资源id,供应商,成本价,団期,清位时间等相同的资源)作为同一个批次,以后只增减批次数量,不再新增批次记录【zhuganggang——已上线】;(2)减少对DB访问, 对于数据量大且修改不频繁的表,如库存批次适用品类表,对于有效数据使用缓存。其key为:key=STK_PRD_CLS_RDS_***** (*****是批次id),值为表记录数组[表记录],以后如果需要查询批次适用品类可以先查缓存,已减小对于DB的访问压力【haoyabin——已上线】; •2.进行DB改造:(1) 对数据进行归档,归档后再删除主库中已归档的数据,减小DB中单个表的存储空间【zhuganggang——已上线】;(2) 拆分数据库,对数据切分,由单机存储转为分布式存储,只是目前互联网行业解决海量数据存储的杀手锏解决方案【haoyabin——这是本次分享重点讲述的内容】。 问题 什么是数据库切分?    2.都有哪些框架工具来实现?    3.使用哪个工具? 方法 •调研: 数据库切分有如下几种:•1. 垂直切分: 将数据库想象成为由很多个一大块一大块的“数据块”(表)组成,我们垂直的将这些“数据块”切开,然后将他们分散到多台数据库主机上面。这样的切分方法就是一个垂直的数据切分。以表为单位,把不同的表分散到不同的数据库或主机上。•2. 水平切分:  简单的水平切分主要是将某个访问极其平凡的表再按照某个字段的某种规则来分散到多个表之中,每个表中包含一部分数据。以行为单位,将同一个表中的数据按照某种条件拆分到不同的数据库或主机上。•3. 联合切分将上述两种切分方法结合使用。 •从系统的程序架构层面来看,切分逻辑可以在几个层面上实现: DAO层 ORM框架层 JDBC API层  介于DAO与JDBC之间的Spring数据访问封装层(各  种Spring的template)  介于应用服务器与数据库之间的切分代理服务器 等。 本次数据切分,选择使用上图中介于DAO与JDBC之间的Spring数据访问封装层的Cobar-Client,并对其进行了扩展,下图是阿里巴巴官方提供的Cobar Client现有架构实现的鸟瞰图,从图上也可得到一个直观映像。选择使用java中间件cobar-client,主要基于如下考虑:(1)cobar-client公司已经有正在使用的基于阿里巴巴的框架扩展的cobar-client; (2)cobar-client是轻量级的应用,基于mybatis和spring,扩展和维护较为简单。(3)使用数据库代理服务器,我们公司没有实战过的实现,代码开发扩展维护技术门槛较高,如果要自己维护的话,至少需要对IO和多线程非常熟悉。(4)数据库代理服务器proxy,需要支持mysql各种语法,而本次需求只需要支持DML(Data Manipulation Language 数据操控语言)语言即可。 (5)不需要申请服务器资源(要实现代理服务器proxy自身HA,需要多台服务器,需要结合其他软件,部署研究学习成本较高); (6)更进一步,cobar-client可以精确指定到某个sql是否支持切分,进一步减少开发和测试成本。 (7)不使用分布式数据库,因为没有找到一个适合的开源的稳定的分布式数据库,另外如果使用分布式数据库的话,可能对现有系统的架构冲击较大,风险不可控。  问题 •公司的tuniu-cobar-client和上述阿里的cobar-client是什么关系? •本次方案是选择分库还是分表? 方法 •先来看tuniu-cobar-client和alibaba-cobar-client的关系: tuniu-cobar-client是在alibaba的基础上开发的,对其进行了移植和扩展,主要有: 1.修改alibaba的Spring2.5为Spring3。相应的mybatis与spring集成框架也改为Mybatis-Spring。 由于tuniu现有的项目使用都是是Mybatis3 + Spring3 ,而alibaba使用的是Spring2.5,因此需要改造。又由于mybatis3与spring集成框架是的Mybatis-Spring框架,因此相应的把alibaba扩展org.springframework.orm.ibatis.SqlMapClientTemplate 类--->改为扩展org.mybatis.spring.SqlSessionTemplate 类 ,还包括相应的一系列移植修改 ,基础做的很扎实。         2.在分库路由之外,新增了分表路由,即同库分表(table分为table01,table02…)的情况,可以在同一个库存里路由到指定的分表。但是它只支持单表分表查询,不支持同库分表连接查询,即

select * from table t join table_other to on t.id = to.tid

当table_other分为table_other01、table_other02

    table           分为table01、          

table02,时不支持。

 另外,他不支持跨库查询聚合操作。      3. 增加了配置工厂类,方便大量配置。可见,如果在stk_nm库里分表,比如stock_round表,创建它的过期表stock_round_01,有效数据留在stock_round中,过期数据迁移到stock_round_01表中。会存在如下问题:       tuniu-cobar-client不支持同库多表连表查询,因其无法定位到多个表名。      比如: stock_round 和stock_round_res连表查询时,无法同时定位到                 stock_round_res 为stock_round_res_01,和stock_round 为stock_round_ 01      另外,对于同库多表,还是在同一个数据库中,实际并没有减轻该数据库的压力。      所以,最后的实现方案为:按団期水平切分到不同的DB中,即把过期数据单独放到过期数据库stk_nm_tenured中,如下图所示: 问题 •公司的tuniu-cobar-client可以实现不同库之间的路由功能,那么能否完全满足本次需要呢? 方法 直接使用公司的tuniu-cobar-client不能完全满足本次分库的需要:因为对于业务来说很难避免的问题就是:跨库查询后数据聚合的问题,比如:  (1)排序后分页,  (2)非排序分页,  (3)group by,  (4)DISTINCT,  (5)group by后聚合函数     SUM,COUNT,MAX,MIN,AVG 等。如果在业务层进行数据聚合的话,一方面业务千变万化实现聚合操作较为繁琐,不利于原有代码的改造;另一方面,聚合操作具有一定的重复性,在较底层实现可以“一劳永逸”。而如前所述公司的tuniu-cobar-client不支持跨库查询聚合操作。 问题: •如何实现跨库查询后数据的聚合呢? 以排序后分页来举例。 方法 淘宝首席架构师——曾宪杰在他的文章中给出了算法如下: 问题 •我们且不去纠结算法的正确性和为什么要这么做,当时也没有时间去纠结,感兴趣的同学可以下去看看,有详细的解释和论证。 •现在的问题是算法有了,如何落实到代码中,确切的说是如何和cobar结合起来? 方法: 由前面的分析我们知道cobar是介于DAO与JDBC之间的Spring数据访问封装层的.实际上他(tuniu-cobar)是用CobarSqlSessionTemplate类覆盖了spring-mybatis的org.mybatis.spring.SqlSessionTemplate类,在mybatis上做了一层分装。因此我们有必要去研究下mybatis的源码,本次分享仅对其做简要介绍:mybatis简介:•SqlSessionFactoryBuilder 每一个MyBatis的应用程序的入口都是SqlSessionFactoryBuilder,它的作用是通过XML配置文件创建Configuration对象(它是保存Mybatis全局配置的一个配置对象),然后通过build方法创建SqlSessionFactory对象。示例程序如下:• SqlSessionFactorySqlSessionFactory对象的主要功能是创建SqlSession对象。• SqlSession主要功能是完成一次数据库的访问和结果的映射,它类似于数据库的session概念,SqlSession对数据库的操作都是通过Executor来完成的. 应用程序就是在SqlSession这里插入到mybatis流程中并获得我们想要的结果的。 SqlSession有一个重要的方法getMapper,它是联系应用程序和Mybatis纽带,应用程序访问getMapper时,Mybatis会根据传入的接口类型和对应的XML配置文件生成一个代理对象,应用程序获得Mapper对象后,就通过这个Mapper对象来访问Mybatis的SqlSession对象,这样就达到里插入到Mybatis流程的目的。示例代码如下:•Executor         Executor对象在创建Configuration对象的时候创建,并且缓存在Configuration对象里。Executor对象的主要功能是调用StatementHandler访问数据库,并将查询结果存入缓存中。•StatementHandler        StatementHandler是真正访问数据库的地方,并调用ResultSetHandler处理查询结果。•单独使用mybatis是有很多限制的(比如无法实现跨越多个session的事务),而且很多业务系统是使用spring来管理的事务,因此mybatis最好与spring集成起来使用。 •在mybatis与spring集成后,通过spring自动扫描简化mapper的配置的方式, MapperScannerConfigurer会查找类路径下的映射器并自动将它们创建成MapperFactoryBeans对象。MapperFactoryBean 是一个工厂bean,在spring容器里,工厂bean是有特殊用途的,当spring将工厂bean注入到其他bean里时,它不是注入工厂bean本身而是调用bean的getObject方法。接下来就看看这个getObjec方法干了些什么:这个方法和我们之前单独使用Mybatis的方式是一样的,都是先获取一个Sqlsession对象,然后再从Sqlsession里获取Mapper对象(再次强调Mapper是一个代理对象,它代理的是mapper Interface接口,而这个接口是用户提供的dao接口 )。自然,最终注入到业务层就是这个Mapper对象。•Mybatis采用职责链模式,通过动态代理组织多个拦截器(插件),通过这些拦截器可以改变Mybatis的默认行为(诸如SQL重写之类的)。 •Mybatis支持对Executor、StatementHandler、PameterHandler和ResultSetHandler进行拦截,也就是说会对这4种对象进行代理。 •sql的解析是在StatementHandler里完成的; •结果集处理是在ResultSetHandler里完成的;由上面的分析可知:•我们可以通过拦截StatementHandler的prepare方法,在sql执行前修改sql,以便把每个数据源的前n页的数据都取出来。 •然后通过拦截ResultSetHandler的handleResultSets方法,来处理结果集,在结果返回前对其进行归并排序和丢弃不需要的数据。 •而查询方法的入口是在前面提到的CobarSqlSessionTemplate类的selectList方法上,因此可以对其进行必要改造来实现。 •排序后分页的时序图和流程如下:时序图sql解析及改写流程图结果集聚合流程图 问题 •虽然计划在拦截器中实现对结果集的处理,但是还有个问题:         通过前面的分析可以看到,在调用CobarSqlSessionTemplate. selectList后,对于多数据源也就是跨库查询的情况,会用多线程来调用SqlSessionCallback方法获取结果集,那么这些各自的调用如何才能在拦截器中收集到一起,拦截器是一个单例,共用的对象,他怎么知道获取到的结果集是哪一次调用的,并且还要把同一次调用的多个线程的结果集合并,只有收集到同一次调用的多个结果集后面的处理流程才能继续,才有意义。 方法 •通过思考,上述问题其实也就是如何实现CobarSqlSessionTemplate 和拦截器之间的通讯问题,于是想到了线程范围内的变量,只要在同一次请求的不同线程中共享同一个DataMergeService对象就可以解决。 •上述问题中的2个:  1.跨库sql查询的聚合如何实现?  2.在拦截器中如果收集结果的问题?  他们是开发中遇到的最棘手的问题,一个是如何开始,一个是开始后过程中困扰人的问题。      另外,针对:select count(0) from t_test;这样的简单需求,因为入口函数不再是CobarSqlSessionTemplate类的selectList,变成了selectOne ,因此上面的流程不能复用,为此提供了默认实现CountAggregationFunctionMerger.java使用时需要配置,以指定哪个sql要调用该方法:本次分享对于其他聚合操作不再一一讲述,感兴趣的同学可以看源码或私下交流。方法:代码改造主要改造点如下:•1.新增拦截器PaginationAfterOrderingPlugin.java •2.新增count聚合函数返回结果merger默认实现CountAggregationFunctionMerger.java •3.新增sql解析包com.tuniu.operation.platform.cobar.client.parser •4.新增数据聚合包com.tuniu.operation.platform.cobar.client.sqlengine,其中排序算法是堆排序,其特点是空间复杂度为O(1)。 •5.其他:修改原tuniu-cobar代码以适应扩展 问题 由上述排序后分页算法的描述可以看到:•取第1页时需要从每个数据源取足1页的数据数据,然后做归并排序,丢弃不需要的数据; •取第2页时需要把每个数据源的前2页的数据都取出来,然后做归并排序,丢弃不需要的数据;  •取第n页时需要把每个数据源的前n页的数据都取出来,然后做归并排序,丢弃不需要的数据; 可以看到,越往后翻页,承受的负载越重,所以应该尽量避免这种访问方式,尤其是需要排序后翻很多页的情况。如果不加以控制,系统OOM的风险不可避免,将是致命的。   根据现在的见识和知识理解,解决方案是有的,   (1)可以从JDBC收到到报文开始,丢弃不需要的报文,返回给Mybatis就是最初数量的数据,可以看到这样需要侵入JDBC和mysql底层通讯,甚至需要自己实现一套JDBC ;  

(2)也有一些团队宣称用缓存解决了该问题。

但是现在由于开发时间及技术储备有限,无法实现。

方法 •现在的方法是规避,具体来说就是:做限制,和对参数主动修改。  (1)前端,不允许跨库;分开为历史团期查询和有效团期查询,两者有一个为必填。(2)后端,如果跨库查询直接路由到有效库。•注意,我们只是为了避免跨库时大数据量带来的问题采取了上述规避措施,并不是不支持跨库聚合查询操作,在确认安全的情况下,内部有在使用,如:    (1)批次查询维护时:如输入批次id,采购计划单号,入库单号查询,而未指定団期时,考虑到数据量不大,允许跨库查询出所有的数据;  (2)出库单管理也存在类似的使用       (3)历史采购数据报表查询,入参是一个団期list,需要跨库,且数据量不大,允许跨库。•但是也要,正视scc-cobar-client跨库查询性能尚未达到大规模商用条件的现实,欢迎各位同学,加入本系统的开发维护中贡献自己的聪明才智,帮助完善本系统。 问题 •解决工具的问题后,就可以改造现有代码,来满足项目的需要,下面的分享都是代码改造过程中遇到的一些问题和解决方法。 •切分字段如何选择,如何处理?  方法 如前所述,本次切分实际是按団期departsDate(防止与业务混淆标记为shardingDate,String类型,格式为 "yyyy-MM-dd")的水平切分,主要遇到如下几种情况•1.没有传団期的情况: (1)确实没有団期,団期为null,就说明需要访问所有数据库节点, 需要在mapper入参里加上shardingDate=null字段; (2)名称不叫departsDate,如名称叫time, 需要将time的值赋值给map.put("shardingDate", time)转换下名称。•2.传的是一个范围(beginTime -- endTime),比如如下sqlcom.tuniu.scc.stock.manage.core.out.dao.StockOutMapper.countOutList<if test="beginTime!=null and beginTime!="><![CDATA[AND ou.departs_date>=#{beginTime}]]></if><if test="endTime!=null and endTime!="><![CDATA[AND ou.departs_date<=#{endTime}]]></if>需要自己加一个前处理,根据情况给shardingDate赋值,以便路由到相应的库,假设:b代表beginTime;  e代表endTime;  r代表referenceDate则:(1) b = e且都存在,map.put("shardingDate",   beginTime);//起止日期相等时,可以精确定位到DB   (2) b > = r,map.put("shardingDate", beginTime);//起始日期为参考时间或者在参考时间之后==>在有效库   (3) e < r,map.put("shardingDate", endTime);//结束日期在参考时间之前==>在过期库   (4)其他情况: map.put("shardingDate", referenceDate);//如果默认查有效库中数据一个算法实现如下:3. 传的是一个集合: 简单实现,跨库全查。 问题 •切分前的系统中对于前端的某些查询丛库的请求操作是通过切面,拦截Controller层的方法,判断方法名将数据请求路由到丛库中查询结果集的。比如出库单管理列表查询接口:manage/out/query,该接口的对应方法是  StockOutController.slaveQueryStockOutList,根据方法名称开头字符串为slave路由到丛库查询,那么使用cobar之后,如何实现读写分离呢? 方法 •如果只按namespace(就是某个mapper文件)或者sqlmap(就是具体到某个mapper文件中的某个sql方法)映射数据库的话, 可能会有在一个业务里需要先读后改的情况,这时候需要都读主库,比如占位需要先读取批次信息,然后判断有无余位,然后修改主库,如果此时读取批次信息走丛库的话,由于主从延迟可能会导致超售的情况,所以建议还是在controller层做控制,以免影响其他函数调用。 •因此为了防止误读从库,在切分字段中加了一个slaveFlag,作为主从标记,还是由controller层来控制是从主库还是丛库获取数据,如果主库读取数据可以设置slaveFlag=0,如果是从丛库读取数据,需要设置slaveFlag=1。上面是说slaveFlag参数值的源头在controller层,但是要生效,需要在调用mapper中的方法前将该的方法参数中的slaveFlag属性值为1。另外如果有shardingDate要给他赋值,可以省去跨库的损耗。比如:map.put("slaveFlag", 1);map.put("shardingDate", "2016-01-01"); 结束语 •其他问题:如库存代码改造步骤,日常数据迁移方案,改造过程中遇到的各种问题,压测和测试中遇到的问题,上线准备中遇到的问题,上线步骤,数据迁移sql,内部改造功能点等在此不在赘述,感兴趣的同学可以发邮件交流。 •本次库存分库实践,最大的意义对我来说,除了满足了项目需求外,更重要的是我们有一个工具,可以按団期分库,就可以按其他字段分库,可以分为2个库,就可以分为4,8,……个库。这是一个打怪升级的过程,还是挺有搞头的。 •最后,还要再一次邀请各位对技术感兴趣的同学,加入本系统的开发维护中,贡献自己的聪明才智,一起完善本系统。