基于to C 电商业务的高并发高可用微服务架构设计

前言

最近一直在研究微服务的架构设计, 在研究技术的同时, 从免不了带入业务需求进行考虑, 否则仅凭性能也无法全面的评估这些设计的好坏, 想得多了, 自己都快记不住了, 觉得应该再开一篇博客记录下来, 说不定在记录的时候再次反思又能想到更多更深入更好玩儿的东西.
当然, 如果能够帮助到其他为此困扰的人, 那也是极好的.

第一节 说明

近期主要是拿to C 电商业务做为观察视角来研究架构设计, 那么核心的设计观点自然也主要围绕此展开.

另外需要提前强调, 直到本文结束宣布结果前, 都只是思维推导的过程, 切勿片面取之.

第二节 了解过去

首先我们先来看一张传统架构的设计图.
结构图

这是一种最经典的设计, 被使用了几十年的设计.
这种架构在功能上进行了分包, 所以虽然是提供整体服务, 但是内部功能模块依然是相对清晰的.
所以无论是前期设计和开发, 还是后期的迭代和维护, 因为都在一个项目中而变的简单方便.
在互联网发展早期, 也就是我们刚开始玩梦幻西游和魔兽世界的时代, 有谁会去知道高并发、高可用、负载均衡这些词汇? 在那个家里有电脑就是大土豪的时代, 做架构设计的人们根本不用考虑这些东西. 可是到了如今, 问题来了.

3G4G的普及, 让中国人直接跳过了个人电脑普及的阶段, 直接进入移动互联网时代. 从互联网发展了近10年才有的两亿网民, 两三年就翻到了五六亿, 到18年更是夸张到突破八亿.

传统的架构设计在这样的环境下就开始显得捉襟见肘, 即使上集群, 同时接受请求量起来了, 数据库也处理不过来, 即使数据也上集群了, 动不动单表过千万的数据量也会让随便一个查询就把硬盘IO和CPU占满.
所以, 很多时候经典往往也代表着过时.

当然, 过时的原因也不仅仅只有上述这一点.
很多采用这种架构设计的项目经过数年的发展, 每一个功能都经过了两三个人的手, 甚至有的功能还要多得多, 其中总会有一些没有完全搞清楚状况的人写的一些代码, 而往往就是这些代码极大增强了功能模块与功能模块之间的耦合性. 最后, 最后一波人拿到需要再继续迭代时发现, 哪儿哪儿都是牵一发动全身, 无从下手, 改无可改.
即使你很牛逼, 花大力气大成本把代码质量控制的非常高, 因为是单项目, 那么只能单语言开发就是迈不过去的坎, 比如需要新加一个逻辑并不复杂后期扩展也不大的功能模块儿, 单因为项目本身是用的Java|.net, 你不得不放弃可能会更便捷的PHP.
还有人才方面, 早期Web服务开发Java和.net可以说是分庭抗礼, 可是到了现在Java在早期开源和跨平台的优势加持下, 已经完全碾压了.net, 假如当年你的项目语言是选择的.net, 现在招人可说是痛苦至极.

第三节 微服务的崛起

在上一节中提到各种问题的折磨下, 一种叫微服务的架构思想应运而生.
注意, 这里说的是微服务架构思想, 微服务并不是一种架构设计, 而是一种架构思想. 因为微服务的灵活, 所以运用这种思想, 我们可以有非常多的设计方式, 具体要怎样来设计, 这一切取决于我们将要实现的业务的特性. 而在传统架构设计中, 中间矩形内的功能可以替换成任何功能, 可以说也是万金油架构设计.
所以我这篇Blog的标题才声明了这是to C的电商类微服务架构设计.

首先, 在这类业务中, 核心关键的主体应该是, 用户、商户、订单、库存, 所以我们还是只拿这四块儿来讨论, 其余可能还有商品、物流、支付等等, 自行往里套就可以了.
那么按照正常的服务垂直拆分法, 那么肯定应该拆分为用户服务、商户服务、订单服务以及库存服务.
这样拆分之后有什么问题呢?

  • 通信问题, 微服务间是肯定需要通信的, 不能像以前一样调个方法搞定了.
  • 分布式事务问题暨数据一致性问题, 微服务之间DB操作无法放在一个数据库事务中了.

通信问题好解决, 无非是增加一个通信层来提供服务调用接口, 增加点儿工作量的问题.
主流的通信方式目前主要有REST over HTTP(S)、RPC(TCP)两种, 性能上因为RPC是基于TCP协议, 而HTTP(S)协议则在TCP之上, 所以自然RPC性能略强一点. 但是, HTTP(S)协议的规范性、通用性我们不能忽略, 这可以让我们管理更轻松同时不用担心开发语言的兼容性. 正是因为这几点, 目前一些RPC模式的开源项目也开始该用HTTP(S)协议或者兼容HTTP(S)协议, 例如gRPC和微软的Windows RPC over HTTP.
综上所述, 我的选择是REST over HTTP(S), 这样的选择无论是服务开发者还是服务调用者都可以轻松愉快.

分布式事务问题就麻烦了, 比如这里的订单和库存, 当新增一个订单时, 库存自然也需要扣除订单里的商品的数量, 在传统架构设计中, 两个服务都在一个架构体系内, 在下单的方法或函数中直接可以调用扣除库存的方法或函数, 使之可以包含在同一个数据库事务中, 那么一旦哪里出现问题, 都可以完成回滚而不影响数据一致性. 可这时候订单和库存被我们拆分成了两个独立的服务, 所以不能享受同一个事务, 那么该如何来保证数据一致性呢? 首先我们先把逻辑梳理一遍, 搞清楚有哪些问题会影响数据一致性.
既然上面提到了服务之间通信使用HTTP(S)协议,
那么我们自然能想到HTTP(S)里面分为同步请求和异步请求两种, 当用户下单时, 自然是同步请求到订单服务, 因为要告知用户下单结果, 总不能异步请求, 然后靠推送告知用户下单结果, 那样体验自然是不佳的. 然后订单服务是否可以异步请求库存服务呢? 也不可以, 因为订单服务需要返回给用户下单结果, 而下单结果判断需要依赖库存服务返回的库存扣除结果, 在高并发环境下, 用户在选择商品时可能还是有库存的, 而确认下单时, 库存可能就已经没了, 例如一些秒杀活动, 所以这里也只能选择同步请求到库存服务, 库存服务检查库存确认支持本次扣除后完成扣返回结果或不能完成扣出返回结果, 订单服务根据返回的结果完成下单返回告知用户或返回结果告知用户售罄不能完成下单.
第一种情况, 库存服务发生未知异常, 无返回或HTTP(S)返回状态码非200.
这种情况订单服务无法判断库存服务是否已完成库存扣出, 因为不知道未知异常发生时库存服务是否已完成本地事务提交.
第二种情况, 请求库存服务超时.
这种情况订单服务依旧无法判断库存服务是否已完成库存扣出, 因为发生超时后又两种可能性, 有可能是库存服务连接数满拒绝服务, 也有可能是数据库出现慢SQL导致排队, 被拒绝服务则不会完成扣出, 而导致排队虽然请求超时, 但最后很可能完成扣出.

第一种情况的解决办法当然就时让订单服务知道事务是否已经提交, 在执行过程中产生任何异常信息一律往上返回, 最终以类似{error: 500, message: “兄弟我错了”, transaction_state: 1}的形式返回给调用者(当然也可以直接约定错误码段来表示事务已提交, 但这样会增加调用者的学习成本和理解难度).
举例: 如果我们的需求中交易完成后需要通知用户服务增加积分, 并且返回当前积分余额, 使用乐观锁方式增加余额成功并已提交事务, 因为是使用乐观锁, 所以必然已经查询过积分余额, 事务提交后, 我们直接在程序中计算增加后的积分余额, 结果这里代码不谨慎发生了异常, 最后返回{error: 500, message: “我傻逼了”, transaction_state: 1}给支付服务, 支付服务得知已经增加积分完成, 但后来发成了异常, 则可以重新调用用户服务中查询积分的服务来补偿丢失的数据, 从而完成这次服务.
至于无任何返回, 如果是使用Tomcat、JBoss等Web容器在未宕机前提下肯定不会发生无返回的情况, 遇到异常会返回非200的状态码并附带异常信息, 如果是使用其他无容器的语言, 比如说Golong, 如果发生异常后被panic, 则会产生无返回的情况, 那么我们需要避免这样的情况发生, 避免在流程中使用panic, 在Golang的官方建议中也提到, 除非你知道你自己在干什么, 否则不要使用panic.

第二种情况就要谈到一个微服务里一个很重要的术语, 幂等. 幂等是个数学名词, 听着好牛逼好高大上的样子, 其实通俗来讲很简单, 就是保证服务接口被重复调用时, 产生的结果或影响相同. 互联网早期玩论坛的用户肯定遇到过这样的情况, 那时候我们普遍都是小水管, 网速慢, 发一些长贴时点了确定后半天没反应, 然后我们就反复点确定, 结果发帖完成后返回列表时发现该板块已经被我们屠版了… 而幂等就是我们这次请求无论被提交多少次, 但是帖子只发一个. 如果我们的库存服务的扣除库存接口也实现了幂等的话, 订单服务就可以放心大胆的重试嘛, 超过指定重试次数以后告知用户失败就完事了, 如何实现库存服务的扣除库存接口的幂等呢, 无非要的结果就是在一次服务内你调用我多少次我都只执行一次, 那么我们就需要让我们的服务知道每次被调用是否是同一次服务, 所以就需要由我们的订单服务的下单接口定义一个Xid, 然后在调用库存服务的扣除库存接口时一起传递过来, 库存服务缓存这个ID及状态, 当扣除完成事务提交后立即更新此ID对应的状态, 最后返回结果. 每次接口被调用时, 先判断传入的Xid是否已存在, 当已存则说明上一次被调用已超时, 然后判断状态来得知扣除是否已经完成, 已完成则直接返回告知已完成, 未完成则返回告知订单服务请等待并为Xid对应更新一个重试次数(判断和更新重试次数在悲观锁中进行, 两个操作皆是内存操作, 所以不会对并发性能产生多少影响). 第一次服务请求终于执行完后判断重试次数是否已经打倒订单服务那边约定好的上限(这里依然使用悲观锁判断), 未达重试次数上限则提交本地事务, 等待下一次重试带回结果, 已达重试次数则回滚.
到这里, 虽然数据一致性问题被我们解决掉了, 但是可以看到, 对我们的业务代码侵入性非常大, 每一个接口的实现都需要增加大量为了保证数据一致性的代码, 降低开发效率不说, 还容易出错.
其实还有一种更优雅, 业务侵入几乎没有的方式来实现, TXC模式, 专业名词又来了, 好高大上好难理解有木有, 好吧, 简单来说就是SQL代理, 你们都把增删改的SQL给我, 我做为第三方来帮你们放在同一个事务内执行, 你们之间也解耦, 别在同步请求, 不用等对方返回, 统一由我来告诉你们结果.
具体逻辑是这样的, 订单服务的下单接口初始化一个全局唯一的Xid, 然后异步调用库存服务的扣除库存接口, 除了正常应该传入的参数外, 增加Xid的传入, 接着调用TXC, 传入Xid、insert 订单的SQL、依赖返回的接口的唯一标示暨库存服务的扣除库存接口的唯一标示, 自身接口唯一标示. 最后读取阻塞队列.
到库存服务的扣除库存接口里, 调用TXC时同上, 没有依赖返回的接口则留空. 最后读取阻塞队列.
TXC这边的原理是, 接到请求后以Xid为Key, 将调用者接口的唯一标示和其依赖的接口唯一标示存入缓存, 然后判断同一个Xid下其依赖的接口是否已经调用了TXC, 如果没有则结束本次请求(此时肯定没有). 等到库存服务的扣除库存接口调用TXC时, 同上, 根据Xid对应的依赖关系判断是否都已经调用TXC, 得到结果都已经调用, 开启事务执行SQl, 最后将结果写入队列.
在Java中有非常丰富的第三方开源资源可以使用, 无论是业务侵入多的TCC模式还是侵入量少的TXC模式, 都能很容易在GitHub上找到非常优质的开源资源, 其他语言可能就比较痛苦, 例如Golang, 我目前没有找到能够良好支持Golang的或者提供跨语言支持的开源分布式事务框架.

第三节 海量数据处理与存储

服务分好了, 只能算架构的一半弄好了, 还剩下最关键的数据库设计, 因为高并发往往意味着大数据, 如何接受这些数据以便提供高效的CRUD, 也是高并发高可用架构里需要绞尽脑汁的一环.
很多人觉得, 既然服务已经垂直拆分了, 数据库也垂直拆分呗, 这样不就分担了IO压力了么? 那我们来分析分析这样拆分给我们带来的到底是好还是坏.

  • 单表数据量过大问题并没有得到解决.
  • 再次面对数据库层面的分布式事务问题, 订单库存两个库如何保证数据一致性.
  • Join连表查询的问题, 跨库无法直接使用Join方便的查询出我们想要的结果集.

可见, 这样做一没有得到我们想要的, 二还会带来相当多的副作用. 完全的得不偿失.
所以对于我们最有利的还是水平分片法, 根据指定的规则, 将数据表进行水平分片, 例如根据时间分片、根据主键求模分片、根据字段取值范围分片等等. 依此将数据表分片成若干份, 根据需求放置不同的数据库中独享硬盘IO, 这样就解决了我们单表数据量过大的问题同时提升高并发性能. 不过这样好像依然给我们带来了上面提到的后两点副作用. 其实我们可以巧妙的利用分片规则, 从而也规避掉这两点副作用.

首先我们要明确单表数据量控制在多少能获得最佳性能, 这个视业务所需查询复杂度而定, 一般取值在500W-800W之间, 而to C业务一般查询逻辑都不怎么复杂, 所以推荐选择800W, 再者to C电商需求要分两种情况, 一种是to C的O2O类电商需求, 一种是to C的线上电商需求.

先来说O2O类需求

既然是线上对线下, 那么我们的四个主体必然一定是在同一个地区, 很显然, 我们最佳的分片规则即是按照地区来完成, 再者考虑到高并发下分担IO压力的问题, 则建议同时分库, 这样就可以分别部署在独享硬盘或服务器上, 现在假设拆分成出了成都库、德阳库两个数据库, 再假设我们有userbusinessorderinventory用户商户订单库存四张表, 在这四张表中, 地区相对固定的是商户订单库存三张表, 所以我们对这三张表使用按地区分片的策略, 用户表暂时放在一边, 如果某个地区发展特别好, 数据量依旧特别大了又怎么办呢? 所以分片规则不能只是根据地区, 而是地区范围, 在设计我们的地区字典表时, 地区的ID使用数字并且一个城市下的区县一定时连续的, 能够用范围来表示的, 例如: 1000-1100=CD, 可表示ID1000到1100的地区都是成都. 然后分片规则里用这样的范围规则来分片, 假设发展特别好的地区就是成都, 需要继续分片, 就可以这样1000-1010=cdjj、1011-1100=CD, 表示1000到1010的地区为锦江分片, 其他仍然是成都分片, 这样就把一个区单独拆分成一个分片, 然后将历史数据迁移到该新分片即可.
这样做的好处是, 可以永远保证同一个商户的订单和库存都存放在同一个分片库上, 那么使用join进行连表查询时则不需要多余的处理, 不存在跨库的问题.
但随着时间流逝订单数据量大的问题又来了, 这个时候订单就需要再增加一个分片规则, 按时间分片, 至于是按照年还是月甚至是小时, 还是按照单表数据量500W-800W原则来确定, 因为按地区分片已经分担了并发IO压力, 所以这里仅分表不用分库.

用户表的分片我们使用尽量平均的分片规则即可, 但这样做可以肯定无法控制用户数据和其订单数据存放在同一个分片上, 所以是否独立成库可根据并发量选择, 那么商户和用户需要查询历史订单怎么办呢? 商户的历史订单需要关联用户以显示用户昵称、电话等, 用户查询历史订单但订单却不在同一个分片表中, 很头疼吧.

商家这边的解决办法就是单独分页查询出订单列表(假设10条), 然后拿到10个用户ID, 再多线程并发id in ()到所有用户分片查询, 用户表分片数据库量控制在800W以下in主键的效率可控制在10毫秒以内, 因为是并发查询所以时间也不会叠加, 而用户分片一般项目很难超过20个, 20个可就是20 × 800 = 1.6亿用户量了, 所以瞬间消耗线程数也就是20以内.
用户查询历史订单的话, 我们不用并发查询所有订单分片, 因为订单是按地区和时间分片的, 所以我们需要设定一个允许用户查历史订单的期限, 一般是一年(美团、饿了么皆限制一年), 这就确定了12个分片(假设订单按月分片), 如果地区分片规则颗粒度很大, 例如按省分片的话, 则可以完成并发查询进行分页, 不过占用线程数就是36 × 12, 仍然有点浪费, 最好的办法是再建立一张表记录一年内用户产生过订单的地区, 该表跟随用户表一起分片, 保证俩存放在一个分片上, 用户登陆时join一起查询来并缓存, 这样就可以完全确定历史订单所在分片, 并发查询即可. 这里你的地区分片颗粒度越细, 线程资源消耗越大, 所以颗粒度选择一定不是越小越好, 一切遵照500W-800W原则, 否则就是浪费, 同时也需要根据实际线程资源消耗情况选择服务器配置及对微服务进行集群负载均衡.

再来分析纯线上类的需求

这里因为没有的地区属性, 所以我们可以执行使用平均分片的规则完成商户订单库存的分片, 但是考虑到上面提到过的Join问题, 所以不能这么简单, 正确的选择是仅商户使用平均分片的规则, 订单库存使用跟随分片规则, 跟随分片规则就是跟随商户进行分片, 加上ID为1的商户在0号分片, 那么订单和库存里跟商户Id为1绑定的数据也分片到0号分片, 这样即可保证同一个商户的订单和库存数据都存放在用一个分片库内, 继而解决join查询的问题.
另外订单当然还需根据时间再次分片, 考虑到纯线上的to C业务数据量做好了都会很夸张, 所以建议考虑按天分片, 可设置按指定天数分片, 初期可设置按30天甚至更多来分, 代码中实时监控数据量, 发现分片数据量超过警戒值后动态调整分片天数, 更加灵活应对波峰波谷(例如狂欢节什么的). 另外还建议按年对订单分片表进行归档, 不提供跨年的条件查询, 需要使用条件查询订单, 先选择年份, 避免查询需要的并发线程太大. 假设一年12亿个订单, 按800W一个分片算就是150个分片, 不按年归档3年下来一次查询就需要450个线程.

用户表的分片就个O2O的一样了, 商户查询订单列表需要关联用户的问题解决办法也是一样的, 有差别的就是用户查询历史订单了, 像淘宝目前是允许普通用户在自己所有订单里面进行搜索的,
而且个人感觉是非常有必要的, 因为O2O业务的商品一般是服务或者短质保期商品, 用户一般没有查询一年前订单的刚需, 而纯线上业务很可能存在几年以上质保期的商品, 那么用户搜索自己全部订单则变成了刚需功能, 否则无法获取售后服务. 可是几年下来订单表的总分片量可能非常大, 直接并发查询基本不可能, 所以只能再增加一张表来记录用户在订单的哪些分片上有订单, 该表同样跟随用户分片, 以此来减少并发查询量.

关于实现的建议

分析完数据库设计, 回过头来想想, 如果这些逻辑全部放在业务代码中去, 那么对业务编码人员的要求又是极高的, 所以最好依然是做成一个数据库中间件实现, 这样可以做到0业务侵入, 目前主流且相对容易一点的方式是自己实现一个所用语言的DB Driver替代你的数据库的DB Driver, 假设你使用的Java+Mysql, 那么你就拿jdbc-mysql.jar来改, 做成你自己的driver.jar. 如Insert SQL传过来之后, 在你的driver.jar中解析这句SQL, 应用分片规则得出应该插入哪个分片后, 在Insert到那个分片去.
当然, 也有开源的数据库中间件可以选择, 目前我自己测试使用感觉能用的只有Mycat, 但依然不能满足本文描述的架构设计, 不过好在开源, 而且是使用Java编写, 可以比较方便的进行无侵入或少量侵入的扩展.

留个Mycat实战的传送门吧: Mycat数据库中间件上手实践及分布式事务和读写分离实现

哦对了, 另外建议所有高查询热度的数据分片库都用主从同步实现一下读写分离, 进一步提高并发性能.
也留一个读写分离实战的传送门吧: MySQL主从同步和读写分离配置及理解

总结

不知不觉又写了怎么多了, 也算把这些年的思考一股脑全部记录下来了, 以后有新的思考应该还会继续更新, 毕竟性能优化这玩意儿既没有万金油更是无止无尽.
最后上一张总结性的结构图当作Ending吧.

结构图

我真不信有人能认认真真从头看到这里😂!Thanks for your reading😁!

文章目录
  1. 1. 前言
    1. 1.1. 第一节 说明
    2. 1.2. 第二节 了解过去
    3. 1.3. 第三节 微服务的崛起
    4. 1.4. 第三节 海量数据处理与存储
      1. 1.4.1. 先来说O2O类需求
      2. 1.4.2. 再来分析纯线上类的需求
      3. 1.4.3. 关于实现的建议
  2. 2. 总结
,