一、背景
随着用户量级的快速增长,vivo 官网商城 v1.0 的单体架构逐步暴露出弊病:模块愈发臃肿、开发效率低下、性能呈现瓶颈、系统维护艰难。
从 2017 年开始启动的 v2.0 架构降级,基于业务模块进行垂直的零碎物理拆分,拆分进去业务线各司其职,提供服务化的能力,独特撑持主站业务。
订单模块是电商零碎的交易外围,一直累积的数据行将达到单表存储瓶颈,零碎难以撑持新品公布和大促流动期间的流量,服务化革新势在必行。
本文将介绍 vivo 商城 订单零碎建设的过程中遇到的问题和解决方案,分享架构设计教训。
二、零碎架构
将订单模块从商城拆分进去,独立为订单零碎,应用独立的数据库,为商城相干零碎提供订单、领取、物流、售后等标准化服务。
零碎架构如下图所示:
三、技术挑战
3.1 数据量和高并发问题
首先面对的挑战来自存储系统:
- 数据量问题
随着历史订单一直累积,MySQL 中订单表数据量已达千万级。
咱们晓得 InnoDB 存储引擎的存储构造是 B + 树,查找时间复杂度是 O(log n),因而当数据总量 n 变大时,检索速度必然会变慢,不论如何加索引或者优化都无奈解决,只能想方法减小单表数据量。
数据量大的解决方案有:数据归档、分表
- 高并发问题
商城业务处于高速发展期,下单量屡翻新高,业务复杂度也在晋升,应用程序对 MySQL 的访问量越来越高。
单机 MySQL 的解决能力是无限的,当压力过大时,所有申请的访问速度都会降落,甚至有可能使数据库宕机。
并发量高的解决方案有:应用缓存、读写拆散、分库
上面对这些计划进行简略形容:
- 数据归档
订单数据具备工夫属性,存在热尾效应,大部分状况下检索的都是最近的订单,而订单表里却存储了大量应用频率较低的老数据。
那么就能够将新老数据离开存储,将历史订单移入另一张表中,并对代码中的查问模块做一些相应改变,便能无效解决数据量大的问题。
- 应用缓存
应用 Redis 作为 MySQL 的前置缓存,能够挡住大部分的查问申请,并升高响应时延。
缓存对商品零碎这类与用户关系不大的零碎成果特地好,但对订单零碎而言,每个用户的订单数据都不一样,缓存命中率不算高,成果不是太好。
- 读写拆散
主库负责执行数据更新申请,而后将数据变更实时同步到所有从库,用多个从库来分担查问申请。
但订单数据的更新操作较多,下单顶峰时主库的压力仍然没有失去解决。且存在主从同步提早,失常状况下提早十分小,不超过 1ms,但也会导致在某一个时刻的主从数据不统一。
那就须要对所有受影响的业务场景进行兼容解决,可能会做一些斗争,比方下单胜利后先跳转到一个下单胜利页,用户手动点击查看订单后能力看到这笔订单。
- 分库
分库又蕴含垂直分库和程度分库。
① 程度分库:把同一个表的数据按肯定规定拆到不同的数据库中,每个库能够放在不同的服务器上。
② 垂直分库:依照业务将表进行分类,散布到不同的数据库下面,每个库能够放在不同的服务器上,它的核心理念是专库专用。
- 分表
分表又蕴含垂直分表和程度分表。
*① 程度分表:* 在同一个数据库内,把一个表的数据按肯定规定拆到多个表中。
*② 垂直分表:* 将一个表依照字段分成多表,每个表存储其中一部分字段。
咱们综合思考了革新老本、成果和对现有业务的影响,决定间接应用最初一招:分库分表
3.2 分库分表技术选型
分库分表的技术选型次要从这几个方向思考:
- 客户端 sdk 开源计划
- 中间件 proxy 开源计划
- 公司中间件团队提供的自研框架
- 本人入手造轮子
参考之前我的项目教训,并与公司中间件团队沟通后,采纳了开源的 Sharding-JDBC 计划。现已更名为 Sharding-Sphere。
- Github:https://github.com/sharding-sphere/
- 文档:官网文档比拟毛糙,然而网上材料、源码解析、demo 比拟丰盛
- 社区:沉闷
- 特点:jar 包形式提供,属于 client 端分片,反对 xa 事务
3.2.1 分库分表策略
联合业务个性,选取用户标识作为分片键,通过计算用户标识的哈希值再取模来失去用户订单数据的库表编号.
假如共有 n 个库,每个库有 m 张表,
则库表编号的计算形式为:
– 库序号:Hash(userId) / m % n
– 表序号:Hash(userId) % m
路由过程如下图所示:
3.2.2 分库分表的局限性和应答计划
分库分表解决了数据量和并发问题,但它会极大限度数据库的查问能力,有一些之前很简略的关联查问,在分库分表之后可能就没法实现了,那就须要独自对这些 Sharding-JDBC 不反对的 SQL 进行改写。
除此之外,还遇到了这些挑战:
(1)全局惟一 ID 设计
分库分表后,数据库自增主键不再全局惟一,不能作为订单号来应用,但很多外部零碎间的交互接口只有订单号,没有用户标识这个分片键,如何用订单号来找到对应的库表呢?
原来,咱们在生成订单号时,就将库表编号隐含在其中了。这样就能在没有用户标识的场景下,从订单号中获取库表编号。
(2)历史订单号没有隐含库表信息
用一张表独自存储历史订单号和用户标识的映射关系,随着时间推移,这些订单逐步不在零碎间交互,就缓缓不再被用到。
(3)治理后盾须要依据各种筛选条件,分页查问所有满足条件的订单
将订单数据冗余存储在搜索引擎 Elasticsearch 中,仅用于后盾查问。
3.3 怎么做 MySQL 到 ES 的数据同步
下面说到为了便于管理后盾的查问,咱们将订单数据冗余存储在 Elasticsearch 中,那么,如何在 MySQL 的订单数据变更后,同步到 ES 中呢?
这里要思考的是数据同步的时效性和一致性、对业务代码侵入小、不影响服务自身的性能等。
- MQ 计划
ES 更新服务作为消费者,接管订单变更 MQ 音讯后对 ES 进行更新
- Binlog 计划
ES 更新服务借助 canal 等开源我的项目,把本人伪装成 MySQL 的从节点,接管 Binlog 并解析失去实时的数据变更信息,而后依据这个变更信息去更新 ES。
其中 BinLog 计划比拟通用,但实现起来也较为简单,咱们最终选用的是 MQ 计划。
因为 ES 数据只在治理后盾应用,对数据可靠性和同步实时性的要求不是特地高。
思考到宕机和音讯失落等极其状况,在后盾减少了按某些条件手动同步 ES 数据的性能来进行弥补。
3.4 如何平安地更换数据库
如何将数据从原来的单实例数据库迁徙到新的数据库集群,也是一大技术挑战
岂但要确保数据的正确性,还要保障每执行一个步骤后,一旦呈现问题,能疾速地回滚到上一个步骤。
咱们思考了停机迁徙和不停机迁徙的两种计划:
(1)不停机迁徙计划:
- 把旧库的数据复制到新库中,上线一个同步程序,应用 Binlog 等计划实时同步旧库数据到新库。
- 上线双写订单新旧库服务,只读写旧库。
- 开启双写,同时进行同步程序,开启比照弥补程序,确保新库数据和旧库统一。
- 逐渐将读申请切到新库上。
- 读写都切换到新库上,比照弥补程序确保旧库数据和新库统一。
- 下线旧库,下线订单双写性能,下线同步程序和比照弥补程序。
(2)停机迁徙计划:
- 上线新订单零碎,执行迁徙程序将两个月之前的订单同步到新库,并对数据进行稽核。
- 将商城 V1 利用停机,确保旧库数据不再变动。
- 执行迁徙程序,将第一步未迁徙的订单同步到新库并进行稽核。
- 上线商城 V2 利用,开始测试验证,如果失败则回退到商城 V1 利用(新订单零碎有双写旧库的开关)。
思考到不停机计划的革新老本较高,而夜间停机计划的业务损失并不大,最终选用的是停机迁徙计划。
3.5 分布式事务问题
电商的交易流程中,分布式事务是一个经典问题,比方:
- 用户领取胜利后,须要告诉发货零碎给用户发货。
- 用户确认收货后,须要告诉积分零碎给用户发放购物处分的积分。
咱们是如何保障微服务架构下数据的一致性呢?
不同业务场景对数据一致性的要求不同,业界的支流计划中,用于解决强一致性的有两阶段提交(2PC)、三阶段提交(3PC),解决最终一致性的有 TCC、本地音讯、事务音讯和最大致力告诉等。
这里不对上述计划进行具体的形容,介绍一下咱们正在应用的本地音讯表计划:在本地事务中将要执行的异步操作记录在音讯表中,如果执行失败,能够通过定时工作来弥补。
下图以订单实现后告诉积分零碎赠送积分为例。
3.6 系统安全和稳定性
- 网络隔离
只有极少数第三方接口可通过外网拜访,且都会验证签名,外部零碎交互应用内网域名和 RPC 接口。
- 并发锁
任何订单更新操作之前,会通过数据库行级锁加以限度,防止出现并发更新。
- 幂等性
所有接口均具备幂等性,不必放心对方网络超时重试所造成的影响。
- 熔断
应用 Hystrix 组件,对外部零碎的实时调用增加熔断爱护,避免某个系统故障的影响扩充到整个分布式系统中。
- 监控和告警
通过配置日志平台的谬误日志报警、调用链的服务剖析告警,再加上公司各中间件和根底组件的监控告警性能,让咱们可能可能第一工夫发现零碎异样。
3.7 踩过的坑
采纳 MQ 生产的形式同步数据库的订单相干数据到 ES 中,遇到的写入数据不是订单最新数据问题
下图右边是原计划:
在生产订单数据同步的 MQ 时,如果线程 A 在先执行,查出数据,这时候订单数据被更新了,线程 B 开始执行同步操作,查出订单数据后先于线程 A 一步写入 ES 中,线程 A 执行写入时就会将线程 B 写入的数据笼罩,导致 ES 中的订单数据不是最新的。
解决方案是在查问订单数据时加行锁,整个业务执行在事务中,执行实现后再执行下一个线程。
sharding-jdbc 分组后排序分页查问出所有数据问题
示例:select a from temp group by a,b order by a desc limit 1,10。
执行是 Sharding-jdbc 里 group by 和 order by 字段和程序不统一是将 10 置为 Integer.MAX_VALUE, 导致分页查问生效。
io.shardingsphere.core.routing.router.sharding.ParsingSQLRouter#processLimit
private void processLimit(final List<Object> parameters, final SelectStatement selectStatement, final boolean isSingleRouting) {boolean isNeedFetchAll = (!selectStatement.getGroupByItems().isEmpty() || !selectStatement.getAggregationSelectItems().isEmpty()) && !selectStatement.isSameGroupByAndOrderByItems();
selectStatement.getLimit().processParameters(parameters, isNeedFetchAll, databaseType, isSingleRouting);
}
io.shardingsphere.core.parsing.parser.context.limit.Limit#processParameters
/**
* Fill parameters for rewrite limit.
*
* @param parameters parameters
* @param isFetchAll is fetch all data or not
* @param databaseType database type
* @param isSingleRouting is single routing or not
*/
public void processParameters(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {fill(parameters);
rewrite(parameters, isFetchAll, databaseType, isSingleRouting);
}
private void rewrite(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {
int rewriteOffset = 0;
int rewriteRowCount;
if (isFetchAll) {rewriteRowCount = Integer.MAX_VALUE;} else if (isNeedRewriteRowCount(databaseType) && !isSingleRouting) {rewriteRowCount = null == rowCount ? -1 : getOffsetValue() + rowCount.getValue();} else {rewriteRowCount = rowCount.getValue();
}
if (null != offset && offset.getIndex() > -1 && !isSingleRouting) {parameters.set(offset.getIndex(), rewriteOffset);
}
if (null != rowCount && rowCount.getIndex() > -1) {parameters.set(rowCount.getIndex(), rewriteRowCount);
}
}
正确的写法应该是 select a from temp group by a desc,b limit 1,10;应用的版本是 sharing-jdbc 的 3.1.1。
ES 分页查问如果排序字段存在反复的值,最好加一个惟一的字段作为第二排序条件,防止分页查问时漏掉数据、查出反复数据,比方用的是订单创立工夫作为惟一排序条件,同一时间如果存在很多数据,就会导致查问的订单存在脱漏或反复,须要减少一个惟一值作为第二排序条件或者间接应用惟一值作为排序条件。
四、成绩
- 一次性上线胜利,稳固运行了一年多
- 外围服务性能晋升十倍以上
- 零碎解耦,迭代效率大幅晋升
- 可能撑持商城至多五年的高速倒退
五、结语
咱们在零碎设计时并没有一味谋求前沿技术和思维,面对问题时也不是间接采纳支流电商的解决方案,而是依据业务理论情况来选取最合适的方法。
集体感觉,一个好的零碎不是在一开始就被大牛设计进去的,肯定是随着业务的倒退和演进逐步被迭代进去的,继续预判业务倒退方向,提前制订架构演进计划,简略来说就是:走到业务的后面去!
作者:vivo 官网商城开发团队