极客时间《后端存储实战课》阅读笔记
极客时间《后端存储实战课》阅读笔记
01 | 创建和更新订单时,如何保证数据准确无误?
插入防重复:预生成订单号
在插入数据,尤其是新建订单的时候,需要注意网络波动或者用户手抖等导致的重复订单问题,需要注意的是,这并不能完全依靠前端防抖来实现。1是依赖前端并不可靠;2.是在后端,比如说网关层可能就有重试的机制。
一个比较通用的解决方案是:让订单系统提供一个接口,不需要任何入参,返回一个全局唯一的订单号,也称为预生成订单号。
在创建订单号的时候,前端带着预生成的订单号去生成订单,这样就可以防止因为网络抖动或者用户手抖重复创建订单了。
而带着预生成的订单号怎么防止重复下单就比较简单了,一般是订单号作为主键,利用主键唯一来约束即可。当然,网络抖动等导致第二单重复创建的时候,用户友好考虑,虽然插入失败,但是可以直接给用户返回创单成功而不是失败。
考虑到后端一般以订单号为主键插入数据库,考虑到插入性能,因此生成的全局唯一的订单号一般是符合主键约束的单增的数字。
更新信息防ABA问题:版本号机制
举一个例子说明ABA更新的问题:用户修改订单的备注:当前是555,用户修改成666点击保存,但是发现输入错误,又改成888点击保存,发现成功了就退出了。
最后这个备注是多少呢?既又可能是666,也有可能是888.
对于后端来说,更新成666和更新成888就是两次更新请求,考虑到网络抖动和服务处理速度,实际到数据库执行来说,可能出现:更新成666了,但是响应丢失了。更新成888成功,响应成功。第一次更新666的服务端发现超时,尝试重试成功。
因此会出现666-》888-》666的情况,就是ABA问题。解决ABA问题也有一个比较他同识的方法:版本号机制,具体来说,增加一列版本号,每次查询都返回其他信息和版本号,每次更新的时候都需要带着old version number。
这样在更新的时候就可以写出类似的sql:
UPDATE orders set tracking_number = 666, version = version + 1
WHERE version = 8;
如果版本号不符合要求,自然无法更新成功。通过这样的机制,就可以保证当前的更新的时候看到的信息一定是最新的信息。
对于之前的例子,第二次(更新888)和第三次(重复更新666)都会失败!成功解决ABA问题。
这样的版本号也可以同时解决这样的问题:a设备拿着很久之前打开的网页去更新信息,然后发现信息对不上之类的问题。
02 | 流量大、数据多的商品详情页系统该如何设计?
商品详情页的特点如下,所以说详情页是一个需要好好设计的页面。
- 访问次数多
- 页面内容多:内容又多,形式又杂。包括文字、图片、视频各种。
详情页这种典型的get请求页面,需要做好的就是存储,在考虑缓存之前,我们需要先考虑非缓存的存储。
详情页内容形式比较杂,因此需要针对不同的内容进行不同形式的存储。
详情页内容主要包括:商品基本信息(颜色、大小等)、价格信息、参数信息、文字介绍、视频图片等。而评价、商家信息、其它商品等电商中其他系统负责的部分本次不讨论。
按照适合存储的介质,可以将详情页的内容如下分类:
-
文本信息:
- 方便结构化的:比如所有商品基本都有的大小、颜色、重量等,考虑使用MySQL等进行存储
- 不方便结构化的:某些商品特有的,比如防晒霜的防护指数、酒精的浓度等,某些商品才特有,这种商品分类比较少的时候可以考虑MySQL等进行结构化存储,但是数量多了建议使用MongoDB。其基于BJSON存储,不需要数据列相同。
-
图片和视频:图片和视频由于占用存储空间比较大,一般的存储方式都是,在数据库中只保存图片视频的 ID 或者 URL,实际的图片视频以文件的方式单独存储。而文件一般使用对象存储服务进行存储。
其它优化:
- 缓存:这个不必多说,当然还需要注意缓存一致性问题~
- 网页静态化:对于长期不变的网页信息(比如说商品介绍),为了减少服务器压力,可以考虑将网页部分直接静态化,即就把这个页面事先生成好,保存成一个静态的 HTML,访问商详页的时候,直接返回这个 HTML(毕竟NGINX能抗住的并发和后端服务能抗住的并发不在一个数量级)。对于价格之类的动态信息,在访问的时候再实时查询获取。
MongoDB 最大的特点就是,它的“表结构”是不需要事先定义的,其实,在 MongoDB 中根本没有表结构。由于没有表结构,它支持你把任意数据都放在同一张表里,你甚至可以在一张表里保存商品数据、订单数据、物流信息等这些结构完全不同的数据。并且,还能支持按照数据的某个字段进行查询。 它是怎么做到的呢?MongoDB 中的每一行数据,在存储层就是简单地被转化成 BSON 格式后存起来,这个 BSON 就是一种更紧凑的 JSON。所以,即使在同一张表里面,它每一行数据的结构都可以是不一样的。当然,这样的灵活性也是有代价的,MongoDB 不支持 SQL,多表联查和复杂事务比较孱弱,不太适合存储一般的数据。
03 | 复杂而又重要的购物车系统,应该如何设计?
一个很有意思的问题是对于购物车系统,是否可以通过MySQL上增加Redis缓存来提升性能?
如果随便想一想,那么很可能得出错误的答案:可以。实际上,缓存优化的是读性能,因此读多写少的场景才适合用缓存。我们考虑一下购物车这个场景,每个用户访问自己购物车的时候大概率是要下单或者修改购物车,所以缓存的优化其实很有限,反而大概率因为引入缓存带来了维护缓存的压力。
评论区:读多写少用缓存,写多读少用MQ。对于前者,前提是读场景频繁且能具备较高的命中率。用户购物车数据不符合该场景。
04 | 事务:账户余额总是对不上账,怎么办?
订单类似的系统,从业务角度来说不太需要记录全部历史记录。但是为了「对账」,在每一次变动的时候,都需要额外记录一个变动信息(流水信息),而且变动信息最好是:只能新增,不能删除或者改动。比如取消或者修改都是新增一行变动信息,记录下来操作被取消或者修改。
05 | 分布式事务:如何保证多个系统间的数据是一致的?
2PC、3PC、TCC、SAGA 和本地事务表等等。
需求:对于A、B两个服务实现分布式事务 。
2PC:需要强一致、并且并发量不大的场景
核心原理:引入一个事务协调者的概念(在实际实现中,事务协调者一般不会是一个单独的服务,就本地进程中运行即可),将分布式事务划分为两个阶段:准备阶段和提交阶段。
- 准备阶段:服务A和服务B都实现对应逻辑并写入数据,但是注意📢对应到数据库层面,系统A和系统B是开启事务但是不提交的。
- 提交阶段:服务A和服务B都提交事务。
可能出现数据不一致的情况:
准备阶段:无论哪个系统发生异常,由于事务没有提交,要么就超时、要么就回滚,总之没有数据不一致的情况。
提交阶段:只要进入提交阶段,就属于开工没有回头箭,只能继续执行了。如果因为网络超时,那么重试即可成功。但是如果服务A和服务B本身宕机,或者系统底层数据库宕机,那么确实会出现数据不一致的情况。但是实际中,因为提交阶段的执行时间很短,不太容易出现问题。
2PC适合的场景:强一致,并发量不大。2PC可以保证数据强一致,但整个事务的执行过程需要阻塞服务端的线程和数据库的会话,所以,2PC 在并发场景下的性能不会很高。并且,协调者是一个单点,一旦过程中协调者宕机,就会导致订单库或者促销库的事务会话一直卡在等待提交阶段,直到事务超时自动回滚。 卡住的这段时间内,数据库有可能会锁住一些数据,服务中会卡住一个数据库连接和线程,这些都会造成系统性能严重下降,甚至整个服务被卡住。
todo 待看下MySQL自身的两阶段提交的原理~感觉和2PC一模一样?
本地事务表:分布式最终一致性
核心原理:思路:将分布式事务转化成本地事务表中记录消息的本地事务。具体做法:服务A和服务B的分布式事务可以转变成:服务A开启事务后执行操作并在本地事务中记录服务B需要的操作,然后就可以直接提交了。服务A(或者其他服务)异步的根据这个消息去执行服务B的操作再更新事务表中数据状态即可。
可能出现数据不一致的情况:
异步的操作服务B的时候:因为这一步的异步操作可能会失败,重试即可。只要不是一直不断失败。同时,在服务B操作完成之前,会存在暂时的数据不一致。
本地事务表适合的场景:
- 异步执行的那部分操作,不能有依赖的资源。比如说,我们下单的时候,除了要清空购物车以外,还需要锁定库存,就不适合本地事务表了,因为这样第二步的异步操作会一直失败导致数据不一致!
- 最终一致性:允许出现暂时的数据不一致。
此外,事务消息本质上和本地事务表是一个思路,在使用上还更加简单!
TCC
2025年06月09日00:45:56 待开始~
TCC 方案,该方案天生适合用于需要强隔离性的分布式事务中。
核心思想是针对每个操作都要注册一个与其对应的确认(Try)和补偿(Cancel) 。如同名字,TCC 整个事务流程由三个阶段组成:
- Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)。
- Confirm 阶段:如果所有分支的Try都成功了,则走到Confirm阶段。Confirm真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源。
- Cancel 阶段:如果所有分支的Try有一个失败了,则走到Cancel阶段。Cancel释放 Try 阶段预留的业务资源。
按照 TCC 的协议,Confirm 和 Cancel 是只返回成功,不会返回失败。如果由于网络问题,或者服务器临时故障,那么事务管理器会进行重试,最终成功。
以一个下单服务为例,说明 TCC 事务处理流。该下单服务由两个系统操作完成:订单系统 X、资金账户系统 Y。
- Try 操作 : try X 下单系统创建待支付订单。try Y 自己账户系统冻结订单金额 100 元。
- Confirm 操作 confirm X 订单更新为支付成功。confirm Y 扣减账户系统 100 元。
- Cancel 操作 Cancel X 订单异常,资金退回,Cancel Y 扣款异常,订单支付失败
由上述操作过程可见,TCC 其实有点类似 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度,不会导致数据库层面的阻塞。但是相对而言,其代码复杂度应该比2PC更高,毕竟2PC是数据库的回滚,而TCC的Cancel需要业务上去回滚。
SAGA
SAGA适用于长时间多流程的分布式事务,大致的思路是把一个大事务分解成多个可交错运行的子事务集合,并在每个子事务中引入补偿操作。
在 SAGA 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
SAGA 由两部分操作组成。
-
一部分是将大事务 T 拆分成若干小事务,命名为 T1,T2,Tn。每个子事务被应被视为原子行为,如果分布式事务 T 能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。
-
另一部分是为每个子事务设计对应的补偿动作,命名为 C1,C2,Cn。Ti 与 Ci 满足以下条件:
- Ti 与 Ci 具备幂等性。
- Ci 必须能成功提交,即不考虑 Ci 的失败回滚情况,如果出现失败持续重试直至成功或者被人工介入为止。
如果 T1 到 Tn 均执行成功,那么整个事务顺利完成,否则要根据下面两种恢复策略之一进行恢复。
- 正向操作(Forward Recovery) 如果 Ti 提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要进行补偿,适用于事务最终都要执行成功的情况,譬如订单服务中银行已经扣款,那么就一定要发货。
- 逆向回滚(Backward Recovery) 如果 Ti 提交失败,则执行对应的补偿 Ci,直至恢复到 Ti 之前的状态,这里要求 Ci 必须成功(持续重试,最大努力交付)。
由于存在重试的机制,这也是为什么Ti需要具备幂等性。
相比于TCC,SAGA更适合长时间执行,保证最终一致性的事务,且不需要提前锁定资源的操作。
- 不需要提前锁定资源:这一点虽然简单,但是失败事务失败时不一定是一件好事,因为相比于TCC的提前占据资源,SAGA是实际执行事务然后回测,业务上可能更加复杂。
- 谁来执行事务:TCC是由事务发起方调度,而SAGA可以由服务发起方来调度,也可以没有中心调度节点,比如Ti执行的完毕发送mq消息,其它节点订阅消息进行后续操作。
“Saga”一词源自古诺尔斯语(Old Norse),原指冰岛和中世纪北欧流传的长篇英雄传说或历史叙事(如《尼雅尔萨迦》),特点是故事跨度长、情节复杂且分阶段展开。这一概念被借用到分布式事务领域,是因为SAGA事务模式同样将一个长时间运行的大事务(Long Lived Transaction)分解为多个有序的子事务,每个子事务像“故事章节”一样逐步推进,最终完成整体事务的叙述。
事务消息
见消息队列实现分布式事务|事务消息1,其本质和事务消息相同,只是介质不同。
参考:
5.3.2 TCC | 深入架构原理与实践
06 | 如何用Elasticsearch构建商品搜索系统?
在 ES 里面,数据的逻辑结构类似于 MongoDB,每条数据称为一个 DOCUMENT,简称 DOC。DOC 就是一个 JSON 对象,DOC 中的每个 JSON 字段,在 ES 中称为 FIELD,把一组具有相同字段的 DOC 存放在一起,存放它们的逻辑容器叫 INDEX,这些 DOC 的 JSON 结构称为 MAPPING。
MySQL与Elasticsearch的名词对应关系:
因为用户每输入一个字都可能会发请求查询搜索框中的搜索推荐。所以搜索推荐的请求量远高于搜索框中的搜索。es针对这种情况提供了suggestion api,并提供的专门的数据结构应对搜索推荐,性能高于match,但它应用起来也有局限性,就是只能做前缀匹配。再结合pinyin分词器可以做到输入拼音字母就提示中文。如果想做非前缀匹配,可以考虑Ngram。不过Ngram有些复杂,需要开发者自定义分析器。比如有个网址www.geekbang.com,用户可能记不清具体网址了,只记得网址中有2个e,此时用户输入ee两个字母也是可以在搜索框提示出这个网址的。以上是我在工作中针对前缀搜索推荐和非前缀搜索推荐的实现方案。
ES最不擅长的就是深分页了……所以,据我所知,没什么好的解决方案。
07|MySQL HA:如何将“删库跑路”的损失降到最低?
原理
一般是两个措施结合使用来保证数据库可以:
- 定时全量备份
- 使用binlog回放
定时全量备份的目的是加快数据回放到某一个时间点。虽然binlog可以完成这一步,但是总不能从建表开始回放吧,这样的话无论是耗时还是binlog有没有保存这么久都是问题。
使用binlog回放的目的是精确指定时间,因为「全量备份」的频次一般来说最高是天级别,还需要binlog来指定回放时间段。在回放 Binlog 的时候,指定的起始时间可以比全量备份的时间稍微提前一点儿,确保全量备份之后的所有操作都在恢复的 Binlog 范围内,这样可以保证恢复的数据的完整性。且binlog的回放是幂等的(在binlog的格式是row格式的前提下,大家一般也都是这么设置的,具体格式区别见为啥用binlog格式为row来解决问题,或者说binlog的格式为statement会有什么问题?首先看下两种不同的binlog的格式有什么区别,然后这个问题就可以解决了。 statemen...2 )!
操作
全量备份数据库test
$mysqldump -uroot -p test > test.sql
备份出来的文件就是一个 SQL 文件,如何执行:
$mysql -uroot test < test.sql
show variables like ‘%log_bin%’”命令确认一下是否开启了 Binlog 功能:
mysql> show variables like '%log_bin%';
+---------------------------------+-----------------------------------+
| Variable_name | Value |
+---------------------------------+-----------------------------------+
| log_bin | ON |
| log_bin_basename | /usr/local/var/mysql/binlog |
+---------------------------------+-----------------------------------+
mysql> show master status;
+---------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+---------------+----------+--------------+------------------+-------------------+
| binlog.000001 | 18745 | | | |
+---------------+----------+--------------+------------------+-------------------+
Binlog 恢复数据:在回放 Binlog 的时候,指定的起始时间可以比全量备份的时间稍微提前一点儿,确保全量备份之后的所有操作都在恢复的 Binlog 范围内,这样可以保证恢复的数据的完整性。
$mysqlbinlog --start-datetime "2020-02-20 00:00:00" --stop-datetime "2020-02-20 15:09:00" /usr/local/var/mysql/binlog.000001 | mysql -uroot
高可用
08 | 一个几乎每个系统必踩的坑儿:访问数据库超时
2025年06月11日14:00:23 待开始 todo
需要知道的一点是,当数据库非常忙的时候,它执行任何一个 SQL 都很慢。所以,并不是说,慢 SQL 日志中记录的这些慢 SQL 都是有问题的 SQL。大部分情况下,导致问题的 SQL 只是其中的一条或者几条。不能简单地依据执行次数和执行时长进行判断,但是,单次执行时间特别长的 SQL,仍然是应该重点排查的对象。
10 | 走进黑盒:SQL是如何在数据库中执行的?
一条 SQL 在数据库中执行,首先 SQL 经过语法解析成 AST,然后 AST 转换为逻辑执行计划,逻辑执行计划经过优化后,转换为物理执行计划,再经过物理执行计划优化后,按照优化后的物理执行计划执行完成数据的查询。几乎所有的数据库,都是由执行器和存储引擎两部分组成,执行器负责执行计算,存储引擎负责保存数据。
11 | MySQL如何应对高并发(一):使用缓存保护MySQL
Cache Aside 模式和上面的 Read/Write Through 模式非常像,它们处理读请求的逻辑是完全一样的,唯一的一个小差别就是,Cache Aside 模式在更新数据的时候,并不去尝试更新缓存,而是去删除缓存。
实战注意:缓存穿透,可以考虑预热缓存,两种方案:少量放量真实流量预热或者主动预热缓存。
12 | MySQL如何应对高并发(二):读写分离
如果你配置了多个从库,推荐你使用“HAProxy+Keepalived”这对儿经典的组合,来给所有的从节点做一个高可用负载均衡方案,既可以避免某个从节点宕机导致业务可用率降低,也方便你后续随时扩容从库的实例数量。因为 HAProxy 可以做 L4 层代理,也就是说它转发的是 TCP 请求,所以用“HAProxy+Keepalived”代理 MySQL 请求,在部署和配置上也没什么特殊的地方,正常配置和部署就可以了。
组件 | 作用 | 组合后的效果 |
---|---|---|
HAProxy | 负载均衡、流量分发 | 多节点后端服务的流量管理 |
Keepalived | 高可用、提供VIP 、故障切换 | HAProxy 节点的高可用性保障 |
客户端 -> [VIP] -> HAProxy 主节点(正常) ↘ HAProxy 从节点(备用/热备) ↘ 后端服务器集群
豆包:这种组合在云原生时代仍是经典方案之一(虽然现在也有 Kubernetes Ingress + etcd 等替代方案),但其轻量、稳定、低延迟的特点在传统架构中依然不可替代。
13 | MySQL主从数据库同步是如何实现的?
MySQL 从 5.7 版本开始,增加一种半同步复制(Semisynchronous Replication)的方式。异步复制是,事务线程完全不等复制响应;同步复制是,事务线程要等待所有的复制响应;半同步复制介于二者之间,事务线程不用等着所有的复制成功响应,只要一部分复制响应回来之后,就可以给客户端返回了。 比如说,一主二从的集群,配置成半同步复制,只要数据成功复制到任意一个从库上,主库的事务线程就直接返回了。这种半同步复制的方式,它兼顾了异步复制和同步复制的优点。如果主库宕机,至少还有一个从库有最新的数据,不存在丢数据的风险。并且,半同步复制的性能也还凑合,也能提供高可用保证,从库宕机也不会影响主库提供服务。所以,半同步复制这种折中的复制方式,也是一种不错的选择。
“rpl_semi_sync_master_wait_slave_count”,含义是:“至少等待数据复制到几个从节点再返回”。这个数量配置的越大,丢数据的风险越小,但是集群的性能和可用性就越差。最大可以配置成和从节点的数量一样,这样就变成了同步复制。
“rpl_semi_sync_master_wait_point”,这个参数控制主库执行事务的线程,是在提交事务之前(AFTER_SYNC)等待复制,还是在提交事务之后(AFTER_COMMIT)等待复制。默认是 AFTER_SYNC,也就是先等待复制,再提交事务,这样完全不会丢数据。AFTER_COMMIT 具有更好的性能,不会长时间锁表,但还是存在宕机丢数据的风险。
复制状态机:所有分布式存储都是这么复制数据的
这种基于“快照 + 操作日志”的方法,不是 MySQL 特有的。
Redis Cluster 中,它的全量备份称为 Snapshot,操作日志叫 backlog,它的主从复制方式几乎和 MySQL 是一模一样的。
Elasticsearch,它是一个内存数据库,读写都在内存中,那它是怎么保证数据可靠性的呢?对,它用的是 translog,它备份和恢复数据的原理和实现方式也是完全一样的。这些什么什么 log,都是不同的马甲儿而已,几乎所有的存储系统和数据库,都是用这一套方法来解决备份恢复和数据复制问题的。
14 | 订单数据越来越多,数据库越来越慢该怎么办?
在分库分表之前,还有一个首选方案:归档历史数据。这么做是因为订单这样的数据具有很强的热近效应,历史数据无论是查询还是修改都非常少。
而且归档相比于分库分表还有一个优势:对于历史代码改动小。(当然如果最开始就做了分库分表的设计,自然也就不存在归档这一说法了)
使用APP的时候,经常可以看到:点击查看三个月之前的订单的按钮,其实就是因为「归档历史数据」设计引发的。
迁移大概流程:
在迁移完成之后,我们就需要考虑:删除热库中的订单数据这一步了。
- 如何删除大批量的数据:两个注意点:分批删除 + 尽量使用主键删除。分批删除即使用
limit xxx
循环删除,分批删除好处太多:避免MySQL压力,减少可能的锁表时间(如果删除where没有命中索引,是可能会锁表的);主键删除:算是进一步优化吧。归档场景一般按照时间归档,可以找到归档时间中最大的主键id,因为id递增,所以可以把删除条件换为:id<{max id}
- 删除后表大小为什么还没减小:简短来说:mysql删除表只是将空间标记为无效,并没有实际释放磁盘空间,释放空间需要执行
Alter table {table_name} engine = innodb
相关操作,具体:13 为什么表数据删掉一半,表文件大小不变?3
无端联想:limit非常有用,在执行ddl的时候,如果是update或delete操作,需要强制写上
limit 1
。
15 | MySQL存储海量数据的最后一招:分库分表
大厂在线交易这部分的业务,比如说,订单、支付相关的系统,还是舍弃不了 MySQL,原因是,只有 MySQL 这类关系型数据库,才能提供金融级的事务保证。 我们之前也讲过分布式事务,那些新的分布式数据库提供的所谓的分布式事务,多少都有点儿残血,目前还达不到这些交易类系统对数据一致性的要求。
在考虑到底是分库还是分表之前,需要先明确一个原则,那就是能不拆就不拆,能少拆不多拆。原因也很简单,你把数据拆分得越散,开发和维护起来就越麻烦,系统出问题的概率就越大。
分库分表之后,对查询来说也是有很多限制的,比如说查询如果没有带Sharging Key(分表列),那么就会查询所有的分表,再聚合结果,查询性能损耗极大。
分库分表的目的是解决:高并发、查询数据量大的问题。简单地说,数据量大,就分表;并发高,就分库。
选择合适 Sharding Key 和分片算法非常重要,直接影响了分库分表的效果。此外,千万需要考虑热点问题!
Sharding Key
如何选择 Sharding Key :选择 Sharding Key 最重要的参考因素是,我们的业务是如何访问数据的。
比如说:
如果我们业务最常的方式是根据订单ID作为查询条件,那么就可以以订单ID为Sharding Key。
但以订单ID为Sharding Key,那么这时候用户想查询自己的订单,就没法使用Sharding Key了,因此可以考虑:
1.让用户ID成为订单的一部分,比如生成订单的时候,最后几位为用户ID;
2.将系统同步到其他存储(比如ES)来进行查询;
需要注意的是:将系统同步到其他存储(比如ES)来进行查询,如果需要更详细的信息再进一步根据订单ID进MySQL查询是一个常用的做法。
2更加常用,如果又有其他查询需要,比如查询某个商户下的订单,某个城市下产生的订单,「1」是无法在系统设计初期完全考虑到的。
对于热点问题,设计的时候时刻保持注意注意即可。这里介绍解决热点问题的一个冷门的方法:手动创建分片映射:
分片算法
分片算法如何不合理的话会产生热点问题!
一般有三种分片方式:
范围分片容易产生热点问题,但对查询更友好,适合并发量不大的场景;
哈希分片比较容易把数据和查询均匀地分布到所有分片中;
查表法更灵活,但性能稍差。
一般实际运用中,采用哈希分片比较多,而考虑到性能、处理方便,一般会直接使用sharding key % 分片数量
的方式来分片。
实际上:
sharding key % 分片数量
的方式并没有严谨的打散分片,还是有可能会产生热点问题。但是其好处也是很明显的:处理快速、不会出错、排查问题需要手动查询的时候也很方便,所以还是需要综合考虑数据热点的可能性。
17 | 大厂都是怎么做MySQL to Redis同步的?||缓存一致性
在【wip】缓存一致性保证4中,针对缓存一致性的保障,针对先改后改和先改后删方案都分别提出了并发限制和lease的机制来保证缓存一致性。
这里提出了另一种解题的思路:
使用mq或者Canal订阅mysql的binlog变动来更新缓存,具体来说:通过mq或者Canal订阅binlog变动,然后消费者根据binlog去更新缓存。
这样做的方式类似于先改后改,不过对缓存的“最终一致性”有更强的保证。
好处:保证缓存一定能更新成功(原方案极小可能服务器宕机导致数据不一致);如何配合上顺序消费保证,可以保证缓存和数据库的最终一致性!
坏处:数据一致性的延迟略高,毕竟中间又多了两步!需要考虑一旦出现不同步问题时的降级或补偿方案。
18 | 分布式存储:你知道对象存储是如何保存图片文件的吗?
对象存储是最简单的分布式存储系统,主要由数据节点集群、元数据集群和网关集群(或者客户端)三部分构成。数据节点集群负责保存对象数据,元数据集群负责保存集群的元数据,网关集群和客户端对外提供简单的访问 API,对内访问元数据和数据节点读写数据。 为了便于维护和管理,大的对象被拆分为若干固定大小的块儿,块儿又被封装到容器(也就分片)中,每个容器有一主 N 从多个副本,这些副本再被分散到集群的数据节点上保存。 对象存储虽然简单,但是它具备一个分布式存储系统的全部特征。所有分布式存储系统共通的一些特性,对象存储也都具备,比如说数据如何分片,如何通过多副本保证数据可靠性,如何在多个副本间复制数据,确保数据一致性等等。
20 | 如何在不停机的情况下,安全地更换数据库?
我把这个复杂的切换过程的要点,按照顺序总结成下面这个列表,供你参考:
- 上线同步程序,从旧库中复制数据到新库中,并实时保持同步(一般可以用订阅binlog实现);
- 上线双写订单服务,只读写旧库;
- 开启双写,同时停止同步程序; 开启对比和补偿程序,确保新旧数据库数据完全一样;
- 逐步切量读请求到新库上;
- 下线对比补偿程序,关闭双写,读写都切换到新库上;
- 下线旧库和订单服务的双写功能。
其中值得注意的地方:
-
对比补偿程序存在的意义:启动双写和停止同步程序之间是无法做到无缝,这期间数据会存在一些不一致;切换过程中可能由于代码逻辑问题或者网络超时等问题导致数据不一致。
-
对比补偿程序没有一个统一严谨的方式,只能根据业务场景去分析:比如订单,完成状态的订单是不修改的,因此可以按照订单维度去同步等
-
关闭双写之前都是可以无损回滚的,关闭双写之后无法无损的回滚了!
23 | MySQL经常遇到的高可用、分片问题,NewSQL是如何解决的?
对于CockroachDB这种new SQL,其只带云原生属性(天生就是为了分布式集群设计),且可以说提供了MySQL这样的ACID保证,值得关注。
其架构:
可以从架构图中看出其支持分布式SQL的能力来源于其底层的分布式KV数据库底座,借助于分布式KV数据库的能力,其很容易达到分布式这一目的。
但是也是因此,其对于ACID的保证不和MySQL完全一样,其有两种事务的隔离级别:SI和SSI:
先从SI开始,SI的隔离是基于快照的,看起来其不会有脏读、不可重复读和幻读的问题,但是其有写倾斜(Write Skew)的问题。
写倾斜(Write Skew)中 “倾斜” 在此处是一个数学/物理隐喻:
- 正常情况:事务并发修改时,数据状态应沿正确方向演变(如账户总和保持正值)
- 异常情况:多个事务的独立操作产生“合力偏移”,使数据状态滑向错误方向(如总和意外变负)
CockroachDB 的 写倾斜(Write Skew) 是一种特定类型的并发事务异常,指多个事务同时读取同一数据集,并根据读取结果独立修改不同数据项,最终导致违反全局业务逻辑约束的现象。其核心特点是:事务间无直接数据冲突,但组合结果不正确。
一个经典案例是主副卡转账问题:
-- 事务A(修改主卡)
BEGIN;
SELECT * FROM accounts WHERE type IN ('主卡','副卡'); -- 读数据集
UPDATE accounts SET balance = balance - 200 WHERE id = '主卡';
COMMIT;-- 事务B(修改副卡,并发执行)
BEGIN;
SELECT * FROM accounts WHERE type IN ('主卡','副卡'); -- 读相同数据集
UPDATE accounts SET balance = balance - 200 WHERE id = '副卡';
COMMIT;
核心矛盾:
-
表面无冲突:
- 事务A修改
主卡
,事务B修改副卡
→ 无数据行重叠 - 传统行锁(如
SELECT FOR UPDATE
)只能锁定具体行 → 锁不住“逻辑约束”
- 事务A修改
-
真实冲突点:
- 业务要求:
主卡+副卡总和 > 0
→ 这是个跨行的逻辑约束 - 事务各自检查时总和为150(>0),但未考虑对方修改 → 约束被绕过
- 业务要求:
CockroachDB通过 Serializable Snapshot Isolation (SSI) 实现 “约束感知”的冲突检测:
-
事务读取时记录 逻辑范围(如
WHERE type IN ('主卡','副卡')
) -
提交时检查:是否有其他事务修改过此范围内的数据?
- ✅ 事务A提交时:发现事务B修改了“副卡”(在读取范围内)→ 中止
- ✅ 事务B提交时:发现事务A修改了“主卡” → 中止
-
最终只有先提交的事务成功,另一个重试后基于新数据计算
相当于自动为逻辑约束加锁(而非物理行锁)
消息队列实现分布式事务|事务消息
在实际应用中,比较常见的分布式事务实现有 2PC(Two-phase Commit,也叫二阶段提交)、TCC(Try-Confirm-Cancel) 和事务消息。每一种实现都有其特定的使用场景,也有各自的问题,都不是完美的解决方案。这里只讨论「事务消息」的实现。
事务消息的核心原理就是先发送给mq一个消息,然后执行事务,通过执行事务的结果决定最开始的那条消息是否提交或者是回滚。流程如下图所示:
对于第四步提交事务消息时失败了的情况,RocketMq和Kafka有不同的处理:
- Kafka 的解决方案比较简单粗暴,直接抛出异常,让用户自行处理。我们可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。
- RocketMQ 则提供了一种MQ反查机制,即Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。
因此,为了支持MQ的反查,业务方需要实现一个供Broker反查的接口。 ↩
为啥用binlog格式为row来解决问题,或者说binlog的格式为statement会有什么问题? 首先看下两种不同的binlog的格式有什么区别,然后这个问题就可以解决了。 statement:记录SQL语句;row:记录具体的行操作,逻辑日志。row不会存在问题可以肯定的,那么statement为什么会存在问题?因为statement记录的是实际SQL的输入顺序(从库就会按这个执行),而这与实际的插入顺序可能不同,在并发批量插入、并发的时候可能存在问题,实际上可以见 小林coding中对auto-inc锁为轻量级版本binlog为statement会存在问题。 ↩
13 为什么表数据删掉一半,表文件大小不变?
根本原因:MySQL的删除的标记删除+覆盖。可能原因:数据空洞(数据页分裂)导致文件占用过大。
根据个人理解精简。
要回答这个问题或其他类似问题,我们就必须弄懂两个前置问题:
- MySQL如何删除数据行/数据页;
- MySQL的数据空洞;
MySQL如何删除数据行/数据页: MySQL删除数据行、页的流程就是打上删除标记(和自己操作MySQL数据很像),后面需要再用到这个位置的话直接覆盖。
举个例子:下面图片中如果R4删除了,就被打上了标记,那么如果插入(300,600)的数据就可以复用R4现在的位置。需要注意的是如果插入的数据
<300
或>600
,就不能复用现在的位置。删除数据页和数据行类似,就是打上标记,不同的是:对于数据行,只有符合范围的数据才能复用(如上面所示),而对于数据页来说,任何新的数据页都可以复用。
MySQL的数据空洞: 在很多情况下MySQL的数据页中的某些空间会有空洞而浪费, 【插入】和【删除】操作均有可能会导致数据空洞,具体来说:删除造成空洞就如上面所示;插入造成空洞的原因是插入导致
数据页分裂
。这样删除数据后表大小没有减少就找到原因了,是因为 MySQL的删除是标记的方式 加上 数据空洞现象。
引申一下,如何 解决数据空洞呢 或者说 如何减小表空间呢 ?
答案是重建表。
试想一下,如果你现在有一个表 A,需要做空间收缩,为了把表中存在的空洞去掉,业务可以怎么做呢?
你可以新建一个与表 A 结构相同的表 B,然后按照主键 ID 递增的顺序,把数据一行一行地从表 A 里读出来再插入到表 B 中。
由于表 B 是新建的表,所以表 A 主键索引上的空洞,在表 B 中就都不存在了。显然地,表 B 的主键索引更紧凑,数据页的利用率也更高。如果我们把表 B 作为临时表,数据从表 A 导入表 B 的操作完成后,用表 B 替换 A,从效果上看,就起到了收缩表 A 空间的作用。
实际上,数据库支持这样的能力,可以使用
alter table A engine=InnoDB
命令来重建表。在 MySQL 5.5 版本之前,这个命令的执行流程跟我们前面描述的差不多,区别只是这个临时表 B 不需要你自己创建,MySQL 会自动完成转存数据、交换表名、删除旧表的操作。上方的重建表的过程和原理都比较简单,但是存在一个问题:在这个操作过程中没法 增删改查,即业务会受损。
2025年01月02日20:05:16 看到这了。
为了尽可能避免这种情况,在表重建过程中又加入了一种log文件,
row log
,用来记录在重建过程中的 增删改 操作。这也就是online DDL名字的来源。没有 row log
,离线DDL有 row log
,online DDL↩" />
【wip】缓存一致性保证
旁路缓存中,如果修改了数据库的数据,为了保证缓存一致性,通常有先更后更和先更后删两种方案来更新缓存。需要注意的是,简单的应用两种方案都无法保证严格的缓存一致性。
先更后更指的是先更新数据库,再更新缓存,先更后删指的是先更新数据库,再删除缓存中的数据。
先更后更方案出现缓存不一致的时机是 并发出现更新,先更后删方案出现缓存不一致的时机是 并发出现缓存中读取不到数据。
出现缓存不一致的时机唯一的可能性就是并发更新缓存,先更后删方案并发出现缓存miss,才会可能出现并发更新缓存数据。
两种方案更具体的细节讲解可见:数据库和缓存如何保证一致性? | 小林coding
2025年04月30日10:09:48 写到这了。
todo:提一下延迟双删,并表明延迟双删是减少数据一致性的问题,没有严格意义上的保证没有数据不一致的问题。
保证缓存一致性采用写+改的方式的
坏处:
1.需要全量保存数据,缓存压力大
2.数据无法设置超时时间,与第一条相辅相成
3.如果后续修改时失败,可能导致缓存永远不一致。为了修复这个问题,可能由需要引入outbox组件,这样带来更大的复杂性,且引入outbox也不能百分之百的解决问题。
作者给出的最终解决方案是类似于分布式锁的方案,禁掉并发。
好处:
1.不存在缓存miss
2.不存在惊群问题(先改后删的方案可能存在惊群问题:即如果多个并发读请求进来,正好缓存miss了,那么可能存在多个并发的读mysql并且修改缓存的情况,作者称之为惊群问题)
改+删方案有 存在不一致出现在读写并发, 改+改方案 不一致出现在写写并发。
因此作者认为改+删方案不一致概率更高。
个人感觉比较好的方案:先改后删 + 租户机制(文章没提及,个人认为lease机制要限制较短的过期时间) + 缓存设置过期时间。
除了缓存一致性之外,上面也提到了先改后删方案中,还存在惊群问题,那么惊群问题如何解决呢? 关键就在于如果查询时,发现缓存中已经有lease,代表已经有并发开始了查询步骤,此时就等待一段时间再重试即可(代码上如何实现等待呢?感觉可以使用singleFlight)。
感觉这里面最有用的学到的东西就是:
解决缓存不一致问题的思路:限制并发或者拿锁,
本质上lease也是一种拿锁,锁的是 设置缓存数据的资格。
todo:更多思考:引入版本号机制能否解决问题???
参考:主要参考,该文比较硬核,里面讨论的比较详细 缓存与主副本数据一致性系统设计方案(上篇)_HAiLab的技术博客_51CTO博客
↩