本周推荐 | mysql中业务系统可借鉴的设计
Posted 阿里巴巴淘系技术团队官网博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了本周推荐 | mysql中业务系统可借鉴的设计相关的知识,希望对你有一定的参考价值。
推荐语:学习优秀的开源系统来优化我们业务的架构设计,这是我们作为业务开发的必修课,这篇文章从经典的mysql系统原理引申到业务系统设计思考,让人耳目一新,值得我们学习。
——大淘宝技术工程师 默达
如果一个系统能存活5年,看到里面的代码我可能觉得要重构了,看到一个系统存活了10年,那么我就万万不敢动了。mysql能够从1979的一个报表工具,2000年开源,到现在支持高并发,高可用,成为互联网的活化石“世一库”,靠的是无数开源人对技术的热爱,创始人Monty Widenius的人格魅力,以及不断进化的能力……
前言
之前在处理一些慢sql和索引失效问题的时候复习了一波mysql,加上给团队分享设计模式的时候,乱翻了好多源码和课程,越发觉得mysql写的很不错。mysql不仅仅是一个数据库,更是一个优秀的系统……我们不仅可以使用它,我们也可以借鉴它沉淀了数年的设计,技术升级我们的业务系统。
因为很多mysql的知识点大家都清楚,所以着重讨论,略过一些基础。时间匆忙,错误望指正,补充的请留言。
WAL和二阶段提交
▐ 日志
开头肯定是绕不开mysql中经常提到的WAL技术,为了避免发生数据丢失的问题,当前事务数据库系统普遍都采用了 WAL(Write Ahead Log)策略:即当事务提交时,先写redo log,再修改页(先修改缓冲池,再刷新到磁盘);当由于发生宕机而导致数据丢失时,通过 redo log来完成数据的恢复。关键点是日志先行,再写磁盘。
那么记录什么样的日志呢?
引擎层会记录redolog,服务层会记录binlog。redo log是物理日志,记录的是“在XXX数据页上做了XXX修改”;binlog是逻辑日志,记录的是原始逻辑,其记录是对应的SQL语句;binlog 是追加写入的,就是说 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志;而 redo log 是循环写入的。
用户如果对数据库中的数据进行了修改,必须保证日志先于数据落盘。当日志落盘后,就可以给用户返回操作成功,并不需要保证当时对数据的修改也落盘。如果数据库在日志落盘前crash,那么相应的数据修改会回滚。在日志落盘后crash,会保证相应的修改不丢失。
在日志先行技术之前,数据库只需要把修改的数据刷回磁盘即可,用了这项技术,除了修改的数据,还需要多写一份日志,也就是磁盘写入量反而增大,但是由于日志是顺序的且往往先存在内存里然后批量往磁盘刷新,相比数据的离散写入,日志的写入开销比较小。
那么mysql是如何去做日志刷新和数据刷新的呢?
当用户线程产生日志的时候,首先缓存在一个线程私有的变量(mtr)里面,只有完成某些原子操作的时候,才把日志提交到全局的日志缓存区中。当线程的事务执行完后,把日志从缓冲区刷到磁盘。
当把日志成功拷贝到全局日志缓冲区后,会继续把当前已经被修改过的脏页加入到一个全局的脏页链表中。这个链表是order by modified time asc的且用一个字段来记录。这种机制保证从老到新刷入磁盘。这里最重要的是,脏页链表的有序性。
每个 InnoDB 存储引擎至少有 1 个redo log文件组,多个redo log文件。为了得到更高的可靠性,用户可以设置多个镜像日志组(mirrored log groups),将不同的文件组放在不同的磁盘上,以此提高 redo log 的高可用性。在日志组中每个 redo log file 的大小一致,并以循环写入的方式运行。
write pos 和 CheckPoint 之间的就是 redo log file 上还空着的部分,可以用来记录新的操作。
如果 write pos 追上 CheckPoint,就表示 redo log file 满了,这时候不能再执行新的更新,得停下来先覆盖一些 redo log,把CheckPoint 推进一下。
业务可以借鉴-类WAL机制实现合并处理,异步处理,异常恢复回滚等
其实这一块mysql有很多贴近数据层面的设计,但是把数据想象为业务,数据的记录和回滚--->业务操作的记录和回滚,数据的原子性--->业务操作的原子性,那么会有一些灵感。
其实现在很多关注数据强一致性的系统,都会记录操作(记录入数据库)来达到异常恢复和回滚的效果。比如结算账单的发起收佣和分账,商品的发品上下架,交易订单的打标去标,等等,都会将业务操作记录下来,作为落库保障稳定性,同时支持错误情况下的回滚凭证。不仅如此,也可以实现异步和外部系统交互的操作。达到重试和异步的机制。
下面是mysql更新数据操作和结算系统分账操作的对比图。mysql的“用户调用-日志记录-磁盘”就类比于系统的“操作发起者-持久化操作-下游”。都是运用了WAL机制,首先从用户调用(业务层)查询或初始化等操作,然后在内存(or业务领域层)记录即将执行的原子性的操作,之后采用不同机制(mysql使用内存刷取机制or结算系统运用异步调用及其他机制)来执行最终操作(mysql磁盘or业务系统底层服务)。
这里第二张图把回滚机制和多次重试的机制统一放到处理机制里面,并且和各内存中操作用双箭头表示调用和恢复回滚。
持久化的方式:最常见的,就用各种数据库把操作记录或者账单或者领域事件的状态记录下来,单条多次更新;或者比较少见使用日志文件记录下来每一次变更,就如同mysql写log一样;
处理机制中正向处理一般是单次的同步即时调用,也可以考虑的是:
合并处理减少调用量,在并发量较高的情况下,合并请求,或者也可以将一些更新操作合并到内存中进行调用;
定时捞取请求异步处理削峰,这种是比较常见的不关注实时性的请求处理,在系统水位不紧张的情况下,内存中定时异步捞取持久化的请求去调用。或者是使用消息机制比如metaq,去慢慢消费处理调用;
异常恢复:
服务调用的原子操作包括:a,b,c。当其中c服务调用超时或者失败,那么就会依次执行回滚操作c’,b’,a’。使用wal机制将服务执行的commit和rollback之前保留重要执行信息。举个简单例子,卖场佣金代扣到旗舰店-->调用平台收佣-->销账,当销账失败无数次由于比如风控原因不能重试成功的时候,需要采用异常回滚。那么将依次采取三个服务的回归方法,进行事务回滚。将钱最终返回原来卖场,否则钱岂不就是卡在旗舰店。
springboot提供的拦截完全可以达到事务识别,同时各服务添加服务id,类似mysql的xid。这里可以参考一些github上的事务回滚框架。
举例,只有一个初级想法,可以讨论一下:
/**
* 回滚的具体方法
*/
public @interface Transactionable
String rollbackMethod();
/**
* 事务的状态
*/
public enum TransactionState
INIT(1),
COMMIT(2),
ROLLBACK(3);
/**
* 各个服务的commit 和 rollback调用实体
*/
public interface Invocation
Class<?> getTargetClassType();
String getMethodName();
Object[] getArgumentValues();
Class<?>[] getArgumentTypes();
Map<String,Object> getExtraAttachMap();
Object getExtraAttachInfoByKey(String key,Object defaultValue);
void putExtraAttachItem(String key,Object value);
/**
* 分布式事务的服务的核心结构
*/
public class aService implements Serializable
private static final long serialVersionUID = -4512371127490746819L;
private String xid;
private String serviceName;
private String methodName;
......
/**
* transaction核心载体
*/
public class Transaction implements Serializable
private static final long serialVersionUID = 6648691752838557325L;
private final TransactionGlobalId transactionGlobalId;
private TransactionState transactionState;
.....
▐ 二阶段提交
redolog原是innodb引擎的东西,binlog是mysql server的东西,逻辑是独立的,可以理解为事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。
假如我们不使用二阶段提交。
先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来。但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行值与原库的值不同。
先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行无更新。但是 binlog 里面已经记录了这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 与原库的值不同。
同时log会有完整格式及xid来确认完整性和关联。
业务可以借鉴-保证数据一致性
其实这个已经有很多分布式事务的理论都写了,通过多段式来保证数据一致性。
这里写一下自己业务的应用,两个例子。比如在配置结算规则,业务方会设置一个规则,并进行自己的校验,平台方也会保存规则,进行校验和打款;再比如预约单核销创建尾款单的场景,需要保障电子凭证状态和订单状态的数据一致性。
其中有一些是需要强一致性,有些是需要弱一致性但是需要最终一致性的。可以参考base version的上图,进行一些定制。
结算规则设置就选择强一致性的二阶段请求,如下:
/**
* 单协议插入:二阶段强一致-先落库初始化,再调用服务H,成功后再次落库生效
*/
private void addAgreement(SettleAgreement settleAgreement, SettleAgreementSaveReqDTO settleAgreementSaveReqDTO)
settleAgreement.setEffectStatus(0);
int num = agreementWriteRespository.insertAgreement(settleAgreement);
if(num != 1)
throw new SettleBizException(CommonErrorDef.DB_HANDLE_FAIL);
// 实际生效规则需要同步H系统
if(settleAgreementSaveReqDTO.getStatus() == 1)
settleAgreement = agreementReadRespository.getAgreementsByOutId(SettleAgreementReq.of(settleAgreementSaveReqDTO.getRuleRelatedId(),settleAgreementSaveReqDTO.getBizCode(), null));
if(settleAgreement.needCallOut())
// 同步H系统
syncRule(settleAgreement);
settleAgreement.setEffectStatus(1);
// 调用成功后生效本地规则
num = agreementWriteRespository.updateAgreement(settleAgreement);
if(num != 1)
throw new SettleBizException(CommonErrorDef.DB_HANDLE_FAIL);
其实也是init本地-prepare-提交另一侧数据-commit。如果那一方的系统异常(类比于mysql的宕机)则失败,且回滚。
而选择弱一致性(最终一致)的二阶段请求则需要设计回补方案。比如预约单核销创建尾款单的场景。尾款单的订单状态需要和电子凭证的状态保持一致(电子凭证未冻结-现订单可创建,电子凭证已冻结-已有尾款单创建,电子凭证已核销-已有尾款单支付成功)。但是创单是p0场景,不能完全依赖电子凭证服务,电子凭证服务不可用或者延迟,不能影响创单,那么我们可以使用下面的方式,弱依赖+异步回补机制。
一些结构
▐ 引擎
Mysql的引擎不是固定的,比较常用的是innodb和myisam,很多模块都是通过插件的形式的方式加载到Mysql主程序上的,这其中不仅有一些日志,状态等插件,还有数据引擎等核心的插件。
在Mysql中访问接口的方式主要有两类,一类是通过注册使用观察者模式来调用;另外一类就是数据库引擎通过handlerton的方式来实现。在数据存储引擎中,对表及事务的相关操作都是通过这种方式来访问相关的引擎插件的。handlerton的源码太长,复制过来很丑就略了。基本分成两大块,是一系列的相关的变量定义,比如state、type、slot等等;另外是一系列的函数指针,诸如binlog_func等。
在Mysql中是通过全局变量来管理这个插件的,它其实是一个插件相关的哈希数组,它可以通过plugin_find_internal来发现插件。像innobase_hton,myisam_hton之类的。像实现的时候,引擎去初始化其实就是调用相关的函数plugin_initialize来实现,调用的话就是从plugin_foreach开始的。
业务可借鉴-可拔插的思路
这种可拔插的,使用观察者和handlerton的形式来支持扩展的设计模式,其实中台大部分代码都是这样,就不多做引申了。
▐ 内存的运用和一些算法
内存管理结构
mysql划分架构Server 层与引擎层(innodb),使用不同的方式进行管理。其中Server 层是由 mem_root 来进行内存管理,包括Sharing与Thead memory;而引擎层则主要由 Free List,LRU List,FLU List 等多个链表来统一管理 Innodb_buffer_pool。
一张网图,侵删。
业务开发的话关于mem_root了解一下即可,其实就是一个函数初始化一块较大的内存空间,向内存分配器申请内存空间,然后另一个函数在这块内存空间中分配出内存进行使用,其目的就是将多次零散的操作合并请求,以提升性能。并且不同的线程会产生不同的mem_root来管理各自的内存。
在innodb内存管理中,有一些分配方式。
内存分配方式
由于 CPU速度与磁盘速度之间的不匹配,通常会使用缓冲池技术来提高数据库的整体性能。通过内存的速度来弥补磁盘速度较慢对数据库性能的影响。前文聊到了查询和更新页操作,就是依赖这个buffer pool:从磁盘读到的页存放在缓冲池中,下一次再读相同的页时,首先判断该页是否在缓冲池中。若在缓冲池中,称该页在缓冲池中被命中,直接读取该页。否则,读取磁盘上的页。修改操作的具体步骤就是这样的:修改在缓冲池中的页;然后再以一定的频率刷新到磁盘上。控制poos和包含配置的主结构是buf_pool_t,控制数据页的是buf_page_t。
这个地方用了一个内存分配算法,在释放一个内存块的时候没有直接放回,而是先查看其伙伴是否也空闲,如果是则进行合并,再尝试对合并后的内存块进行合并。如果其伙伴是在使用的状态,这里做了一次重新分配操作,将其内容拷贝到其它空闲的内存块上,再进行对它合并。
另外一个比较好聊的是LRU list的算法,即最少使用的老数据先从buffer pool驱逐,新的页数据加入到list的中间位置,这就是所谓的中点插入策略。一般情况下list 头部存放的是热数据,就是所谓的young page,list尾部存放的就是old page。这个算法就保证了最近经常使用的page信息会被保存在最近访问的sublist,相反的不被经常访问的就会保存在old sublist。一般比例是对半分或young page少点。这样既能支持热点数据的读取写入,又防止了大量数据对全表数据的影响。
业务可以借鉴-缓存的思路
关于内存的思路没什么太多可以借鉴。主要是一些缓存的想法,包括热点商品的插入可以使用lru算法,在一些占用性能较大的服务上使用伙伴算法,等等。
动态地看待锁
mysql大量使用锁包括全局锁,表锁,行锁,mdl锁,间隙锁等等,来处理并发问题。作为共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。
在mysql锁的设计中,在不同场景下使用不同粒度的锁,且锁也是放在最合适的地方,来提升并发度。
比如全库逻辑备份的时候,使用全局锁;当对一个表做增删改查操作的时候,加MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁;对于行的更新操作,最小粒度加行锁。
▐ 事务顺序
而如果事务中需要锁多个行,也会把最可能造成锁冲突,最可能影响并发度的锁尽量往后放。举个简单例子,交易发货。那么需要做:1. 更新消费者订单状态;2. 该货品量扣减;3. 插一条发货记录。为了保证交易的原子性,我们要把这三个操作放在一个事务中,很显然如果随意加锁的话,会产生大量锁冲突。比如两笔订单发货的是同一个货品,那这个货品这一行数据就会冲突。所以,如果把语句 2 安排在最后,比如按照 3-1-2 这样的顺序,那么该货品这一行的锁时间就最少,大量减少事务之间的锁等待,提升了并发度。
▐ 锁的退化
虽然集团貌似为了避免死锁用的是Read Committed,而mysql默认的是Repeatable Reads。但是Repeatable Reads下的next key lock我觉得还是需要了解一下的也挺有意思。查找过程中访问到的对象会加next key lock;索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁;索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。这种资源的降级退化思路是可以借鉴的。
状态机的使用
▐ 状态流转
mysql中有很多的状态,包括Checking table,Closing tables,Killed,Locked,Sending data,Sleeping,Waiting for tables……等等状态,状态之间会互相流转。
比如checking table状态在查询之后会进入locked,以及更新状态会进入locked,查到锁冲突时会进入waiting等等。
能够在如此复杂的状态中进行精准流转,且代码并不臃肿。在mysql其中一个版本的分支代码中,尝试使用二维的形式来流转状态,是可以借鉴的。
业务可以借鉴-二维简化复杂状态流转
一般业务系统中,状态机的使用是流程调用中set不同的状态,并允许在特定状态下进行特定操作。
最简单的做法是分支逻辑,即if-else,将每一个状态转移,原模原样地直译成代码。这种会使得极易漏写或者错写某个状态转移,可读性和可维护性都很差。具体请参考各种老系统的状态流转。
平时常见的做法是充血模式状态机,所有的状态转移和动作执行的代码逻辑,都集中在业务的实体类中,代码分散开来,同时存在一个状态机类作为流转。其实这种方案是比较好的,但是当状态很多的时候,会引入更多状态类和操作,代码会越来越臃肿。
实际上,除了用状态转移图来表示之外,状态机还可以用二维映射来表示,也叫做查表法,比如说,一维表示状态,另一维表示事件,值表示当前状态经过事件之后,转移到的新状态及其执行的动作。实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改二维映射即可。
比如交易订单是有很多状态的。这里写的不一定对,举个例子而已。
状态\\操作 | 关闭订单 | 付款 | 发货 | 确认收获 | 退款 | 。。。 |
交易关闭 | / | / | / | / | / | |
待付款 | 交易关闭 | 待发货 | / | / | / | |
待发货 | 交易关闭 | / | 待确认收货 | / | 交易关闭 | |
待确认收货 | 交易关闭 | / | 待确认收货 | 交易成功 | 交易关闭 | |
交易成功 | / | / | / | / | 。。。 | |
。。。 |
public enum Event
closeOrder(0),
pay(1),
sendGoods(2),
receiveGoods(3),
refund(4);
private int value;
private Event(int value)
this.value = value;
public int getValue()
return this.value;
public class OrderStateMachine
private State currentState;
private static final State[][] transitionTable =
trade_close, trade_close, trade_close, trade_close, trade_close,
trade_close, wait_sendgoods, wait_pay, wait_pay, wait_pay,
trade_close, wait_sendgoods, wait_receivegoods, wait_sendgoods, trade_close,
trade_close, wait_receivegoods, wait_receivegoods, trade_success, trade_close,
trade_success, trade_success, trade_success, trade_success, trade_success
;
public OrderStateMachine()
this.currentState = State.trade_init;
public void closeOrder()
executeEvent(Event.closeOrder);
public void pay()
executeEvent(Event.pay);
public void sendGoods()
executeEvent(Event.sendGoods);
public void receiveGoods()
executeEvent(Event.receiveGoods);
public void refund()
executeEvent(Event.refund);
...
...
private void executeEvent(Event event)
int stateValue = currentState.getValue();
int eventValue = event.getValue();
this.currentState = transitionTable[stateValue][eventValue];
public State getCurrentState()
return this.currentState;
这个只是简化一下。复杂做法,可以具体excute方法可以在各模块或产品包,映射模型也可以统一维护在配置文件中。只是为了将状态流转放到一处去维护。
同时除了订单状态,有些操作会引发物流单,支付单等状态,可以将二维升级为三维等等。而不是把各种状态的流转放到event代码中,会很难维护。针对不同的业务身份,不同业务类型,也可以设置不同的状态流转配置。
当然这种方式适合event比较简单,但是状态较多的场景,比如mysql中,其实很多事件只是加个锁,发个数据,等等。像交易如果越做越重的话,还是使用状态充血模式,需要依业务来选型。
如何“删库跑路”
首先,大家应该是没有单独数据权限的,且有审批,所以删库跑路还是不要多想了!从恢复难易程度来看几个删除数据的方法。
使用 rm 命令删除整个 MySQL 实例:登上机器,查看mysql安装路径然后查找是否存在服务,之后直接kill并rm带mysql的东西即可。这种方式的恢复方法,就是即使删除一个节点的实例,集群也会推举出新的主库,然后根据集群其他节点数据恢复这个节点的数据即可。对于高可用+跨机房的集群来说,除非批量全下掉实例,不然应该是最好恢复的。
删库/删表:使用drop database直接删除数据库,drop table 或者 truncate table来删除表。此时恢复需要全量备份,并且新的操作会有实时增量binlog,使用这些binlog恢复一个临时库,然后设置主备关系即可。如果binlog也删除了直接从binlog备份系统中找到需要的 binlog,再放回备库中,这样恢复事件一般很长。dba应该有些其他科技来加速。比如使用一些并行的方式。
使用delete语句删除一些数据行:除了简单delete外,搞复杂点比如delete完再insert一条不想干的,然后再update一下。其实对恢复来说复杂度差不多,使用binlog解析工具把语句反译一下,反过来执行一下放回备库重放,但是需要确保binlog_format=row 和 binlog_row_image=FULL,这个应该是默认的所以不用担心。
总结
本短文大致介绍了一下mysql的wal机制,一些内部结构和算法,锁和状态机的视角,以及程序员经常碰到的“删除”。mysql发展这么多年了,涌现了很多专业分析和经典课程,本文主要是另辟蹊径从业务借鉴的角度来看看它的设计,给大伙儿提供一个引子,希望后续继续和评论区讨论。
其实在当前技术同学视角下,最常见的两方面,一是完成一个业务研发活动,比如商品的3d详情,交易的改价分摊,双十一的秒杀;二是实现技术上的突破,比如缓存tair支持sql,mq消息队列的升级,部署安全等等。因为这些都是容易让人获得成就感的,是容易量化的。然而还有一些比如合理设计系统架构,构建开放开源文化,不同技术互相融合,是容易让人忽略的,却也是非常重要的。
团队介绍
我们是大淘宝技术创新业务团队,支撑淘宝,天猫核心电商以及家装新零售,优品,汽车等创新业务,服务n亿用户,赋能各行业数千万商家,并作为核心技术团队,保障双十一购物狂欢节的成功。家装新零售业务围绕卖场线和品牌线,以门店数字化交易为基础,通过营销工具,私域导购,客户留资等手段构建线上线下相结合的家装新零售解决方案,为家装新零售商家持续带来增量价值。
¤ 拓展阅读 ¤
以上是关于本周推荐 | mysql中业务系统可借鉴的设计的主要内容,如果未能解决你的问题,请参考以下文章