《后端存储实战课》 的学习笔记,欢迎阅读斧正。
最少应该有以下几张表:
订单主表和字表是一对多关系,关联的外键是订单主表的主键(订单号)。
可能出现重复下单的场景:一种是就是用户多点了,另外就是网络错误导致(RPC、网关的重试机制)
解决方案:通过一个专门生成全局唯一订单号的服务,插入数据的时候将其保存
更新订单相关信息的时候可能出现。
解决方案:在表中加个时间戳或者版本的字段,每次查询的时候将其返回,更新的时候要比较下当前订单数据的版本号,是否和预期的一致。不一致直接拒绝更新,一致的话在一个事务中更新数据的同时将版本号+1。
一是高并发,商品的详情页每天都会有很高的 DAU(日均访问次数)。
二是商品数据规模的问题,比如下图中要存储的:
基本信息相对来说比较固定,可以在数据库中建表存储,也要提供缓存(比如 Redis、Memcached)帮助系统抗一些读请求。另外要注意的是,要保留商品的历史版本,因为订单中关联的商品数据必须是下单哪个时刻的商品数据,可以用一张历史表来保存。
商品参数指商品特征,比如内存大小、屏幕尺寸、口红色号等,和基本属性一样,是结构化的数据,但是不同类型的商品参数也是不一样的。对于属性不固定的数据来说,可以使用 MongoDB 来存储,因为 MongoDB 没有数据表要有固定结构的要求。
MongoDB 中的每一行数据,在存储层就是简单地被转化成 BSON 格式后存起来,这个 BSON 就是一种更紧凑的 JSON。所以,即使在同一张表里面,它每一行数据的结构都可以是不一样的。当然,这样的灵活性也是有代价的,MongoDB 不支持 SQL,多表联查和复杂事务比较孱弱,不太适合存储一般的数据。
图片和视频由于占用空间比较大,一般的存储方式是在数据库中只保留图片视频的 id 或者 url,实际的图片视频以文件方式存储,可以保存在对象存储(Object Storage)中。
对象存储可以简单理解为无限容量的大文件 KV 存储,key 是唯一的,对象是 value,可以通过 key 操作 value(写入、访问、删除)。对象存储提供了客户端 API,可以在 Web 页面或者 app 中直接访问,而不用通过后端服务。页面通过提供的 URL 直接访问,省事不占带宽,同时,对象存储云服务也提供了自带 CDN(Content Delivery Network)服务,响应时间比直接请求业务服务器更短。云服务厂商还会提供针对性优化,比如说缩放和转码服务。
商品介绍在商详页中占得比重是最大的,包含了大量的带格式文字、图片和视频。其中图片和视频自然要存放在对象存储里面,商品介绍的文本,一般都是随着商详页一起静态化,保存在 HTML 文件中。
静态化是相对于动态页面来说的。一般我们部署到 Tomcat 中的 Web 系统,返回的都是动态页面,也就是在 Web 请求时,动态生成的。比如说商详页,一个 Web 请求过来,带着 SKUID,Tomcat 中的商详页模块,再去访问各种数据库、调用后端服务,动态把这个商详页拼出来,返回给浏览器。
商详页的绝大部分内容都是商品介绍,它是不怎么变的。那不如就把这个页面事先生成好,保存成一个静态的 HTML,访问商详页的时候,直接返回这个 HTML。这就是静态化。
商详页静态化之后,不仅仅是可以节省服务器资源,还可以利用 CDN 加速,把商详页放到离用户最近的 CDN 服务器上,让商详页访问更快。
至于商品价格、促销信息等这些需要频繁变动的信息,不能静态化到页面中,可以在前端页面使用 AJAX 请求商品系统动态获取。这样就兼顾了静态化带来的优势,也能解决商品价格等信息需要实时更新的问题。
用户未登录,需要临时暂存购物车中的商品;
用户登录,将暂存购物车的商品加入到用户购物车,清空暂存购物车;
用户登录后,各端同步用户购物车。
如果存在服务端,需要唯一标识存储,浪费服务端资源,故需要存储在客户端。
存在客户端的途径:SESSION 不合适(时间短,SESSION 数据其实还是在服务端);Cookie 存储的话,实现简单,通过服务端读写 Cookie 实现具体的加减购物车合并购物车逻辑;LocalStorage 存储的话,实现复杂(客户端和服务端都需要实现逻辑),但是容量大,不用每次请求都携带,节省带宽。
还是推荐 MySQL,如果用 Redis 存在丢数据的情况,查询方式和事务都不如 MySQL。
引入 Redis 需考虑问题:
对账系统存在的目的:来核对、矫正账户系统和其他系统之间的数据差异。
每个账户系统都不是孤立存在的,至少要和财务、订单、交易这些系统有着密切的关联。理想情况下,账户系统内的数据应该是自洽的。所有用户的账户余额加起来,应该等于这个电商公司在银行专用账户的总余额。账户系统的数据也应该和其他系统的数据能对的上。比如说,每个用户的余额应该能和交易系统中充值记录,以及订单系统中的订单对的上。
记录流水,可以修改由于系统 bug 或者认为篡改导致的账户余额的问题。流水的数据模型至少要包含:流水 ID、交易金额、交易时间戳、交易双方的系统、账户、交易单号等信息。
在对账的时候,一旦出现了流水和余额不一致,并且无法通过业务手段来确定到底是哪儿记错了的情况,一般的处理原则是以交易流水为准来修正余额数据,这样才能保证后续的交易能“对上账”。
首先要保证只有记录流水的时候更新余额,不可以将更新余额单独提供;其次使用数据库事务,记录流水和修改余额两个操作要么都成功,要么都失败。
MySQL 事务和 ACID 相关内容不在此赘述
分布式环境下跨系统数据一致性问题。
2PC、3PC、TCC、Saga、本地消息表都是分布式事务的解决方案。
2PC(Two Phase Commit,两阶段提交),两个阶段指的是准备阶段和提交阶段。
以订单和优惠券为例:订单系统中需要在「订单优惠券表」中写入订单关联的优惠券数据以及在「订单表」中写入订单数据,两个操作的一致性可以通过数据库事务解决;促销系统中需要将优惠券的状态改为「已使用」。
在分布式系统下,我们需要上述两个系统的更新操作保持一致,要么都成功,要么都失败。
准备阶段要做的:除了提交数据库事务之外的所有工作,都需要在此阶段完成。如下述的两个系统步骤所示,二者都未提交事务。
订单系统在此阶段完成:
促销系统在此阶段完成:
协调者在收到两个系统发来的「准备成功」响应后,开始第二阶段。
等两个系统都准备好了之后,进入提交阶段。提交阶段就比较简单了,协调者再给这两个系统发送「提交」命令,每个系统提交自己的数据库事务,然后给协调者返回「提交成功」响应,协调者收到所有响应之后,给客户端返回成功响应,整个分布式事务就结束了。以下是这个过程的时序图:

先说准备阶段:如果任何一步出现错误或者超时,协调者就会给两个系统发送「回滚事务」的请求,两个系统收到请求后,回滚各自的数据库事务。

再说提交阶段:准备阶段成功,整个分布式事务只能成功,不能失败。
网络传输失败:反复重试,直到提交成功;
两个数据库宕机,服务节点宕机,订单提交,促销库宕机自动回滚,数据不一致:提交非常简单,非常快,这种情况概率非常小。
没必要单独启动一个事务协调服务,协调服务的工作最好和订单服务和优惠券服务在同一进程,两个好处:
优点:强一致性设计,保证原子性和隔离性,要么都成功,要么到失败
缺点:事务执行过程需要阻塞服务端的线程和数据库会话,并发场景下的性能不会很高,协调者是一个单点,过程中协调者宕机,导致订单库或者促销库的事务会话卡在等待提交阶段,直到事务超市自动回滚。
背景:下单做了两件事,第一件是创建一个新订单,关联的商品是购物车中的商品;第二件是清空购物车。
这也是一个分布式事务问题,创建订单和清空购物车两个操作需要要么都成功要么都失败。但是清空购物车的一致性不如 2PC 中说的扣减优惠券那么高,创建订单后晚几秒删除购物车的商品也是可以接受的,只要经过小的延迟时间后,最终订单数据和购物车的数据一致就行。
订单服务收到下单请求后,正常使用订单库的事务更新订单数据,并且执行更新数据库事务过程中,在本地记录一条日志,记录「清空购物车」操作。由于是在本地记录,就是一个普通的单机事务,所以完成这一步就可以给客户端返回成功响应。
然后再开一个一步的服务,去读取刚才所说的「清空购物车」的日志,调用购物车的服务清空购物车。清空之后,将刚才的日志状态更新为已完成。如果清空失败,可以通过重试解决,最终保持订单系统和购物车系统数据一致。
保证 ACID 中的持久性,对于 ACI 都比较差,优点是实现简单。
使用前提:异步执行的操作,不能有依赖的资源。比如说下单的时候还要锁定库存。如果使用异步进行锁定库存的操作,可能会出现缺货锁定失败的情况。
用 SQL 的 like 也能实现搜索,但是对于全文匹配来说,数据库的索引几乎派不上用场,只能全表扫描,非常慢。一般用 ES(Elasticsearch) 来实现搜索。
通过 ES 搜索东西快的原因之一是采用了倒排索引机制。

在 MySQL 中存储的结构可能是上图左侧,DOCID 是 key。
但是在 ES 中,将单词作为了索引的 key,每个 key 的值是一个列表,记录了包含单词的商品记录的 DOCID。当我们往 ES 里写入记录的时候,ES 会先对需要搜索的字段进行 分词。然后按照单词给商品记录做索引。

可以通过 mysqldump 命令来全量备份,使用 binlog 来增量备份,相关文档:MySQL:Backup and Recovery。
为了应对单机 MySQL 实例宕机后实例恢复可能会很耗时的问题,我们可以通过增加台备用的数据库,让其数据和主库一样。具体配置方法:MySQL Replication
主库数据更新时,主从两个库的更新顺序:
主从延迟:由于从库是通过读取主库的 binlog 来进行数据同步的,所以从库上的数据相对于主库来说是稍微旧一点的。
解决方案:可以牺牲性能,开始 MySQL 的 同步复制,开启后,MySQL 主库会等待数据成功复制到从库后再给客户端返回响应。这种情况下,如果从库宕机,主库会一直等待才哭,主库也卡死了。
