引言

订单系统可以说是整个电商系统中最重要的一个子系统,因此订单数据可以算作电商企业最重要的数据资产。这篇文章我们来看看在我们的商城系统中订单服务是如何实现的,特别是在设计和实现一个订单系统的过程中有哪些问题是需要特别考虑的。

业务分析

订单系统业务分析

对于一个合格的订单系统,最基本的要求是什么?数据不能出错。用户的每一次购物,从下单开始到支付、发货,再到收货,流程中的每个环节,都需要同步更新订单数据,每次更新操作可能都需要同时更新好几张表。这些操作可能会随机分发到不同的服务器节点上执行,服务器或网络都有可能会出问题,在这么复杂的情况下,如何保证订单数据不出错呢?

  • 第一,代码必须是正确的没有Bug,当然这个要求很简单也很复杂,全是bug系统无法正常运行,但是也没有什么系统能保证没有一个bug。当然要确保不能因为代码Bug而导致数据错误。
  • 第二,要能够正确地使用事务。比如,在创建订单的时候,如果需要同时在订单表和订单商品表中插入数据,那么我们必须在一个数据库事务中执行这些插入数据的INSERT 语句,数据库事务可以确保:执行需要同时进行的操作语句时,要么一起成功,要么一起失败。而实际上,在微服务下,仅仅使用数据库事务是不够的,很多时候还需要分布式事务。后面课程会专门的讲解分布式事务在项目中的实现。

即使满足了上面列举的这两个基本要求,某些特殊情况也仍然可能会引发数据错误,是什么样的数据错误问题?如何解决呢?都都会在后续讲述

在此之前,我们需要首先了解对于一个订单系统而言,它的核心功能和数据结构是怎样的。其实任何一个公司的电商系统,其订单系统的功能都是独一无二的,因为订单系统会基于其业务配置了很多的功能,并且都很复杂。因此我们的电商系统只能化繁为简,聚焦那些最核心的、共通的业务功能和数据模型,并且以此为基础讨论其中的实现技术。

订单系统的核心功能和数据表

为了支撑订单模块的必备功能,一般订单数据库中至少需要具备如下4张表。

  • 订单主表:也称订单表,用于保存订单的基本信息,也就是我们的order表。
  • 订单商品表:用于保存订单中的商品信息,也就是我们的order_item表。
  • 订单支付表:用于保存订单的支付和退款信息。
  • 订单优惠表:用于保存订单使用的所有优惠信息。

这4张表之间的关系是订单主表与后面的几个子表都是一对多的关系,关联的外键就是订单主表的主键,即订单ID。

在我们的商城系统中,做了适度的改造和简化,表现在:
因为取消了一般的促销优惠,自然没有专门的订单优惠表;
订单的是否支付和是否退款直接保存在订单主表中,没有设计单独订单支付表用以保存支付和退款相关信息;
我们系统中没有单独的库存系统,所以库存扣减由订单系统发起,由产品服务执行实际的扣减库存操作。
对应到存储上,则是oms_order作为订单主表,其中的status字段表示了订单状态,包括

0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单

产品的库存则是保存在产品服务product对应数据库tl_goods的sku_stock表中。

从上面我们对订单的流程描述来看,订单系统的实现其实并不复杂,就是标准的CRUD。

但是前面我们也说过,某些情况也仍然可能会引发数据错误,是哪些情况呢?我们来一一分析下。

订单重复下单问题

仔细分析一下订单创建的场景:订单系统为用户提供创建订单的HTTP接口,用户在浏览器页面上点击“提交订单”按钮,浏览器向订单系统发送一条创建订单的请求,订单系统的后端服务收到请求,向数据库的订单表中插入一条订单数据,至此,订单创建成功。

那么我们设想一下,用户在点击“提交订单”的按钮时,不小心点了两下,那么浏览器就会向服务端连续发送两条创建订单的请求,最终的结果将会是什么?很自然会创建两条一模一样的订单。这样肯定是不行的,因此我们还需要做好防重工作,怎么做呢?

可能有人会想到,前端页面上应该防止用户重复提交表单,当用户“提交订单”的按钮后,将该按钮置灰不可用。但是仔细想想,即使前端控制了用户不重复提交,网络错误也有可能会导致重传,很多RPC框架和网关都拥有自动重试机制,所以对于订单服务来说,重复请求的问题是客观存在的。

解决办法是,让订单服务具备幂等性。什么是幂等性?幂等操作的特点是,操作任意多次执行所产生的影响,均与一次执行所产生的影响相同。也就是说,对于幂等方法,使用同样的参数,对它进行多次调用和一次调用,其对系统产生的影响是一样的。

例如:

update tableA set count = 10 where id = 1

这个操作多次执行,id 等于1的记录中的 count字段的值都为10,这个操作就是幂等的,我们不用担心这个操作被重复。

update tableA set count = count + 1 where id = 1;

这样的SQL操作就不是幂等的,一旦重复,结果就会产生变化。

所以,不用担心幂等方法的重复执行会对系统造成任何改变。如果创建订单的服务具备幂等性,那么无论创建订单的请求发送了多少次,正确的结果都是数据库只有一条新创建的订单记录。

这里又会涉及一个不太好解决的问题:对于订单服务来说,如何判断收到的创建订单的请求是不是重复请求呢?

在插入订单数据之前,先查询一下订单表里面有没有重复的订单,是不是就可以做出判斯了呢?这个方法看起来容易。实际上却很难实现。原因是我们很难通过SQL的WHERE语句来定义“重复的订单”,如果订单的用户、商品、数量和价格都一样,是否就能认为它们是重复订单呢?这个其实是无法确定的,因为有可能用户就是连续下了两个一模一样的订单。

这个问题的思路是利用数据库的唯一约束来判断数据是否重复。在数据库的最佳实践中,其中一条是要求数据库的每个表都有主键。在非分库分表的情况下,我们在向数据库的表中插入一条记录的时候,无需提供主键,插入的同时由数据库自动生成一个主键。这样,重复的请求就会导致插入重复的数据。

表的主键是自带唯一约束的,如果我们在一条INSERT语句中提供了主键,并且这个主键的值已经存在于表中,那么这条INSERT语句就会执行失败,数据也不会成功插入表中。我们可以利用数据库的这种“主键唯一约束”特性,在插入数据的时候带上主键,来解决创建订单服务的幂等性问题。

具体做法如下:首先,为订单系统增加一个“生成订单号”的服务,这个服务没有参数,返回值就是一个新的、全局唯一的订单号。在用户进入创建订单的页面时,前端页面会先调用这个生成订单号的服务得到一个订单号,在用户提交订单的时候,在创建订单的请求中带着这个订单号。

这个订单号就是订单表的主键,这样,无论是用户原因,还是网络原因等各种情况导致的重试,这些重复请求中的订单号都是相同的。订单服务在订单表中插入数据的时候,这些重复的INSERT语句中的主键,都是同一个订单号。数据库的主键唯一约束特性就可以保证,只有一次INSERT语句的执行是成功的,这样就实现了创建订单服务的幂等性。

时序图如下:

所以,可以看到,在PortalOrderController中专门提供了generateOrderId方法供外部系统获得订单ID。

而秒杀系统相关的微服务中虽然没有提供类似的generateOrderId方法,但依然注意了避免重复下单问题,在生成订单确认信息时,将预先生成的订单ID传递给了前端订单确认页。

还有一点需要注意的是,在具体实现时,如果是因为重复订单导致插入订单表的语句失败,那么订单服务就不要再把这个错误返回给前端页面了。否则,就有可能会出现用户点击创建订单按钮后,页面提示创建订单失败,而实际上订单已经创建成功了。正确的做法是,遇到这种情况,订单服务直接返回“订单创建成功”的响应即可。
要做到这一点,可以捕获java.sql.SQLIntegrityConstraintViolationException或者org.springframework.dao.DuplicateKeyException来实现。

订单ABA问题和解决

订单系统中,各种更新订单的服务同样也需要具备幂等性。

更新订单的服务,比如支付、发货等这些步骤中的更新订单操作,最终都会落到订单库上,都是对订单主表进行更新操作。

比如对支付操作的数据库的更新操作、无论是执行一次还是重复执行多次,订单状态都是已支付,不用我们额外设置任何逻辑,这就是天然幂等性。

在实现这些更新订单的服务时,还有哪些问题需要特别注意呢?在并发环境下,我们需要特别注意ABA问题。

什么是更新下的ABA问题呢?我们知道并发编程下的CAS有ABA问题,这个ABA问题和并发的ABA问题有相似之处。我们来看这么一个例子:
订单支付完成,填入物流单号666提交后,发现填错了,修改成正确的单号888,对于订单服务来说,这里就产生了两个更新订单的请求。
按照我们的设想,正常情况下,订单中的快递单号会先更新成666,再更新成888,这是没有问题的。但是现实生活有很多不正常的情况,比如,更新成666的请求到了,快递单号更新成666,然后更新成888的请求到了,快递单号又更新成888。但是订单服务在向调用方返回666更新成功的响应时,这个响应在网络传输过程中丢失了。如果调用方没有收到成功响应,触发自动重试逻辑,再次发起更新成666的请求,快递单号将会再次更新成666,这种情况下数据显然就会出错了。这就是ABA问题。

那么ABA问题应该怎么解决呢?仔细想想并发编程里怎么解决ABA问题的?版本戳。所以这里同样可以使用版本戳。

为订单主表增加一列,列名可以叫 version、也就是“版本号”的意思。每次查询订单的时候,版本号需要随着订单数据返回给页面。页面在更新数据的请求时,需要把该版本号作为更新请求的参数再带回给订单更新服务。

订单服务在更新数据的时候需要比较订单当前数据的版本号与消息中的版本号是否一致,如果不一致就拒绝更新数据。如果版本号一致,则还需要在更新数据的同时,把版本号加1。当然需要特别注意的是,“比较版本号、更新数据和把版本号加1”这个过程必须在同一个事务里面执行,只有这一系列操作具备原子性,才能真正保证并发操作的安全性。

具体的SQL语句参考如下:

UPDATE orders set tracking_number = 666,version = version + 1 WHERE version = ?;

版本号的机制可用于保证,从打开某条订单记录开始,一直到这条订单记录更新成功,这期间不会存在有其他人修改过这条订单数据的情况。因为如果被其他人修改过,数据库中的版本号就会发生改变,那么更新订单的操作就不会执行成功,而只能重新查询新版本的订单数据,然后再尝试更新。

所以可以看到,在order中专门设计了version字段:

但是因为牵涉到更新订单的操作未执行成功(表现为update语句返回行数为0)时的重试机制,代码修改较大,所以在OmsOrderMapper.xml和相关订单业务方法中没有实现上述的ABA解决方案,感兴趣的同学可以自行调整。

总的来说,因为网络、服务器等导致的不确定因素,重试请求是普遍存在且不可避免的问题。具有幂等性的服务可以克服由于重试问题而导致的数据错误。

所以,总的来说,对于创建订单的服务,可以通过预先生成订单号作为主键,然后利用数据库中“主键唯一约束”的特性,避免重复写入订单,实现创建订单服务的幂等性。对于更新订单的服务,可以通过一个版本号机制,即在每次更新数据之前校验版本号,以及在更新数据的同时自增版本号这样的方式来解决ABA问题,以确保更新订单服务的幂等性。

通过这样两种具备幂等性的实现方法,我们可以保证,无论是不是重复请求,订单表中的数据都是正确的。

当然这里讲到的实现订单幂等性的方法,在其他需要实现幂等性的服务中也完全可以套用,只需要这个服务操作的数据保存在数据库中,并且数据表带有主键即可。

实现服务幂等性的方法,远不止本章介绍的这两种,其实,实现幂等性的方法可分为两大类,一类是通过一些精巧的设计让更新本身就是幂等的,这种方法并不能适用于所有的业务。另一类是利用外部的具备一致性的存储(比如 MySQL)来做冲突检测,在设计幂等方法的时候,通常可以顺着这两个思路来展开。

读写分离与分库分表

使用Redis 作为MySQL的前置缓存,可以帮助MySQL挡住绝大部分的查询请求。这种方法对于像电商中的商品系统、搜索系统这类与用户关联不大的系统、效果特别好。因为在这些系统中、任何人看到的内容都是一样的,也就是说,对后端服来说,任何人的查询请求和返回的数据都是一样的。在这种情况下,Redis 缓存的命中率非常高,几乎所有的请求都可以命中缓存。

但是与用户相关的系统(不是用户系统本身,用户信息等相关数据在用户登录时进行缓存,就价值很高),使用缓存的效果就没有那么好了,比如,订单系统、账户系统、购物车系统、订单系统等等。对于这些系统而言,各个用户查询的信息与用户自身相关,即使同一个功能界面,用户看到的数据也是不一样的。

比如,“我的订单”这个功能,用户看到的都是自己的订单数据。在这种情况下,缓存的命中率就比较低了,会有相当一部分查询请求因为命中不了缓存,穿透到 MySQL 数据库中。

随着系统的用户数量越来越多,穿透到MySQL 数据库中的读写请求也会越来越多,当单个MySQL支撑不了这么多的并发请求时,该怎么办?

读写分离

读写分离是提升 MySQL 并发能力的首选方案,当单个MySQL无法满足要求的时候,只能用多个MySQL实例来承担大量的读写请求。MySQL与大部分常用的关系型数据库一样,都是典型的单机数据库,不支持分布式部署。用一个单机数据库的多个实例组成一个集群,提供分布式数据库服务,是一件非常困难的事情。

一个简单且非常有效的是用多个具有相同数据的MySOL实例来分担大量查询请求,也就是“读写分离”。很多系统,特别是互联网系统,数据的读写比例严重不均衡,读写比例一般在9:1到几十比1,即平均每发生几十次查询请求,才会有一次更新请求,那就是说数据库需要应对的绝大部分请求都是只读查询请求。

分布式存储系统支持分布式写是非常困难的,因为很难解决好数据一致性的问题。但分布式读相对来说就简单得多,能够把数据尽可能实时同步到只读实例上,它们就可以分担大量的查询请求了。

读写分离的另一个好处是,实施起来相对比较简单。把使用单机MySQL的系统升级为读写分离的多实例架构非常容易,一般不需要修改系统的业务逻辑,只需要简单修改DAO (Data Access Object,一般指应用程序中负责访问数据库的抽象层)层的代码,把对数据库的读写请求分开,请求不同的MySQL实例就可以了。通过读写分离这样一个简单的存储架构升级,数据库支持的并发数量就可以增加几倍到十几倍。所以,当系统的用户数越来越多时,读写分离应该是首要考虑的扩容方案。

主库负责执行应用程序发来的数据更新请求,然后将数据变更同步到所有的从库中。这样,主库和所有从库中的数据一致,多个从库可以共同分担应用的查询请求。

读写分离的数据不一致问题

读写分离的一个副作用是,可能会存在数据不一致的问题。原因是数据库中的数据在主库完成更新后,是异步同步到每个从库上的,这个过程会有一个微小的时间差。正常情况下,主从延迟非常小,以几毫秒计。但即使是这样小的延迟,也会导致在某个时刻主库和从库上数据不一致的问题。

应用程序需要能够接受并克服这种主从不一致的情况,否则就会引发一些由于主从延迟而导致的数据错误。

回顾我们的订单系统业务,用户对购物车发起商品结算创建订单,进入订单页,打开支付页面进行支付,支付完成后,按道理应该再返回到支付之前的订单页。但如果这时马上自动返回到订单页,就很有可能会出现订单状态还是显示“未支付”的问题。因为支付完成后,订单库的主库中订单状态已经更新了,但订单页查询的从库中这条订单记录的状态可能还未更新,如何解决这种问题呢?

其实这个问题并没有特别好的技术手段来解决,所以可以看到,稍微上点规模的电商网站并不会支付完成后自动跳到到订单页,而是增加了一个支付完成页面,这个页面其实没有任何新的有效信息,就是告诉你支付成功的信息。如果想再查看一下刚刚支付完成的订单,需要手动选择,这样就能很好地规避主从同步延迟的问题。

如果是那些数据更新后需要立刻查询的业务,这两个步骤可以放到一个数据库事务中,同一个事务中的查询操作也会被路由到主库,这样就可以规避主从不一致的问题了,还有一种解决方式则是对查询部分单独指定进行主库查询。

总的来说,对于这种因为主从延迟而带来的数据不一致问题,并没有一种简单方便且通用的技术方案可以解决,对此,我们需要重新设计业务逻辑,尽量规避更新数据后立即去从库查询刚刚更新的数据。

分库分表

除了访问MySQL的并发问题,还要解决海量数据的问题,很多的时候,我们会使用分布式的存储集群,因为MySQI本质上是一个单机数据库,所以很多场景下,其并不适合存储TB级别以上的数据。

但是绝大部分电商企业的在线交易类业务,比如订单、支付相关的系统,还是无法离开MySQL的。原因是只有MySOL 之类的关系型数据库,才能提供金融级的事务保证。目前的分布式事务的各种解法方案多少都有些不够完善。

虽然 MySQL 无法支持这么大的数据量,以及这么高的并发需求,但是交易类系统必须用它来保证数据一致性,那么,如何才能解决这个问题呢?这个时候我们就要考虑分片,也就是拆分数据。

如果一个数据库无法支撑1TB的数据,那就把它拆分成100个库,每个库就只有10GB的数据了。这种拆分操作就是MySOL的分库分表操作。

如何规划分库分表

以订单表为例,首先,我们需要思考的问题是,选择分库还是分表,或者两者都有,分库就是把数据拆分到不同的MySQL 数据库实例中,分表就是把数据拆分到一个数据库的多张表里面。

在考虑到底是选择分厍还是分表之前,我们需要首先明确一个原则,那就是能小拆就小非,能少抖就小多拆。原因很简单,数据拆得越分散,并发和维护就越麻烦,系统出问题的概率也就越大。

遵循上面这个原则,还需要进一步了解,哪种情况适合分表,哪种情况适合分库。选择分厍或是分表的目的是解决如下两个问题。

第一,是为了解决因数据量太大而导致查询慢的问题。这里所说的“查询”,其实主要是事务中的查询和更新操作,因为只读的查询可以通过缓存和主从分离来解决。分表主要用于解决因数据量大而导致的查询慢的问题。

第二,是为了应对高并发的问题。如果一个数据库实例撑不住,就把并发请求分散到多个实例中,所以分库可用于解决高并发的问题。
简单地说,如果数据量太大,就分表;如果并发请求量高,就分库。一般情况下,我们的解决方案大都需要同时做分库分表,我们可以根据预估的并发量和数据量,分别计算应该拆分成多少个库以及多少张表。

商城订单模块实战 - 数据库设计、ABA问题处理、读写分离分库分表相关推荐

  1. 数据库主从复制,读写分离,分库分表理解 (数据库架构演变)

    主从复制 主从复制, 主要是针对MySQL数据库的高可用性, 容灾性上面.      是叫做高可用性? 高可用性可以简单的理解为容灾性, 稳定性, 针对故障,风险情况下的处理, 备案, 策略.  指系 ...

  2. 电子商务(电销)平台中订单模块(Order)数据库设计明细

    电子商务(电销)平台中订单模块(Order)数据库设计明细 以下是自己在电子商务系统设计中的订单模块的数据库设计经验总结,而今发表出来一起分享,如有不当,欢迎跟帖讨论~ 订单表 (order) |-- ...

  3. 电子商务(电销)平台中订单模块(Order)数据库设计明细(转载)

    电子商务(电销)平台中订单模块(Order)数据库设计明细 以下是自己在电子商务系统设计中的订单模块的数据库设计经验总结,而今发表出来一起分享,如有不当,欢迎跟帖讨论~ 订单表 (order) |-- ...

  4. 数据库面试 - 如何设计才可以让系统从未分库分表动态切换到分库分表上?

    数据库面试 - 如何设计才可以让系统从未分库分表动态切换到分库分表上? 面试题 现在有一个未分库分表的系统,未来要分库分表,如何设计才可以让系统从未分库分表动态切换到分库分表上? 面试官心理分析 你看 ...

  5. 冷热分离和直接使用大数据库_用读写分离与分表分库解决高访问量和大数据量...

    原标题:用读写分离与分表分库解决高访问量和大数据量 一. 数据切分 关系型数据库本身比较容易成为系统瓶颈,单机存储容量.连接数.处理能力都有限.当单表的数据量达到1000W或100G以后,由于查询维度 ...

  6. 现在有一个未分库分表的系统,未来要分库分表,如何设计才可以让系统从未分库分表动态切换到分库分表上?

    问题 现在有一个未分库分表的系统,未来要分库分表,如何设计才可以让系统从未分库分表动态切换到分库分表上? 分析 你看看,你现在已经明白为啥要分库分表了,你也知道常用的分库分表中间件了,你也设计好你们如 ...

  7. 学会数据库读写分离、分表分库

    https://www.cnblogs.com/joylee/p/7513038.html 系统开发中,数据库是非常重要的一个点.除了程序的本身的优化,如:SQL语句优化.代码优化,数据库的处理本身优 ...

  8. Netty游戏服务器实战开发(11):Spring+mybatis 手写分库分表策略(续)

    在大型网络游戏中,传统的游戏服务器无法满足性能上的需求.所以有了分布式和微服务新起,在传统web服务器中,我们保存用户等信息基本都是利用一张单表搞定,但是在游戏服务器中,由于要求比较高,我们不能存在大 ...

  9. 【ShardingSphere技术专题】「ShardingJDBC实战阶段」SpringBoot之整合ShardingJDBC实现分库分表(JavaConfig方式)

    前提介绍 ShardingSphere介绍 ShardingSphere是一套开源的分布式数据库中间件解决方案组成的生态圈,它由Sharding-JDBC.Sharding-Proxy和Shardin ...

最新文章

  1. 基于用户投票的排名算法(四):牛顿冷却定律
  2. androidstudio表格中填充 宽跟长一样_Excel表格的基本操作教程,覆盖表格制作的10大知识!...
  3. mysql 时间chuo格式化_Mysql时间戳与时间格式转换问题汇总
  4. MBR的Linux分区机制启动过程,linux系统启动流程(MBR)
  5. (转) 深度模型优化性能 调参
  6. C++动态连接库动态加载
  7. 记一次maven打包命令及指定pom文件
  8. 物流系统管理课程(九)
  9. 预加重,去加重和均衡
  10. cocos creator制作微信小游戏排行榜构建发布步骤
  11. 7-55 查询水果价格
  12. 抖音育儿类账号的创作灵感分享, 想进圈的不妨了解一下
  13. 求某年某月某日是星期几?Python
  14. 牛客寒假算法集训营1 小a与军团模拟器(启发式合并)
  15. 解决拉取远程分支后出现.xcodeproj Couldn't load project的问题
  16. 【Eclipse】关闭单词拼写检查
  17. 遗传算法流程概述与简单实例认知
  18. 安全组设置IP段 -- 示列
  19. C语言:extern用法
  20. jQuery 学习-样式篇(五):jQuery 设置元素的 html 结构或 text 内容

热门文章

  1. aspx 文件上传(简单)
  2. Python3.9.7英文版软件安装教程
  3. 综述(十五)国家对智能联网汽车的发展战略支持
  4. The web application [] appears to have started a thread named [Thread-
  5. php 面试常问的题目2
  6. 关于三维坐标系基本概念的一些另类理解
  7. html如何用百分比绝对定位,绝对定位元素的宽度百分比是多少?
  8. 爬虫框架 Scrapy 教程详解
  9. 安装双系统误删Windows引导,0xc0000098解决方案
  10. Android event log 说明