交易平台技术栈迁移实战

989 阅读22分钟
原文链接: mp.weixin.qq.com

作者:石林本文原创,转载请注明作者及出处

背景介绍

去Windows化:从使用“Windows 系统部署+Net 开发技术+SqlServer 数据库”的技术栈迁移到“Linux 系统部署+Java 开发技术+MySql 数据库”的技术栈。

首先,公司在规模化的发展中,对技术的要求更加偏向于开源平台的技术栈;其次,在业务不断的发展演进中,也需要对现有系统进行一次规模化的重构,来偿还不断前进中忽略的技术债务。

任何一个傻瓜都能写出计算机能理解的程序, 而优秀的程序员却能写出别人能读得懂的程序。—— Martin Fowler <<重构>>

在此背景下,公共业务部门开展了去Windows化的技术栈迁移改造。公共业务主要涉及业务有订单、商品、促销、清结算,以及一些老的遗留业务等。其中,商品前期已经完成了 去Windows化。本次迁移的重点是交易核心部分,共涉及应用系统有13个,影响业务方有5个(如:享互、沪江网校、财务、BI、仓库等)。

由于时间紧,改变了之前先迁移 Java 应用再迁移 MySql 数据库的方案,采取了应用+数据库的同步切换、订单正向交易和订单逆向交易分步部署的方案,难度和复杂度还是相当可观的。

系统梳理

总体业务划分

由于业务不断发展和演进,早期的系统设计不完善之处也逐渐显现出来。如:在之前的设计中订单部分耦合了很多营销的业务,像:卡码券的金额计算、促销规则的应用、卡券列表的查询等,导致订单业务过重,修改频繁;借着此次系统迁移重构,对现有的业务做了域的划分,共分为四大块,如下图所示:

  • 订单域:主要负责订单查询、下单、改单、配送、金额分摊、对账管理等

  • 营销域:主要负责商品管理、卡码券管理、促销管理、规则引擎计算等

  • 结算域:主要负责商户管理、合同管理、计费规则、清分、分销、结算管理等

  • 售后域:主要负责逆向退款退货等

订单域业务系统

下图是梳理出的订单域正逆向的服务及数据库。

  • 红色模块:第一批同步迁移的订单正向服务模块及订单数据库

  • 绿色模块:第二批同步迁移的订单逆向服务模块

  • 蓝色模块:正逆向依赖的服务模块及数据库

其中,订单域最重要的一个服务就是下单服务。分析下前台结算页的下单服务流程。

关键步骤:

  1. 校验创建订单参数(金额、学币、卡码券等等)

  2. 获取商品、用户、订单跟踪、结转信息等等

  3. 加载促销、卡码券信息、计算营销规则

  4. 填充订单上下文、金额计算并校验

  5. 并发检测(卡码券是否使用)

  6. 持久化订单信息

  7. 支付通知(0元直接支付)

  8. MQ 发布订单创建完成消息

这里,第3步是比较复杂的一步,在订单域重构改造前,这一步的营销规则计算是放在订单系统里。订单先从营销拿到促销、卡码券、规则信息,再进行各种规则及优先级组合计算。从设计角度来看,订单系统的职责过重、和营销系统耦合较深,系统边界不够清晰。在这次迁移重构中,也将这一块规则计算全部移到营销系统去处理。

还有,第6步持久化信息,创建订单需要持久化多张关联表的信息;这里将以前的长事务处理重构成分步持久化,通过设置订单的最终有效状态,来保证事务的最终一致性。

如下是下单关键流程的时序图:

经过重构后的下单服务通过压测,TPS 达到870左右,这个压测环境比生产环境配置差了很多,还有优化的空间,如下图所示:

系统梳理小结

根据业务域的划分,进行业务的重新整理和规划,梳理出需要迁移的服务接口共计185个,后台任务7个,需要改动的相关前后台站点5个。

系统梳理的目的:

  • 理清系统边界(优化重构、职责分离)

  • 梳理依赖方(数据隔离、老服务切换)

  • 划分重点项目、关键应用、关键模块(重点模块单元测试全覆盖)

  • 合理安排开发、测试资源(重点项目优先)

为什么要分批部署?

首先,还是资源紧张,大量的开发任务集中在订单正向。其次,订单逆向的业务流程相对滞后,可以通过数据同步,在之前的老的系统上继续运行订单逆向业务,待逆向部分开发完成,再次部署上线。

如何数据同步?

本次数据迁移及上线回滚方案主要用到阿里巴巴的跨数据源异构数据同步的开源中间件(DataX、yugong)。DataX:我们主要用做数据的全量同步。yugong:我们主要用做数据的增量同步。在使用中,DataX 的配置比较简单,可以自定义读写插件,但不支持数据的增量同步;yugong 的配置稍复杂点,它对源数据、目标数据的字段名、类型字段个数都有强校验,但好处是支持全量增量数据同步、数据校验等。具体的数据同步方案,后面的迁移实战会有介绍。

迁移实战

1、数据隔离、服务化

去Windows化,首先要进行的就是数据隔离,杜绝外围业务系统对订单数据库的直接访问,统一改成服务化调用。之前的很多业务,如订单对账就是通过 Kettle直接从订单库和支付库进行数据抽取来做对账的,这样如果要做数据库迁移时,这些逻辑都要重做。所以,在应用迁移改造前,就需要在现有的 .NET 应用里提供代替外部直接访问数据库的 API 接口,并请业务方配合完成新的 API 接口切换。

2、通用代码的快速迁移

a、Python 脚本批量生成代码实体类

首先明确的是,我们的服务接口契约是和 .NET 服务接口契约完全一致的,那么接口的传入、传出参数肯定是不会变的。所以,我们做了一个 Python 的脚本,来对 .NET 的代码实体类批量转换到 Java 的代码实体类。

代码片段:

  1. def processConvertJavaByDir(paths,namespace):

  2.    list=os.listdir(paths)

  3.    for i,file in enumerate(list):

  4.        subpath=os.path.join(paths,file)

  5.        if os.path.isdir(subpath)==True:

  6.            processConvertJavaByDir(subpath,namespace)

  7.        elif os.path.isfile(subpath) and subpath.endswith(".cs"):

  8.            targetContent = genTargetContent(subpath, namespace)

  9.            targetFile = os.path.join(paths, file.replace(".cs", ".java").replace("Entity","Bo"))

  10.            if os.path.isfile(targetFile):

  11.                os.remove(targetFile)

  12.            open(targetFile, mode='w', encoding='utf-8').write(targetContent)

  13. def main():

  14.    try:

  15.        sourceStr = input("输入源文件目录: \n").encode();

  16.        namespaceStr = input("输入命名空间: \n").encode();

  17.        while (not os.path.isdir(sourceStr.decode())):

  18.            sourceStr = input("请输入有效的源文件目录: \n").encode();

  19.        namespace=namespaceStr.decode()

  20.        sourceDir=sourceStr.decode()

  21.        targetDir = sourceDir

  22.        processConvertJavaByDir(targetDir,namespace)

  23.        print("成功执行转换,生成目录:" + targetDir)

  24.    except  Exception as err:

  25.        print(err)

  26.    finally:

  27.        print("结束...")

b、表结构变动批量替换PO实体、DAO方法、DAO映射XML

订单库在从 SqlServer 重构迁移到 MySQL 中,由于设计调整,经常会有字段名变更、添加字段、变更字段类型等等。虽然有 MybatisGenerator 自动生成代码,但去找到对应代码的位置,一个个文件去替换也是比较繁杂的,如果自定义的代码和生成代码混合在一起就更不好替换了。这里主要通过改造 MybatisGenerator ,在生成代码时,前后加上特定标记,然后通过 Python 脚本去批量替换标记内的代码。

  1. @Data

  2. @EqualsAndHashCode(callSuper = true)

  3. public class OrderConfigPo extends BasePo {

  4.    /*<AUTOGEN--BEGIN>*/

  5.    /**

  6.    * 主键

  7.    */

  8.    private Long id;

  9.    /**

  10.    * 配置的key

  11.    */

  12.    private String key;

  13.    /*<AUTOGEN--END>*/

  14.    public ShopConfigBaseBo toShopConfigBase(){

  15.        return this.toShopConfigBase(this);

  16.    }

  17. }

  • PO实体、DAO方法通过/*<AUTOGEN--BEGIN>*//*<AUTOGEN--END>*/来标记生成的代码。

  • DAO映射XML是通过<!--AUTOGEN-BEGIN--><!--AUTOGEN-END-->来标记生成的代码。按照约定自定义的代码是放在这个标记之外。

3、保证服务的完整性

本次去Windows化的一个前提就是,无论我们怎么迁移改造、业务方不需要做任何改动,做到无感知的过渡。那么,我们迁移改造的服务就要向前完全兼容。

做到兼容的原则:

  • 接口传入、传出参数一致性(名称相同、类型相同、默认值相同)

  • 写入数据时,表字段类型、值、默认值相同

这里默认值是比较大的一个坑,在之前 .NET 应用代码里,我们使用的基本类型都是值类型;而在 Java 应用里,我们约定了基本类型都定义为引用类型,如下所示:

  1. //c#代码

  2. public int ProductType { get; set; }

  3. public bool IsBill { get; set; }

  1. //java代码

  2. private Integer productType;

  3. private Boolean isBill;

那像 C# 代码IsBill字段,默认值是false(这种设计就分不清到底是业务上的false还是默认值),而 Java 代码isBill字段,默认值是null。

Java 应用会将isBill序列化为null,业务方 .NET 应用在反序列化时会报错,这个问题在商品去Windows化时遇到过。还有,业务方如果用了引用类型的基本类型,对反序列化后的isBill的判断也会有问题,因为之前IsBill始终会有值的。所以,我们在序列化时,如果是null,就不序列化该字段;如果之前 .NET 应用有默认值, Java 应用里也设置默认值。

只保证接口的传入、传出参数的一致性还不够;还要对关联表字段进行检查,确保和之前的表字段值一致。在商品去Windows化上线一个月了,业务方发现有个业务属性不正确,后来排查发现,在迁移改造中,这个属性字段在保存时遗漏了。

上述,只是在开发中需要遵循的规则,要做到完全兼容,还需要测试人员进行自动化的测试。

测试要点:

  • 同时在 .NET 应用、Java 应用调用服务,通过 Python 脚本,来比对接口的传入、传出参数是否一致

  • 通过 Python 脚本,抓取 SqlServer 表、MySql 表,来比对关联字段值是否一致

4、迁移过程中遇到的问题

4.1、基础知识问题

a、典型的字符串“==”问题,由于 .NET 开发人员的习惯,这个也是问题的高发点,所以建议使用org.apache.commons.lang3包下的 StringUtils 类去做字符串的判断操作。(其他如日期、数值都建议使用该包下的类去操作)

b、BigDecimal 的比较大小,使用 compareTo,而不是 equals

c、BigDecimal 构造数值型,使用new BigDecimal(“0.9”),而不是 new BigDecimal(0.9)

d、自动拆箱的空引用问题。自动拆箱赋值时,编译期无错误,运行时也不一定有错误(如果为 null 就会抛错);一定要注意判空,同时也建议基础类型都用引用类型。

  1. //如:将一个 Integer tempOrderVersion 属性拆箱赋值给 int tempOrderVersion 的属性

  2. response.setTempOrderVersion(po.getTempOrderVersion());

e、不建议使用 BeanUtils.copyProperties 进行属性的赋值。主要是属性个数、属性类型、属性名称的不匹配,而没有成功赋值;但编译期又发现不了,很难排查,很容易带到线上。像之前有个属性就因为名称大小写不一致而导致始终查不到值。如下这个就一个字母 N 大小写不一致,排查只能靠眼力了。

  1. //源字段

  2. private String originalPaynumber;

  3. //目标字段

  4. private String originalPayNumber;

f、对一个公用数据进行临时修改时,没有克隆副本,导致多线程读取数据错误。这个问题虽然很简单,但当时做优惠券压测时确实查了很久,而且由于业务数据的原因不是每次都必现。

g、并发执行对 MySql 同一个数据表的批量修改、删除操作时;如果条件为非主键,非唯一索引,容易引起死锁问题。

4.2、RabbitMQ、Codis 反序列化问题

这里主要是框架使用 RabbitMQ、Codis 传值时,使用了 FastJson 的数据格式。

框架为了嵌套反序列化时,类型不丢失,使用了 type 来描述对象类型。如下图所示:

  1. {"@type":"com.company.ecm.model.Person","age":18,"name":"李明"}

但我们的应用为了兼容 .NET 的应用,并没有使用一致的对象类型来接收这种 json 串,使用这种格式反序列化就会报错。

故在项目里重新定义了 json 的序列化参数,移除了 SerializerFeature.WriteClassName 类型的配置:

  1. //SerializerFeature[] serializerFeautres = new SerializerFeature[]{SerializerFeature.WriteClassName,SerializerFeature.DisableCircularReferenceDetect}

  2. SerializerFeature[] serializerFeautres = new SerializerFeature[]{SerializerFeature.DisableCircularReferenceDetect}

4.3、依赖服务

a、返回数据格式

先看下调用的依赖服务接口的返回数据格式,大部分的都是遵循标准规范的返回格式,但还是有些老的服务使用了非标准的返回格式,如下所示:

  1. //返回数据

  2. //标准规范格式

  3. {

  4.    //data为基本对象类型、不能是泛型、数组、基础类型等

  5.    "data": null,

  6.    "message":null,

  7.    "status":0

  8. }

  9. //非标准规范格式

  10. {

  11.    "data": null,

  12.    "faults": [],

  13.    "type": 1

  14. }

  15. //非标准规范格式

  16. {

  17.    "data": null,

  18.    "status": false,

  19.    "error": null

  20. }

b、请求响应数据类型

接口的请求响应数据类型一般都为 json 格式的,但也有一些其他的请求响应类型,如:

  1.    //标准格式

  2.    @POST

  3.    @Path("/test/apply")

  4.    @Produces(MediaType.APPLICATION_JSON)

  5.    @Consumes(MediaType.APPLICATION_JSON)

  6.    @Reader(value = CustomerJsonReaderPlugin.class)

  7.    Response getResponse(TestRequest request);

  8.    //非标准格式

  9.    @POST

  10.    @Path("/test/{userId}")

  11.    @Produces(MediaType.TEXT_HTML)

  12.    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)

  13.    @Reader(value = HtmlJsonReaderPlugin.class)

  14.    Response getResponse(@PathParam("userId") Long userId, @RequestBody String body);

解决方法:

通过自定义实现MessageBodyReaderReaderPlugin 来处理非标准的数据格式。

c、接口请求数据的验签规则

这一块,依赖服务的验签规则都不太统一,针对各种验签规则,都需要分别处理下。

4.4、MySql、SqlServer 日期时间存储问题

SqlServer 的 datetime 字段类型,是可以存储毫秒的;而 MySql 的 datetime 字段类型,默认只能精确到秒。我们业务中没有用到毫秒,所以精确到秒是没有问题的,但是在存储时,如果带了毫秒的数据会发生进位。如:

  1. update order_master set bill_date='2017-09-17 16:41:10.500' where  order_id=100006014;

如果存储的毫秒大于等于500毫秒,MySql 会自动进位到秒,上述实际存储值是:2017-09-17 16:41:11这样在创建订单时,服务返回的时间和数据库存储的时间会有 1s 的误差。

解决方法:实现 MyBatis 的 Interceptor 接口,拦截需要存储的时间类型,将毫秒置为 0。

4.5、服务 URL 大小写问题

在之前 .NET 应用里,服务 URL 使用上,是不区分大小写的。现在使用的 RESTEasy 默认是区分大小写的,这样就导致服务被调用时,URL 会找不到的情况发生(这个也是在测试时遇到的比较多的问题)。

解决方法:应用通过实现 ContainerRequestFilter ContainerResponseFilter,构造自定义的 filter 将请求的 URL 统一转为小写的 URL 来处理。

4.6、yugong 数据同步的问题

场景是需要从 MySql 增量、全量同步数据到 SqlServer;SqlServer 表存在复合主键、而 MySql 表都有主键 ID,SqlServer 表字段不允许为null,MySql 表字段允许为 null 等等。

官方的 yugong 是不支持 SqlServer 的同步以及复合主键同步的,这里架构的同事们帮忙实现了这部分功能、并添加了一些自定义的转换,而且支持 YAML 的配置方式。

在使用过程中,为了业务需要还添加了几个功能点:

a、配置节添加defaultColumns的配置项,主要是当 SqlServer 不允许为 null 时,如果 MySql 为 null 则设置一个默认值;如果 MySql 为某个特定值,则设置 SqlServer 的替换值。

  • ColumnTranslator 类,添加对defaultColumns配置项的处理。

  1. //如果设置了defaultColumns,则根据配置的默认值来替换原来的null值,如果设置了原值,则根据匹配的原值替换

  2. for (Map.Entry<String, Map<String, Object>> entry : defaultColumns.entrySet()) {

  3.  ColumnValue column = record.getColumnByName(entry.getKey());

  4.  Object beforeValue= entry.getValue().get("beforeValue");

  5.  if(beforeValue!=null){

  6.    if(column.getValue().equals(beforeValue)){

  7.      column.setValue(entry.getValue().get("value"));

  8.    }

  9.  }else if(column.getValue()==null){

  10.    column.setValue(entry.getValue().get("value"));

  11.  }

  12. }

b、如果使用联合主键同步到 SqlServer 时,目前更新的 position_data 的 id 值为第一个联合主键的值,而不是 MySql 的主键 id,会导致增量同步数据出问题(原因:增量数据是从 position_data 的 id 值起始同步)解决方法:

  • Record 类添加 sourcePkeys 来存储原始的 MySql 主键信息

  1. //当目标库复合主键时,原库的主键信息

  2. private List<ColumnValue> sourcePkeys;

  • AbstractFullRecordExtractor 类的 ack 方法修改存储 position_data 的 id 值为 sourcePkeys 的主键 ID。

c、更新 position_data 的 id 值时,记录当前的更新时间(为了方便计算和修改增量同步 SQL 语句的更新时间)

这些更新点,也推给了架构组维护的 gitlab 项目;这个修改版本,架构部也在筹备推给开源社区 github。

具体的使用如下,这个是表字段配置的一个代码段:

  1. appiler: {a: todo, b: todo}

  2. databases:

  3.  source: {schema: null}

  4.  target: {schema: null}

  5. extractor: {a: todo, b: todo}

  6. table: {a: todo, b: todo}

  7. translators:

  8.  record:

  9.    order_deal_memo:

  10.        - class: com.taobao.yugong.translator.NameStyleDataTranslator

  11.          #对应的目标表

  12.          properties: {table_to: ShopOrderDealMemo}

  13.          #列数据转换插件

  14.        - class: com.taobao.yugong.translator.ColumnFixDataTranslator

  15.          properties:

  16.            #源字段目标字段不一致时,同步别名映射

  17.            column_alias:

  18.              Id: [ID]

  19.              OrderId: [OrderID]

  20.            #源表的字段在目标表不存在对应时,需要显示的排除  

  21.            exclude_columns: [DealUserCompanyId, Timestamp]

  22.            defaultColumns:

  23.              #DealUser当源值是null时,目标字段赋值0

  24.              DealUser: {value: 0}

  25.              #IsChild当源值是0时,目标字段赋值null

  26.              IsChild: {beforeValue: 0, value: null}

我们要迁移的有 24 张表,这里每张表做了一个 YAML 配置文件便于维护,然后通过 Python 脚本合并成最终的配置文件。并将数据初始化、数据同步、增量配置、数据验证等写到 Shell 脚本里,方便执行。

最终的运行目录如下:

定时或手动运行 run_yugong_import.sh

  • 修改 positioner_data 目录对应表的配置文件里的 id 值(如果是需要同步修改数据,这个 id 值是固定的)

  • 根据上次同步完成写入 position_data 的更新时间,修改 target_order_mysql-mssql-check.properties、target_order_mysql-mssql-full-sync.properties文件里 SQL 语句的更新时间

  • 执行 yugong-shaded.jar,先做数据的同步,产生结果到 yg_app_import;再次执行 yugong-shaded.jar,做数据的验证,产生结果到 yg_app_check

  • 将执行后的 yg_app_import 目录下的 positioner_data 文件夹覆盖到主目录下的 positioner_data 文件夹

  • 将同步、验证后的日志按照时间戳备份到 logs 文件夹

  • 输出数据验证的结果,是否有错误产生等

run_only_check.sh:主要用作数据验证,不需要数据同步。像通过 DataX 全量数据同步后,可运行这个脚本来对比验证数据的一致性,并有格式良好的对比日志。

checkResult.py:主要用作数据验证后的日志分析,查看是否有验证错误产生。

5、验证环境测试方案

5.1、API GateWay 切换

验证环境需要做好两份 API GateWay 的转发配置,一套指向 .NET 应用的配置,一套指向 Java 应用的配置。方便在验证环境测试时,可以做到一键切换。

还有订单逆向的 .NET 应用,因为要配合订单正向的迁移,是做了一些改动的。这部分服务只能通过重新发布来切换了。

5.2、验证、产线库数据同步

我们验证环境和产线环境共用一套数据库,当验证环境运行 MySql 数据库做测试的话,产线 SqlServer 数据库也在产生数据。同时产生数据的话,就要做数据的同步而且同时产生的订单ID 不能重复,否则就会产生后续的订单对账、清结算、分销等流程就无法做了。这里说的是测试,其实数据都是真实的,是需要真实支付和后续的报表输出的。

这里以订单表的数据同步方案为例,其他表也是一样:假设当前 SqlServer 订单表的订单 ID 最大自增值为:10000,也即数据范围:1-10000a、首先将 SqlServer 表的订单 ID 的起始自增值修改为 12001(这里具体修改多大的自增值取决于预计会在验证环境产生多少测试数据)。

b、通过 DataX,将 SqlServer 订单表里小于 12001 的数据记录全量同步到 MySql 数据库。

c、MySql 订单表新产生的订单 ID 范围也即:10001-12000,SqlServer 订单表新产生的订单 ID 起始值:12001

d、每天定时通过 yugong 将 MySql 新产生的订单数据增量同步到 SqlServer 的订单表,预留的订单 ID 范围:10001-12000(验证环境的数据也是真实数据,也要同步到产线的 SqlServer 库)

这里 b 步骤全量同步后,其实产线订单数据会有取消订单存在,也即会有之前的数据发生变更;这里的做法是在上线前一个时间点将这些变更数据同步更新到 MySql 数据表里,像订单取消最大值是三天,也即全量同步后三天,就可以将订单取消的数据同步更新到 MySql 的数据表里。

具体方案示意图如下:

6、上线部署方案

在上线部署时,我们有一个小时数据库停机来完成数据同步的操作。沿用上述验证环境测试方案的例子,在 MySql 和 SqlServer 都停止服务后,操作如下:

a、将 SqlServer 订单表订单 ID 值大于等于12001的数据通过 DataX 全量同步到 MySql 订单表。

b、假设同步到 MySql 最大的订单 ID 值为12800,则修改MySql起始自增值 13000。(因为在数据库停服的过程中,可能会有一些阻塞的订单,这里自增值调的大点,方便后续的数据补偿。)

c、启动 MySql 服务,API GateWay 切换到 Java 应用,启动 SqlServer 服务,然后开启 yugong 程序,定时增量同步从 MySql 到 SqlServer 的订单数据。

这里 c 步骤还要继续定时增量同步从 MySql 到 SqlServer 的订单数据,主要是因为这一阶段订单逆向部分还没有上线,需要订单正向 Java 应用和订单逆向 .NET 应用同时并行,等到逆向部分上线后,就可以彻底关停 SqlServer 数 据库了。

上线部署示意图如下:

7、上线回滚方案

一旦上线后,验证出现重大问题,就必须考虑回滚方案。具体操作如下:

a、停止 MySql 数据库的访问,禁止写入。b、手动运行 yugong,增量同步数据到 SqlServer 库。c、修改 SqlServer 订单表的起始自增值,需要大于当前最大值。(可以调的稍大一点,防止停库过程中,阻塞的数据需要后续补偿)d、启用 SqlServer 数据库写入权限,API GateWay 切换到 .NET 应用。

迁移总结

以上主要是对这次 去Windows化 过程中,遇到的一些问题进行分析和总结。这次公共业务 去Windows化 的技术栈迁移改造还是非常成功的,各业务条线共同通宵验证,协同作战,原计划 6.5 个小时的停机部署上线方案,最终 2.5 个小时就完全成功部署上线,无重大 bug,无回滚操作。

上线成功的那一刻,是非常激动人心的,让我们觉得共耗时近6个月,在不中断业务项目的基础上,加班加点完成的工作终于收到了回报。

非常感谢小伙伴们辛苦的付出,也感谢各业务方的配合联调。

相关文章:

技术沙龙推荐

点击下方图片即可阅读

翻译 | 服务性能监控:USE方法(The USE Method)

从ELK到EFK

最终版 | 深度学习之概述(Overview)

翻译 | 关键CSS和Webpack: 减少阻塞渲染的CSS的自动化解决方案

推荐系统那些事儿