推荐语:学习优秀的开源系统来优化我们业务的架构设计,这是我们作为业务开发的必修课,这篇文章从经典的mysql系统原理引申到业务系统设计思考,让人耳目一新,值得我们学习。
——大淘宝技术工程师 默达
如果一个系统能存活5年,看到里面的代码我可能觉得要重构了,看到一个系统存活了10年,那么我就万万不敢动了。mysql能够从1979的一个报表工具,2000年开源,到现在支持高并发,高可用,成为互联网的活化石“世一库”,靠的是无数开源人对技术的热爱,创始人Monty Widenius的人格魅力,以及不断进化的能力……
之前在处理一些慢sql和索引失效问题的时候复习了一波mysql,加上给团队分享设计模式的时候,乱翻了好多源码和课程,越发觉得mysql写的很不错。mysql不仅仅是一个数据库,更是一个优秀的系统……我们不仅可以使用它,我们也可以借鉴它沉淀了数年的设计,技术升级我们的业务系统。
因为很多mysql的知识点大家都清楚,所以着重讨论,略过一些基础。时间匆忙,错误望指正,补充的请留言。
开头肯定是绕不开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 推进一下。
其实这一块mysql有很多贴近数据层面的设计,但是把数据想象为业务,数据的记录和回滚--->业务操作的记录和回滚,数据的原子性--->业务操作的原子性,那么会有一些灵感。
其实现在很多关注数据强一致性的系统,都会记录操作(记录入数据库)来达到异常恢复和回滚的效果。比如结算账单的发起收佣和分账,商品的发品上下架,交易订单的打标去标,等等,都会将业务操作记录下来,作为落库保障稳定性,同时支持错误情况下的回滚凭证。不仅如此,也可以实现异步和外部系统交互的操作。达到重试和异步的机制。
下面是mysql更新数据操作和结算系统分账操作的对比图。mysql的“用户调用-日志记录-磁盘”就类比于系统的“操作发起者-持久化操作-下游”。都是运用了WAL机制,首先从用户调用(业务层)查询或初始化等操作,然后在内存(or业务领域层)记录即将执行的原子性的操作,之后采用不同机制(mysql使用内存刷取机制or结算系统运用异步调用及其他机制)来执行最终操作(mysql磁盘or业务系统底层服务)。
/**
* 回滚的具体方法
*/
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 丢失,这个临时库就会少了这一次更新,恢复出来的这一行值与原库的值不同。
同时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);
}
}
}
Mysql的引擎不是固定的,比较常用的是innodb和myisam,很多模块都是通过插件的形式的方式加载到Mysql主程序上的,这其中不仅有一些日志,状态等插件,还有数据引擎等核心的插件。
在Mysql中访问接口的方式主要有两类,一类是通过注册使用观察者模式来调用;另外一类就是数据库引擎通过handlerton的方式来实现。在数据存储引擎中,对表及事务的相关操作都是通过这种方式来访问相关的引擎插件的。handlerton的源码太长,复制过来很丑就略了。基本分成两大块,是一系列的相关的变量定义,比如state、type、slot等等;另外是一系列的函数指针,诸如binlog_func等。
在Mysql中是通过全局变量来管理这个插件的,它其实是一个插件相关的哈希数组,它可以通过plugin_find_internal来发现插件。像innobase_hton,myisam_hton之类的。像实现的时候,引擎去初始化其实就是调用相关的函数plugin_initialize来实现,调用的话就是从plugin_foreach开始的。
内存管理结构
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 退化为间隙锁。这种资源的降级退化思路是可以借鉴的。
一般业务系统中,状态机的使用是流程调用中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亿用户,赋能各行业数千万商家,并作为核心技术团队,保障双十一购物狂欢节的成功。家装新零售业务围绕卖场线和品牌线,以门店数字化交易为基础,通过营销工具,私域导购,客户留资等手段构建线上线下相结合的家装新零售解决方案,为家装新零售商家持续带来增量价值。