Tallate

该吃吃该喝喝 啥事别往心里搁

参考

1.Tony. 企业融资渠道有哪些?[EB/OL], https://www.zhihu.com/question/23939018
/answer/29132581, 2015.5.
2.周梅. ERP 实施中存在的问题及解决方案[J]. 中国集体经济,2013,28: 35-36.
3.于洪涛. SAP 坚定云转型之路[EB/OL], http://www.cnbp.net/news/detail/13618, 2016.9.
4.James Lewis , Martin Fowler . Microservices a definition of this new architectural term
[EB/OL], https://martinfowler.com/articles/microservices.html, 2014.3.
5.IT168 企业级. 年终盘点篇:2017 年度微服务调查报告出炉[EB/OL],
http://www.sohu.com/a/216277536_374240, 2018.1.
6.Henry Robinson. What Is CAP Theorem?[EB/OL], https://www.quora.com/What-
Is-CAP-Theorem-1, 2010.9.
7. Dan Pritchett. BASE: An Acid Alternative[J]. ACM Queue, 2008, 6(3):48-55.

  1. 保证分布式系统数据一致性的 6 种方案
  2. 阮一峰. 理解 RESTful 架构[EB/OL], http://www.ruanyifeng.com/blog/2011/09
    /restful.html, 2011.9.
  3. 程立. 大规模 SOA 系统中的分布事务处理[EB/OL], http://tsingxu.github.io
    /blog/20140513/distributed-transaction-in-soa-by-chengli.pdf, 2008.12.

15.Hunt P, Konar M, Junqueira F P. ZooKeeper: Wait-free Coordination for Internet-scale
Systems[C]. Usenix Annual Technical Conference. 2010:653–710.
16.Zachary Tong, Clinton Gormley. Elasticsearch 权威指南[M]. O’Reilly Media, Inc,
2015, 27-35.
17.Baron.Scbwartz. 高性能 MySQL[M]. 北京:电子工业出版社, 2013.5, 1-33.
18.Redis Documentation[EB/OL], https://redis.io/documentation, 2018.3.

背景

项目意义

X
这个项目的业务部分的原型是司库云,但是重点不在业务,因此这里仅仅简单介绍一下。平时我们接触的最多的应该是共享单车、在线聊天、支付宝这种 To C 业务,To B 业务主要是由企业提出的,和个人不同,一个企业总是会有部门、上下级等关系,因此 To B 业务与 To C 业务的主要区别有以下几条:

  1. 层次化的权限管理
    一般的企业中员工之间都会有上下级的关系,企业又会有集团、组织这些组成部分;
  2. 无处不在的审批
    企业中大部门场景都需要审批,以前是拿着单子到处跑,现在会将单子录到一些 APP 中,方便很多;
  3. 更大的体量
    一般企业会进行更大额的交易,我们平时将几万存入支付宝中对大型企业来说都是一点零头,对这么大额的操作,安全是优先于效率的(企业内部其实也只有百来号人会去用这样的系统)。

近年,似乎所有传统企业都在试图往互联网靠拢,上次听一位朋友讲到某机关部门布了一个集群供“大数据处理”(上千条级别),再如某企业开发云服务软件,测试时专门有一项“大数据用例”(上万条级别)。
将业务上云后,正如之前很多人困惑的那样:不就是把代码放到云主机上跑吗?但问题的关键不是技术新老,而是为什么要迁移到云端,主要是当下业务变更迅速、弹性大,使用传统的机房部署服务,应用实例能使用的硬件资源是固定的,如果数据量超过了机器可处理的范围,要么换一台更高档的机器,要么将整个实例复制到另一台机器上,再在网关处做负载均衡,当然这两种方式都有些缺陷。因此,技术的变革都是被逼出来的,当下云原生应用能够提供弹性扩展、资源预警等功能,且有更人性化的操作界面,取代传统的运维方式也是必然的。微服务是当下实现应用上云的首选架构风格,有丰富的资源(当然最重要的是比较火),这个小项目也是我对微服务的初步探究。


微服务

架构、架构风格、系统架构

架构风格关注的是如何使用一些连接件来组合软件组件,在 Web 应用中,我们会使用覆盖网络来描述软件的架构,连接件可以是 HTTP 协议、数据库连接器等,在桌面应用中,连接器可以是读取用户输入的管道,等等。
系统架构关注的是软件组件是如何实例化的,比如要几台服务器、哪些组件要复制等。
平时说的架构一般指的是架构风格,但对实现细节的深究是成为架构师的必经之路。

单体式、SOA 和微服务

X
图中,小圆形、方形、三角这些小图形是服务实例,包围小图形的双层方形是 Docker 容器实例,立方体是物理服务器(一般是云提供商提供的虚拟机实例)
传统的单体式应用(monolithic)在实践中往往存在着诸多问题,比如,在达到一定的复杂度后,不仅部署效率会降低,由开发不小心引入的任何 Bug 都会弄垮整个服务,且模块之间高度耦合,添加新功能的代价变得更高,由此也对测试带来了更大的压力。
微服务架构模式有点像SOA,他们都由多个服务构成,因此对 SOA 缺陷的讨论可以参照下面对微服务的讨论。但是,从另一个角度看,微服务架构模式是一个不包含 Web 服务(WS-)和 ESB 服务的 SOA,微服务应用乐于采用简单轻量级协议,比如 REST,而不是 WS-,在微服务内部避免使用 ESB 以及 ESB 类似功能,微服务架构模式也拒绝使用 canonical schema 等 SOA 概念,因此可以认为微服务是轻量版的 SOA。简而言之,它们之间的主要区别是对服务的治理方式不同。
微服务的概念由来已久,2011 年 5 月在威尼斯附近举办的软件架构师研讨会提出了“微服务”这个概念,Adrian Cockcroft 将其描述为一种“细粒度的 SOA”。
在国内,加快互联网+步伐成为许多传统企业的必然选择。业务场景、用户习惯和行为在迅速变化,许多传统行业线上业务出现急速增长。每个月都要进行业务系统更新的企业比例超过了半数,比如金融行业的移动支付、互联网理财等,汽车制造行业的营销、电商、售后服务等线上业务比例迅速提高。IT 团队业务开发、迭代都以每月、甚至每周来计,需要 7*24 小时响应,这些给系统开发和运维带来极大挑战。在服务的复杂化、线上访问压力大、交付速度无法满足业务需求等现状的前提下,寻求架构上的转型正是大势所趋。
微服务相对以传统方式部署的应用来说,具有易扩展、访问便捷、安全、性价比高等特点,将传统的单体式应用进行拆分后成为一个个微服务后,每一个微服务都可以单独进行部署,可以根据企业用户的具体需求提供服务,另外服务之间的弱耦合性也使得扩展变得更加容易。

总而言之,微服务应用相对单体式应用的优势体现在下面这些方面:

  1. 可扩展性高
    严格界定服务边界,服务之间是弱耦合的,每个服务本身是无状态的,可以通过水平复制和负载均衡来提高服务性能。
  2. 实施效率高
    应用 Docker 实现运维自动化,且每个服务都足够小,使得部署效率不会太低。
  3. 健壮
    某个服务的不可用不会引起大规模雪崩,而且服务的复制也提高了整体的可用性。
  4. 单个服务复杂性低
    每个服务都足够简单,由一个独立团队来负责。

开发、实施和运维

开发和运维在传统情况下是完全分割开来的,开发主要负责完成功能需求,并且知道如何去优化功能,运维要明白如何部署应用服务集群、中间件集群外,同时对所使用的工具的原理要有一定程度的了解,比如某个服务器的 CPU 打满了,原因可能是代码写得太差了,也可能是数据库某张热表没有给字段加上索引。现在 DevOps 的概念盛行,开发有时也需要承担环境的维护。
实施,说简单的,就是帮用户装软件的,该人员必须对软件整体具有较好的理解,因为实施会直面用户,如果被用户问倒可不只是丢自己一个人的脸,当然,一定程度的口才也是必要的。实施在 ERP 时代是很重要的一个职位,在当下云服务盛行的情况,实施往往会被派去为用户部署私有云环境(前提是有这个必要)。

有状态和无状态服务

提到无状态我们都会想到 HTTP,HTTP 协议是一种无状态的协议,这意味着 HTTP 服务器不会保存任何用户信息,实现登录、购物车等带状态的功能时都需要额外借助 Session、分布式缓存、数据库等存储方案。以购物车为例,其中 Session 相当于将购物车的状态保存到了服务实例的内存中,而分布式缓存和数据库方案则将状态转移到了另一个服务器内(假设应用和缓存、数据库服务器是部署在不同的服务器上的)。
如果一个服务是有状态的,随着实例的运行,服务实例在内存中可能会多出一些与业务相关的数据,实例间产生差异后,我们后续就不能随意地对服务进行复制了,两次 HTTP 的可能会产生不同的结果,如下图所示。
X
因此有必要在设计的时候就保证服务是无状态的。
X

CAP & BASE

CAP是描述分布式系统特性常用的一种理论,它使用数据一致性(Consistency)服务可用性(Availability)分区容错性(Partition-tolerance)三个指标来定义一个分布式系统,这三个特性不能被同时完全满足,其中,P 是必须要满足的,因为一般的业务系统并不允许网络中的消息被随意丢弃,因此多数的讨论都集中于 C 和 A 之间的权衡。
如果需要满足强一致性,则在对数据进行读写操作时势必都需要进行加锁操作并使用事务来保证分布式一致性,但同时也会对系统的效率产生非常大的影响,起到反作用、影响用户的体验,所以在设计时往往会放宽这个要求,采取最终一致性作为实现目标。服务的高可用性要求请求必须能够完成,这可以通过复制服务实例来实现,服务实例的复制需要投入更多地成本与维护人力,需要根据具体场景进行具体分析。
eBay 的架构师 Dan Pritchett 源于对大规模分布式系统的实践总结,提出了 BASE 理论。BASE理论是对 CAP 理论的延伸,核心思想是即使无法做到 CAP 理论要求的强一致性,但应用可以采用适当的方式达到最终一致性(Eventual Consitency)。BASE 是指基本可用(Basically Available)软状态(Soft State)最终一致性(Eventual Consistency)。基本可用是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。
在 BASE 的提出者 Dan Pritchett 的论文《BASE: An Acid Alternative》中提出了一种实现 BASE 的经典模式,概括来讲,就是服务之间是通过消息队列连接的,消息队列会保证将消息传递给目标服务,但不保证送到的时间。


技术总结

接下来,我希望对项目中使用到的一些技术进行简短总结,尽量引入一些代码和图来辅助说明。

REST & RPC

REST 是现今一套比较成熟的 API 设计理论,主要思想是将网络上的资源抽象为 URI,并通过 HTTP 协议中的字段和动词进行描述和操作,被广泛地应用于 WEB 应用 API 的设计当中。
RPC 是常见的实现服务之间远程调用的协议(在 Java 这种面向对象的语言上实现的理应称作 RMI 协议,但是叫习惯了就无所谓了),服务实例作为不同的进程运行于多台服务器上,在发起请求时,请求的发起方称为客户端、接收者称为服务端,它们通过指定的网络层甚至传输层协议进行通信,可以自动对消息进行序列化并包装为底层消息格式进行传输,并在服务端进行反序列化得到请求。因此 RPC 的可靠性和效率与底层协议本身的效率和对对象进行的序列化和反序列化的效率息息相关。
调用 RPC 接口与调用本地方法形式上是相同的,这大大减少了代码的冗余、提升了开发效率,但是 RPC 本质上与普通方法调用却有着截然不同的执行流程,因此必须与普通方法区分开来、不能滥用。在设计微服务时,也必须考虑服务划分的粒度,如果分得过细就有可能导致一次请求需要过多的网络开销。

分布式锁、乐观锁与分布式事务

单机环境下,资源竞争者都是来自机器内部的进程或线程,那么实现锁的方案只需要借助单机资源就可以了,比如借助磁盘、内存、寄存器来实现。而在分布式环境下,资源竞争者生存环境更复杂了,原有依赖单机的方案不再发挥作用,这时候就需要一个大家都认可的协调者出来,帮助解决竞争问题,那这个协调者称之为分布式锁
通过在执行修改操作前加分布式锁,可以很好地保证临界区资源的互斥访问,一定程度上维护了数据的一致性,但是在客户端进行读写的复合操作时加锁又是不充分的,因为读和写操作之间存在一定的时间差,如果在这期间数据被其他线程所修改,那么接下来的写操作就会覆盖这个修改,导致业务层面上的不一致,解决办法是引入乐观锁,本文的乐观锁是通过为实体类添加版本号来实现的,每次进行修改操作时需要比较对象与数据库中记录的版本,只有大于的情况才能执行。
微服务系统中的多个服务往往拥有自己独立部署的数据库,在跨服务对数据进行写操作时若发生错误就有可能产生数据不一致的情况,利用单纯的加锁机制是不能保证安全性的。分布式事务是指会涉及到操作多个数据库的事务,目的是为了保证系统中各服务能保持数据一致性。分布式事务处理的关键是必须有一种方法可以知道事务的所有参与者的动作,事务最终必须统一提交或统一回滚。文中采取的解决办法是引入 TCC 分布式事务,将业务操作划分为 try、confirm、cancel 三个部分,try 负责对业务资源的锁定,confirm 负责提交事务、正式执行业务操作,cancel 负责回滚事务并释放锁定的资源,在业务流程中执行所有的 try 完毕后,由协调者根据事务的执行情况来统一调用所有事务参与者的 confirm 提交或 cancel 回滚。TCC 在本地事务的基础上进行多个实例间的协调,可以在很大程度上保证跨服务业务操作的一致性。

Spring Cloud

Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具,例如配置管理,服务发现,断路器,智能路由,微代理,控制总线等。

Spring Boot

Sping Cloud 的实现基础是Spring Boot,在构建项目前需要先引入所需基础设施的依赖。
比如若需要使用 ZooKeeper 的服务发现功能,只需要在 Maven 的 pom.xml 文件中添加名为”spring-cloud-starter-zookeeper-discovery”的依赖。

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zookeeper-discovery</artifactId>
</dependency>

并在属性文件中指定 ZooKeeper 服务器的地址,就可以在应用中通过注入 DiscoveryClient 来使用 ZooKeeper 的服务发现功能了。

1
2
@Autowired
private DiscoveryClient discoveryClient;

其他的功能也可以依法炮制。从某种意义上来说,Spring Cloud 更像是通过 Spring Boot 自动配置机制实现的由众多独立子项目组成的大型综合项目。
Spring Boot 的主要目标是解决传统 Spring 项目中配置文件过于繁杂的问题,随着业务的复杂度增加,配置变得越来越难维护且无法定制,因此 Spring Boot 的提出者利用注解和属性文件来取代配置文件,因为注解是与代码紧紧相依的,代码中可以直接通过注解来获取到属性文件中的属性,并按用户的需求来执行 Bean 的初始化过程,从而实现配置的高度可定制化。Spring Boot 的关键特性是自动配置,实现自动配置的方式是在应用启动后从 Spring Boot 提供的包内获取配置好的 Bean,并对常用的配置设定默认值,比如其内置的 Tomcat 服务器的默认占用端口即为 8080,并且支持在属性文件中修改,这在很大程度上减少了配置项的数量,同时也降低了运维人员的压力。

Dubbo

Spring Cloud 和 Dubbo 区别?为什么不使用 Dubbo???

Docker

Docker 执行流程?为什么用它来部署环境?

Docker 是一个基于容器的应用开发、部署和运行平台,它为开发者和系统管理员们提供了一种新式的应用部署方式,具有灵活、轻量、可移植等特点。传统的部署云服务的方式是通过虚拟机完成的,虚拟机会在宿主机上运行一个完整的操作系统、通过 hypervisor 来间接使用宿主机的硬件资源,实际上这远远超出了应用运行所必须的资源要求。而容器正相反,它在操作系统中作为进程运行,与所有其他容器共享同一内核、占用相同容量的内存空间,相对来说会更加轻量。

MySQL

MySQL 是一种常用的开源数据库,MySQL 软件采用了双授权政策,分社区版和商业版,具有体积小、速度快、总体拥有成本低、开放源码等特点。

Redis

Redis 有哪些数据结构?

Redis 是一个开源的使用 ANSI C 语言编写、支持网络、支持持久化的日志型、Key-Value 数据库,并提供多种语言的 API。

Nginx

Nginx 执行流程?反向代理解释一下?为什么使用 Nginx 作为静态页面服务器?负载均衡原理是什么?

Nginx 是一款高性能的 HTTP 和反向代理服务器软件,具有高性能、高并发、低 CPU 内存消耗的特点,功能包括反向代理、负载均衡、访问控制等,且可以根据需要自定义扩展组件。

ZooKeeper

ZooKeeper 执行流程?服务发现原理是什么?分布式锁原理是什么?

ZooKeeper 是一个分布式的开放源码的分布式应用程序协调服务,可以为分布式应用提供一致性服务,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。
ZooKeeper 将资源抽象为文件系统,使用节点来表示数据在该文件系统中保存的路径。为了实现服务发现机制,每个实例在加入应用服务时会在 ZooKeeper 服务的同一命名空间下创建临时节点,并在退出时由 ZooKeeper 自动删除,这样任一个实例都可以通过查询该命名空间下来获得微服务集群内服务的注册情况。ZooKeeper 本身不提供锁服务,但是可以使用节点来表示锁,如果一个客户端需要为一个资源上锁,就可以为该资源所代表的路径下创建一个顺序节点,按照节点创建的顺序进行标号,客户端监听该路径下的节点,如果自己创建的节点标号是最小的就获取到锁,当释放锁时需要删除自己创建的节点,这样基本实现了客户端之间的互斥访问。

RabbitMQ

RabbitMQ 执行流程?消息队列的数据结构是怎样的?

RabbitMQ 是一个在 AMQP 基础上完成的、可复用的企业消息系统。AMQP 协议定义了消息队列需要具有的面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全性等特点。RabbitMQ 是一个开源的 AMQP 实现,服务器端用 Erlang 语言编写,支持包括 Java 在内的多种客户端,主要应用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。

Elasticsearch

Elasticsearch 执行流程?文档数据结构是怎样的?

Elasticsearch 是一个分布式、可扩展、实时的搜索与数据分析引擎。擅长全文搜索、结构化数据的实时统计、复杂的语言处理等。并且原生支持分布式部署,可以通过管理多节点来提高扩容性和可用性,并在硬件故障时确保数据安全。


架构设计与实现

整体架构

微服务的首要任务是对模块进行拆分,整个系统的主要模块如下图所示。
X
需要实现的主要功能包括:

  1. 使用权限模块来实现权限分配、功能隔离、租户隔离;
  2. 用户能够利用该系统进行发债放款、审批、结算等;
  3. 用户能获得预警信息;
  4. 系统内需要使用服务发现机制来实现服务的弹性伸缩。

每个模块成为一个独立的服务,它们具有明确清晰的功能边界,且相互之间只暴露必要的远程接口,形成类似下图的结构。
X

部分功能的实现涉及到一些中间件,更详细的架构图如下图所示。
X
其中:

  • 主要功能模块拥有独立的数据库,它们是独立部署的;
  • 服务发现服务用于自动实现服务的上下线,使用 ZooKeeper 服务器作为服务注册中心,服务发现服务会将注册在 ZooKeeper 服务器上的服务信息更新到 Nginx 服务器上;
  • Docker 提供容器形式的运行环境,使用 Docker Swarm 管理容器集群,服务和中间件运行在容器实例内,称为服务实例,多个服务实例组成一个服务;
  • Nginx 有两个作用:
    1. 作为静态文件服务器。前端使用 React 开发,通过运行 npm run build 编译成为静态文件,将产生的整个文件夹移动到 Nginx 的静态文件目录下;
    2. 负载均衡。Nginx 是一个反向代理服务器,调用任何服务的 REST 接口需要先经过 Nginx 进行转发,Nginx 在同一服务的各个实例间进行负载均衡。比如一次放款提交请求,请求会经过 Web 客户端、Nginx、发债管理、审批管理这几个服务。
      业务服务需要保持无状态,这样才能启动多个而不影响负载均衡的有效性,而各种中间件(如 ZooKeeper、RabbitMQ)一般有自己组织集群的协议,集群一般由 master 和 slave 组成,需要在 Nginx 中配置其中 master 节点的地址。
  • Radis 集群组成了缓存服务,因为系统中涉及到的业务大部分是读多写少的,因此缓存对提高效率是很有必要的;
  • RabbitMQ 集群组成消息队列服务,消息队列可以解耦消息管理服务和业务服务,另外,消息队列也是实现异步调用必须的,消息队列提供重试、备份等功能,有利于实现可靠消息的最终一致性效果。

部分功能实现

我将大部分业务功能忽略了,这些功能中大部分都属于简单的 CRUD 操作,下面介绍一些技术性稍微强一些的功能的实现细节。

服务发现功能

X
服务发现功能的主要运行流程大体分成三个阶段,通过定时器来定时执行:

  1. 从 zk 服务器获取服务注册信息
    在启动服务实例后,新建的服务实例会向 ZooKeeper 服务器发送该服务的注册信息,在 ZooKeeper 中这些信息作为存在于同一命名空间下的临时节点存在。
  2. 构建配置文件,并上传到 Nginx 服务器
    服务发现服务器会定时地从 ZooKeeper 服务器获取微服务集群内服务的注册情况。
  3. 远程执行命令,更新 Nginx 服务器
    在获取到服务的注册信息后,服务发现模块会将数据组织成 Nginx 配置文件的格式,并上传到 Nginx 服务器内,因为 Nginx 是运行在容器之内的,所以客户端需要通过 Docker 提供的 REST API 来操作容器来达到间接操作 Nginx 服务器的目的,在上传完毕后使用 Docker 容器运行命令的接口来执行 Nginx 重新加载配置文件的命令,至此完成服务上线的过程。

当实例退出集群时,代表其服务注册信息的临时节点会被自动清除,此时 Nginx 服务器经过同样的刷新过程将该实例移除出代理目标,由此完成服务的自动伸缩。

在实际环境中启动服务之前,需要先将项目打包成为 Docker 镜像,利用 Docker 技术只要服务器上安装有 Docker 环境即可启动应用,很大程度上减少了重复修改服务本身配置文件的麻烦,因为业务模块的框架是基于 SpringCloud 的,在启动后利用 SpringBoot 自动配置的便利,可以在初始化时调用 ZooKeeper 服务器提供的接口将服务注册到 ZooKeeper 的服务注册中心。服务发现模块的执行流程图如下图所示。
X

其中,更新 Nginx 配置文件的主要代码如下所示。

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 构建nginx.conf配置文件
String content = buildConf();
String localPath = dockerDiscoveryClient.getLocalNginxConfPath(); // 本地配置文件保存位置
String fileName = dockerDiscoveryClient.getLocalNginxConfFileName(); // 配置文件名
String containerPath = dockerDiscoveryClient.getContainerNginxConfPath(); // 容器内配置文件位置
writeConf(localPath + containerPath, fileName, content);
// 遍历所有nginx服务器,重载配置文件
for (Container container : nginxContainerList) {
for (long i = sdHelper.getMaxRetries() - 1; i >= 0; i--) {
try {
// 将配置文件上传到服务器
upload(localPath + containerPath, container.id(), containerPath);
// 重新加载nginx服务器
reload(container.id());
} catch (DockerDiscoveryException e) {
// 发生错误,重新上传加载
log.info("Reload Nginx failed, retrying left " + i + " times", e);
continue;
}
break;
}
}

在将服务实例信息转换为 Nginx 配置文件后,需要循环遍历所有现存的 Nginx 服务器,对服务器地址的请求若出错需要一定次数的重试,尽可能地更新。
客户端或其他模块通过 REST 协议来访问服务的接口,访问请求由网关服务器进行拦截,再通过负载均衡技术来发布到某个服务的实例上。

放款更新功能

X
考虑多用户登录系统并同时对同一实体执行更新操作时,就有可能发生竞态条件。如果使用普通的加锁方式只能保证同一进程内的线程同步、不能保证多个服务实例进程间的互斥访问,解决办法是引入 ZooKeeper 的分布式锁和时间戳机制,在每次修改实体后更新实体的版本,并在每次对实体 ID 加锁后再与数据库中保留的最后一次修改时的版本进行校验,加锁将使得临界区代码在集群内只能被一个服务实例的线程调用,在修改完毕退出后更新实体版本,另一个线程进入后再根据版本进行校验,从而保证了服务间的并发写的一致性。
时间戳的实现比较简单,而分布式锁的实现则涉及到很多细节,已知的包括:

  • 加锁操作的原子性、只有自己可以解锁自己、死锁、线程宕掉、耗时、失效时间、阻塞、可重入。

放款提交功能

发债模块在对单据进行提交操作时实际上需要调用审批服务提供的 RPC 接口,在提交后用户即可在审批模块中查找到这些单据,并可以对其进行审批或取消审批、驳回等操作。首先需要解释的是服务为何如此划分,需要结合企业的具体业务来考虑,因为审批是十分宽泛的场景,不只是发债单据需要审批,为了提高模块的可扩展性,将其划分出来作为单独的服务是合理的。放款提交操作的时序图如下图所示。
X
和其他增删改查操作相同的是,在具体的业务处理之前需要对放款进行加锁和版本校验,成功后发债服务会将该放款信息通过调用服务接口的方式提交到审批服务,审批服务会先根据放款的单据类型从数据库查找其审批流的注册信息及审批流,并成功便新增一条审批流实例到数据库,返回成功信息给发债服务,发债服务再根据返回值来修改放款的审批状态;如果查询或新增失败,审批会返回错误信息,使得发债服务也不会进行任何处理,或者处理后回滚到最初的状态。
发债模块并不了解审批模块内部的实现方式,二者使用相互独立的两个数据库,因此在执行审批操作时是可能发生不一致的情况的,为此需要引入 TCC 补偿机制,对提交操作需要提供一个 confirm 接口和一个 cancel 接口,代表统一的提交和回滚。
放款提交的具体执行过程如下图所示。
X
在登记放款页面点击提交按钮后会将放款单的 ID、版本号等信息发送到后台,程序的初步执行流程仍然是上分布式锁、校验版本号及设置新版本号和更新时间,接下来由于涉及到跨服务的写库操作所以会显得更加复杂。
首先服务调用方的发债服务需要生成本次调用的调用 ID,其作用之后再作论述,因为发债服务的本次调用是事务内的第一次调用,所以还需要生成一个链路 ID,用于标识一次事务,这些 ID 都需要全局唯一,所以和实体 ID 一样需要使用分布式唯一 ID 算法来生成。发债服务在调用审批服务的接口时,除了传递本来需要传递的单据信息之外,还需要将调用 ID 和链路 ID 打包一同传递,以便之后审批服务将 TCC 事务的参与信息上传到缓存中间件。事务的参与者分为根参与者和枝叶参与者,根参与者为事务的发起者,在这里即为发债服务,枝叶参与者包括事务通过传播到达的所有其余服务器,参与者包含的属性包括服务器本身的地址及 TCC 补偿事务所需的 confirm 接口和 cancel,只要某个服务接口需要添加事务属性,它就需要将自己的参与者信息上传到缓存中间件中,因此这里审批服务还有一个上传参与者信息的过程。实现 TCC 补偿事务中根参与者执行流程的关键代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Object interceptRoot(Participator participator, MethodWrapper methodWrapper)
throws TccException {
Object result;
try {
// 执行try
log.info("-> ROOT.try执行");
result = methodWrapper.invoke();
} catch (Throwable cause) {
// 执行cancel
log.info("<- ROOT.try出错.即将执行cancel", cause);
tccCoordinateHelper.submitCancel(traceIdHelper.getTraceId());
throw new TccException("Method invocating failed. cause:" + cause.getMessage(), cause);
}
// 执行confirm,结束后会发送确认信息
log.info("<- ROOT.try完毕.即将执行confirm");
tccCoordinateHelper.submitConfirm(traceIdHelper.getTraceId());
return result;
}

在前面的准备工作完成后,审批服务正式进入业务处理流程,首先需要根据登记放款的单据类型去数据库中查找审批流注册信息,因为审批存在多级审批的场景,因此每条审批流关联了自己下一阶段的审批流,这里为了创建审批流实例需要找到源头第一条审批流,并将发债传递来的单据信息填入即可得到审批流实例。
保存审批流实例时不能直接保存到实体本身代表的数据库表中,而是要暂时保存到一个 dr 表中暂存,表名为“实体名_dr”,因为此时事务并没有完成,如果直接保存到实体表中,直接去审批流页面查询是能查出的,也就是说会发生“脏读”的现象,暂时保存到这个暂存表中,还可以作为日志,供系统发生故障时手动恢复之用。审批服务执行完毕之后,发债服务需要使用其返还值来更新发债数据库中的放款状态。但是事务还未完成,因为审批服务还没有将审批实例实际保存到实体表中,发债服务需要先使用链路 ID 从缓存中间件中获取所有事物参与者信息,然后根据提交操作是否成功来决定是调用 confirm 还是 cancel,这里 confirm 的逻辑是将 dr 表中的审批流实例转移到实体表中,而 cancel 是将该审批流实例从 dr 表中移除、发债服务回滚。
为了应对偶尔的网络不稳定等情况,根参与者需要在异常发生时再重试一定的次数,另外,试想如果请求的超时也被当成故障处理了,那么多次的写操作很有可能会为系统引入脏数据了,因此 confirm 和 cancel 接口需要额外地保证接口的幂等性,为了校验接口是否被多次调用,需要客户端在发出请求时带上此次请求的调用 ID,服务端需要缓存调用 ID 并对请求的重复性进行校验。
最后发债服务释放放款 ID 上的锁,完成一次提交操作。其他审批接口、结算管理的相应接口实现思路是类似的,在此不另加论述。

总而言之,提交操作是需要跨服务写库的,除了在修改操作的加锁和版本校验之外,还需要注意TCC 的执行流程

  1. TCC 事务需要每个参与者提供 try、confirm 和 cancel 三个接口,try 执行资源的预留,比如对涉及的实体加锁,也起着服务验活的功能,confirm 执行事务的提交,也就是真正地执行业务操作、将数据持久化到数据库,cancel 需要释放预留的资源;
  2. 每个事务参与者将自己的信息提交到 Redis 缓存内,之后根参与者需要取得所有参与者信息,主要是它们的 confirm 和 cancel 接口的地址;
  3. 根据 confirm 执行情况,有:
    1. 如果业务操作都成功了,由第个参与者来调用所有其他参与者的 confirm 接口来提交事务;
    2. 如果业务操作失败了,同样由第一个参与者来调用所有其他参与者的 cancel 接口来回滚事务。

调用 ID 和链路 ID 的作用,前者可以用于保证接口调用的幂等性,后者主要用来定位事务内的参与者。

用一个常见的用户购买商品场景为例,需要先确保用户能够支付,然后锁住用户和商户的账户,这是 try 阶段;如果 try 成功,即预留资源成功了,接下来再对用户账户扣款、商户账户入账,如果因为超时等原因失败了,需要事务补偿,即重试,这是 confirm 阶段;如果 try 失败,则释放所有的锁,这是 cancel 阶段。



改进思路

当前的系统仍存在很多可改进的地方。

服务发现功能

使用 Eureka 等高可用服务发现产品替代 ZooKeeper

ZooKeeper 集群通过 Zab 协议保证高一致性,重新选举 Leader 比较耗时,且当节点数量不足以构成容错集群时,ZooKeeper 倾向于返回空;故提出改用 Eureka 等其他倾向于提供高可用性的具有服务发现功能的产品。

使用复制和 DNS 等手段工具提升 Nginx 服务器的可用性

Nginx 服务器作为网关,存在单点故障风险,但是这里的服务发现只能保证被 Nginx 代理的那些服务能弹性伸缩,对 Nginx 本身却没有什么办法,就算复制了 Nginx 服务器,客户端也只能发到一个 IP 上。这里提出使用基于操作系统的方案对 Nginx 集群实现验活及负载均衡,主要是 Keepalived 等软件或独立的 DNS 服务器,但是论文题目限于 Saas 层展开,本人水平有限没法解释。

服务间通信功能(RPC)

使用 TCP 等更底层、灵活的协议

HTTP 是应用层的,Spring Boot 封装了一个 RestTemplate 可以很方便地发 HTTP 请求。而 TCP 是传输层的,如何包装消息需要另外再讨论,常见的框架如 Netty,提供了长连接、心跳检测等功能,可以提高开发效率。

使用 Kryo 等更完善的序列化工具

Kryo 是一个优秀的 Java 序列化方案,大概用了很多优化方案,这就是很零碎的编程问题了。
TCC 的 confir 和 cance 都有可能出错,一般都需要手动恢复,异常日志指的是 redo 和 undo 日志,redo 是未执行前的数据值,undo 是执行修改后的数据值。

服务间一致性(TCC 事务)

考虑 TCC 事务发生异常的情况,做好 redo/undo 日志,供出错时手动执行恢复。

参考

  1. What is the difference between dependency injection and dependency look up?

背景情况

部门开始推一个大项目 NCC,后台几乎沿用原 NC(有近十年历史),将原来 JavaSwing 画的重量前端换成了一种“轻量前端”,其实这种轻量前端一点都不轻量;SQL 完全是自己用字符串拼接起来的;开发模式是前后端分离联调的方式,但是因为后台代码都是部署在一个服务里头的,没有司库云(微服务架构)复杂。

从增删改查流程说起

对 Controller、Service 分层的讨论放在后面。

save

保存操作有以下特点。

  1. 批量
    对普通的 CRUD 操作,最大的开销是网络传输,NCC 系统中操作的往往是多个表关联在一起的复杂结构,如果分成多次请求,一方面对用户体验不好,一方面对效率也有所影响。因此 NCC 中的做法是将同类数据放到同一页签,转换为 json 格式的数据,如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    {
    "pageid":"36650BIS_CARD",
    "head":{
    "head":{
    "areaType":"form",
    "rows":[
    {
    "values":{
    "pk_bondissueregister":{
    "display":null,
    "value":null,
    "scale":"-1"
    }
    ...其他属性
    },
    "status":"2"
    }
    ],
    "areacode":"head"
    }
    },
    "bodys":{
    "repaymentplan":{
    "rows":[
    {
    "rowid":"136270.438b34a6204411a",
    "status":"2",
    "values":{
    "pk_bondrepaymentplan_b":{
    "display":null,
    "scale":null,
    "value":""
    }
    ...其他属性
    }
    }
    ],
    "areaType":"table",
    "areacode":"repaymentplan"
    }
    }
    }
  2. 差异化
    新增和更新操作皆为调用 save 接口,通过区别传递的数据中是否有 id 来进行区分。另外,更新操作会先从数据库中查询出旧的数据,并和客户端传参进行比较,若完全一致则直接跳过此次更新。

delete

执行流程和 save 几乎一样,只是系统中不会真的删除一条数据,而是采用“逻辑删除”的方案。

query

查询操作的过滤条件是由前台给出的,我没有经历过重客户端时代 NC 的开发,但是在 Web 应用中也这么做有点奇怪。

page

分页查询的思路比较奇怪,是先按前端传递的过滤条件查出所有合适记录的 pk,然后在 Controller 层对这些 pk 进行分页:

1
2
3
4
5
6
7
8
// ids指的是查出的所有合适记录的pk
// 本页之后的数据数量
int afterCurPageSize = ids.length - pageInfo.getPageIndex() * pageInfo.getPageSize() + 1;
int targetSize = pageInfo.getPageSize() < afterCurPageSize ? pageInfo.getPageSize() : afterCurPageSize;
String[] targetIds = new String[targetSize];
System.arraycopy(ids, pageInfo.getPageIndex() * pageInfo.getPageSize(),
targetIds, 0,
targetSize);

最后再调用一个批量查询的 Service 接口进行查询。

技术点

轻量前端、分层

轻量前端指的是用 React 开发的浏览器客户端,既然是轻量前端,那么做的事情肯定不会太多了,那么很多工作就必须要转移到后端了,因此在 NCC 中又引入了一个 Action 层,作为 Controller。

读写分离

后台 Service 层将读操作和写操作划分到了两个接口内,实际上应用层可以根据读写进行复制,一般来说读操作更频繁,可以多创建几个实例提供服务,并在前头使用负载均衡器进行反向代理。

SOA

SOA 是一种架构风格,与微服务的主要区别是 SOA 多了一个 ESB(企业服务总线),在 SOA 中,当一个服务需要调用另一个服务时,它需要先从 ESB 中获取那个服务的调用方法、消息格式等,而微服务可以看作一种轻量化的 SOA,它没有集中的一个 ESB,服务之间通过公开的 RPC 接口来相互调用。
实际上 NC 没有 ESB 这玩意。首先需要将 Service 接口及其对应的实现类注册到一个 xml 文件内,服务间调用通过 NCLocator.find(class)实现:如果配置文件中没有设置服务器 URL,则直接本地反射实例化 Service 的实现类;如果配置文件中设置了服务器 URL,则使用 JDK Proxy 做动态代理,将本地方法调用转换为远程调用。

元数据

元数据是描述数据的数据,实际开发业务前需要先使用相关工具”画”出元数据,NCC 的开发极大地依赖于元数据的正确性,老的功能节点的元数据若轻易修改,很有可能导致原功能出错。

模板

模板是对页面的抽象,平台的有关部门做了一个画模板的工具,由后端人员登录到工作桌面画模板,可以减轻前端开发人员的工作压力(可能是领导觉得前端比较稀缺?)。可以从元数据导出模板,并根据原型图进行适配。
随着业务的复杂化,将许多公共组件抽象出来集中管理是必须的,尤其是 NC 这么复杂的庞然大物,其业务拿出两个小时来也没办法讲完 1%,有点夸张,但是事实是这里的需求很多都是从会计等行业转来的,一次给我们讲需求时从 NC 上满屏的标签中挑了部分讲了两小时,大家都是崩溃的…因此 NC 中引入了三大模板的概念,它们分别是:查询模板、打印模板、计算模板,分别对查询 sql、打印、???进行了抽象。

依赖查找

Spring 的核心原理是 IoC,而 IoC 的主要实现方式是 DI 或依赖查找。DI 我们比较眼熟,主要有”属性注入”、”构造器注入”及”方法注入”。NCC 后台为 NC,已经有 10 来年历史,其中实现了一套依赖查找的模式,如下代码所示。

1
IXxxService xxxService = ServiceLocator.find(IXxxService.class);

当然接口和实现类之间不是自动关联在一起的,还需要定义一个 upm 文件来配置他们之间的关联关系。
这实际上和比较原始的 Spring 用法有点像,如下代码所示。

1
XxxBean bean = application.getBean(XxxBean.class);

内存锁

排他锁

就是普通的锁。

1
2
3
4
5
6
7
8
9
10
11
boolean isOK=false;
try{
isOK = PKLock.getInstance().acquireBatchLock(new String[]{ PK }, userid);
if(!isOK)
throw new BusinessException("并发操作");
// TODO: 业务逻辑
} finally {
if(isOK){
PKLock.getInstance().releaseBatchLock (new String[]{ PK }, userid);
}
}

共享锁

共享锁适用于读者写者的情景,即某类操作可以并存、而和其他类操作不能的情况。
使用方法和排他锁差不多:

1
2
PKLock.getInstance().acquireBatchLock(
new String[]{ PK + IPKLockBS.STR_SHARED_LOCK }, userid );

对同一 PK 来说,共享锁和排他锁是不能共存的。

动态锁

动态锁为一种特殊的锁,进行加锁的人员不用自己释放,其调用序列一般为:
pklock.acquireLock… pklock.addDynamicLock…
如果是远程调用,远程调用结束后系统会自动释放所有的动态锁
动态锁其实是对前面两种锁的封装,不过动态锁不需要主动解锁,中间件会在后台事务结束前进行统一解锁;并且在同一次中间件事务中,可以重复申请相同 PK 的锁

1
boolean isOK = PKLock.getInstance().addDynamicLock( PK ); 

动态锁的实现原理是事件机制,动态锁最后会发出一个解锁事件,业务操作执行结束后会触发这个事件。

时间戳校验

有了锁机制可以保证并发操作不会破坏数据一致性,但是不能保证用户的操作是对最新版本的数据进行的,因此需要引入时间戳机制。
主要通过比较当前对象的ts 值(时间戳)与数据库中该对象的 TS 值来判断用户修改的是否为最新版本的数据。在每次对数据进行修改操作时,都需要更新相应数据的时间戳标志 ts,这个一般由系统维护,不需要在业务中特殊处理。

动态代理

上面提到了动态锁机制但是没有具体说解锁的时机,假设所有含写操作的业务操作都需要加锁,难道要把加锁解锁逻辑都写一遍?NC 中为了解决这个问题也实现了一套 AOP 机制,原理是 JDK Proxy,NC 会为所有在配置文件中注册的 Service 创建动态代理,在动态代理中可以对锁等公共功能进行操作,从而大大简化业务代码。在这方面来讲,NC 也可以看作 Spring 的弱化版。

跨组件调用

NCC 后台是传统的单体式应用,所有模块几乎都放在一个服务里(用户买什么就把什么包含进来),跨模块的组件之间可能存在相互引用的关系,比如 a 需要将某些信息存入 b 中,就需要调用 b 提供的接口,而 b 在某些情况下也需要从 a 中获取某些信息,但 b 所在的 B 模块是一个更通用的组件,它不会为每个下游业务都新增代码。在 NCC 中,面对这种情况一般是由 a 来提供一个回调接口,将这个接口名保存到数据库中,然后告诉 b:”这就是 a 的接口啦”,类似于 RPC,不过将耦合的部分转移到了数据库中。

逻辑删除

数据库里的数据不会被直接删除,而是采取逻辑删除的策略,每条数据都有一个额外的dr(delete remark)字段用于标识一条数据是否被删除,查询时需要根据这个字段进行过滤。

Oracle

采用 Oracle 作为数据库,一是因为客户主要是大企业,安全、稳定有更高要求;二,数据量也确实不大,可能也用不着进行分库分表;三,作为延续了十几年的系统,当时“去 IOE 化”还没有提出,Oracle 仍是数据库领域最好的选择;最后,业务十分复杂,且底层 ORM 代码中存在大量直接使用字符串拼接出来的 SQL,想要完全移植到 MySQL 等数据库是不大现实的。

总结

以上我对最近工作中涉及到的东西进行了简单总结,嗯…产品底层仍有很多我未知的部分,一些与业务关联较大的代码也不适合拿出来分享,于是暂时告一段落吧。

参考

  1. 数据库范式简单讲解(1NF、2NF、3NF、4NF、BCNF)
  2. 缓存与数据库一致性之二:高并发下的 key 重建(先淘汰 cache 再写 db)的问题

由来

公司比较看重这批新员工(基本来自 985 院校),做了比较多的培训,从公司文化到衣着讲究都专门开了课,很切合应届毕业生的需求(强制参加的…)。
规范类似设计模式,是对最佳实践的总结,有点收获,这里把还记得的部分总结一下。

Java 编码规范

基本没听(其实是因为不知道下午要考试),内容和阿里 Java 规范很接近,有时间考个证书。

用户体验分享

这个大佬比较惨了,大家都是程序员、还都是偏后台的那种,讲课时间还是刚吃完饭那会,大家不是特别能接受,反正我是没听进去。

数据库规范

公司一个架构师大佬讲的,大部分内容仍然来自阿里 Java 规范,掺杂了一些干货。

遵守规范有什么好处

  1. 约定大于配置(这个理念是 SpringBoot 的基础),规范属于一种约定。
  2. 提高沟通效率
  3. 提升后续管理效率

基础

设置

  1. 使用 UTF8MB4。
  2. 注释很重要。
  3. 数据库内大小写不敏感,像 MySQL、Oracle 都是大小写不敏感的,如果出现敏感的情况注意设置成不敏感的。

命名

  1. 尽量取有意义的名字
  2. 如果有多个单词使用下划线分割
  3. 长度不要过长,如果设置得过长了,后面创建该表的备份表、表名末尾带上一些特殊的后缀,可能会超出数据库对表名长度的限制。

设计

  1. 尽量达到 3NF
    达到 1NF 和 2NF 是理所当然的,BCNF 又太严格了,会把表结构拆得过碎了,由此引入的多表关联会严重影响 DCL 效率,4NF 更是会把表结构拆稀碎了,达到 3NF 是比较理想的,但是不排斥使用 NoSQL 后产生的冗余。
  2. 逻辑删除而不是物理删除
    好处是可以反悔,坏处是表里数据会多很多。
    但是实践表明删除是很少被使用的,换句话说,被删除的数据相对总数来说是很少的,比如说我们把商品加到购物车里一般都会买掉而不是删除了,因此我们更倾向于使用逻辑删除。当然也不能排除有些业务删除是常用的,那就另说了。
    补充:其实我觉得完全可以另开一个表来存被逻辑删除的数据,我在毕设中就是这么做的。
  3. 为数据加上时间戳
    加上时间戳的主要目的是实现数据的增量同步,比如将数据同步到搜索系统(一般是 Elasticsearch)中,全量同步是无法忍受的。
    补充:时间戳还可以当作乐观锁。
  4. 合理利用 char
    varchar 能满足大部分用到字符串的场景,但是 varchar 的效率是不如 char 的,对于手机号、身份证号这种固定长度的属性完全可以使用 char 类型保存。
  5. 复杂的处理逻辑放到应用层而不是持久化层,不要用触发器、存储过程、视图,因为在云计算环境下应用层的扩展成本要远低于持久化层。
    补充:视图其实还是可以用一下的,比如给另一个部门查点数据,又不会暴露数据库的真正结构。

补充:数据库设计理论

元组/行:一个持久化对象。
属性/字段/列:某类持久化对象的属性,属性集一般使用 U 表示。
超键:能唯一标识一条元组的属性集。
候选键:不包含多余属性的超键。
主键:被用户选作元组标识的候选键。
主属性:构成候选键的属性。
函数依赖(FD):由业务确定的属性之间的依赖关系,即由一组属性可以唯一确定另一组属性。
关系模式(R(U)):指在属性集中存在的所有函数依赖。
平凡函数依赖:在 R(U)中存在 X→Y,若 Y 包含于 X,则称 X→Y 为平凡函数依赖。
完全依赖:在 R(U)中,如果 X→Y,并且对于 X 的任何真子集 X’都有 X’-/->Y’,则称 Y 完全依赖于 X,记作 X→Y。比如确定一批书的印刷时间需要给出版次和印次,不管是单独给出版次或印次都无法确认这批书。
部分依赖:与完全依赖相反,如果 X→Y,且 X 中存在一个真子集 X’,使得 X’→Y 成立,则称 Y 部分依赖于 X。比如学号和姓名可以确定一个学生,但其实学号就可以确定一个学生了。
传递依赖:指的是形如 X→Y→Z 的函数依赖。
多值依赖(MVD):原始定义比较难理解,用一个特殊情况来说明,就是在同一个表里存在 X:Y、X:Z 分别是 1 对多关系,即有 Y→X、Z→X,比如[课程, 学生, 先修课]是一张表,其中课程对学生、课程对先修课都是 1 对多关系。
1NF:不能有复合字段(不可分的原子值)。
实现方法:如果有,则拆成多个字段。
例子:一个人可能有多个联系电话,比如手机号、固话等,应该被拆分到多列内,或者多行保存同一人的多个电话。

身份证号 姓名 手机号 固定电话
3303012313 18012341234 123-12341234
身份证号 姓名 手机号
3303012313 18012341234
3303012313 123-12341234

缺陷:
2NF:实体属性完全依赖于主键、不能仅依赖主键的一部分属性(严格地说,非主属性完全依赖于候选键)。
实现方法:如果存在对主键的部分依赖,则拆出来作为一个新实体。
例子:
缺陷:
3NF:非主键属性不能间接依赖于主键(严格地说,不能有非主属性对候选键的传递依赖)。
实现方法:如果存在上述传递依赖,则将依赖的两个非主属性拆出成为新表,原表和新表之间是 1 对多的关系。
例子:
缺陷:
BCNF:消除任何属性对候选键的传递依赖(在 3NF 的基础上,不允许候选键属性传递依赖于候选键的属性)。
实现方法:同 3NF。
例子:
缺陷:
4NF:消除非平凡且非 FD 的多值依赖。
实现方法:
例子:
缺陷

SQL 编写

  1. 明确指出所有列,不要省略或使用通配符,比如:
    1
    2
    select * from t;
    insert into t values('1', 2);
  2. 连接操作给出表的别名,否则容易混淆

性能优化

  1. 分库分表
    将数据从一张表拆到多张表,后续再将这些表转移到多个数据库实例内,一般情况下可以减少单个数据库的压力,提升整体性能。常见的拆库方法如:
    • 按业务纬度分表(垂直拆分):将负责不同业务的表拆到不同库中。
    • id hash(水平拆分):对业务数据 id 求模 hash(id) % n,hash 函数是自定义的,如果 id 是数值可以直接返回 id 本身,如果是字符串可以取前几位的和(遍历每一位太慢了),n 表示我们需要将数据拆到几张表里。
      日期 hash(水平拆分):将不同月甚至日的数据分散到不同的库中。
      特殊 hash(水平拆分):按照某个特定的字段求模,或者根据特定范围段分散到不同的库中。
    • 读写分离:将数据拷贝到多个读库和写库内,读库加上索引,而写库不需要,这样,当然写库还需要通过某种机制将数据增量更新到读库内。
    • 冷热分离:将冷数据分离出去,比如微信聊天中 3 天前的数据就可以算作冷数据,这部分数据比较少用到,分离出去后可以提高查询最近聊天数据的响应效率。
  2. NoSQL
    NoSQL 常用于冗余数据,可以避免复杂的关联查询。
  3. 索引
    索引可以避免全表扫描,正确使用索引可以大大提高查询效率,但要注意函数可能导致索引失效,分析 SQL 语句问题的主要工具是执行计划
  4. 分页
    • 数据库分页优于内存分页
    • 分页前最好先过滤
    • order by 子句中最好不要使用会重复的字段(比如 name 就有可能重名)
    • 海量数据下可以只现实前 500 条数据,而不显示总条数和页数(百科和 Google 搜索都是这么做的)。

补充:缓存命中率和缓存淘汰

缓存命中率 = 查询命中数 / 总查询数
缓存命中率是度量缓存有效性的常用参数,为了提高缓存命中率,最好对不变数据进行缓存。
缓存淘汰是在服务器内存不够用时触发的,将一些价值较小的数据从缓存中淘汰,减少不必要的 swap,提升内存的有效利用率。
缓存淘汰还有一个问题:是先写入还是先淘汰呢?
首先看先写入再淘汰的情况,如下图所示。
X
我们交换操作 3 和 4 的顺序,第一步数据库操作成功,第二步如果淘汰缓存失败,则会出现 DB 中是新数据,Cache 中是旧数据的情况,即产生了数据不一致的问题。
然后是先淘汰再写入的情况,同样如上图所示。
写和读操作是并发执行的,应用实例 1 首先发起对缓存的淘汰,应用实例 2 读缓存 miss,于是从数据库读,而应用实例 1 这才将新值写入数据库,此时实例 2 得到的数就是脏数据了,注意在分布式环境下这种执行顺序完全是有可能的。

补充:执行计划

操作规范

  1. 变更需要明确范围
  2. 为上线预留时间和空间
  3. 给最专业的人进行评估
  4. 错峰操作

考试

考试题

一、单选题(每道题 2 分,共 12 分)
1、现有一张系统用户操作日志表,合理命名应该是( )
A、XTYHRZB
B、SysUserOpLog
C、Sys_User_Oplog
D、SYS_T_USER_OPLOG

2、下面数据库设计不符合规范的是( )
A、大数据量表进行分库分表
B、为了方便查看,表中使用明文存储用户密码
C、使用数字格式存储时间
D、将 json 格式数据存储到 Nosql 数据库

3、下面 SQL 编写不符合规范的是( )
A、select column1, column2 from table1
B、select column1, b.columna from table1, table2 b where column1 = b.columna
C、insert into table1(column1,column2) values(?,?)
D、update table1 set column1 = ‘aaa’ where id = ‘001’

4、下面关于性能优化不正确做法的是( )
A、尽量避免一条 SQL 从大于 4 张表中取数
B、如果一条 SQL 有三层以上的嵌套查询,在设计上考虑解决
C、为了提升代码执行效率,大量的公式运算交给数据库后端运行
D、为常用的分组排序字段建立索引

5、下面不正确的索引建立方法的是( )
A、业务表唯一标识字段必须制定唯一索引
B、只要是查询可能用到的字段,都要建立索引
C、尽量使用数据量少的索引
D、索引列上不使用函数计算

6、下面数据库操作符合规范的是( )
A、为了方便,应用程序使用超级管理员账号
B、为了快速解决问题,直接在后端数据库中修改业务数据
C、修改表结构告知影响人员,并预留一定时间评估、审核
D、程序测试完成后直接上线新功能
二、多选题(每道题 6 分,共 60 分)
1、关于领域模型命名,下列哪些说法符合规范:( )
A、数据对象命名:xxxDO,xxx 即为数据表名,例如:ResellerAccountDO。
B、数据传输对象:xxxDTO,xxx 为业务领域相关的名称,例如 ProductDTO。
C、展示层对象:xxxVO,xxx 一般为网页名称,例如 RecommendProductVO。
D、POJO 是 DO/DTO/BO/VO 的统称,命名成 xxxPOJO。

2、关于代码注释,下列哪些说法符合规范:( )
A、所有的抽象方法(包括接口中的方法)必须要用 javadoc 注释。
B、所有的方法,包括私有方法,最好都增加注释,有总比没有强。
C、过多过滥的注释,代码的逻辑一旦修改,修改注释是相当大的负担。
D、我的命名和代码结构非常好,可以减少注释的内容。

3、关于类命名,下列哪些说法符合规范:( )
A、抽象类命名使用 Abstract 或 Base 开头。
B、异常类命名使用 Exception 结尾。
C、测试类命名以它要测试的类的名称开始,以 Test 结尾。
D、如果使用到了设计模式,建议在类名中体现出具体模式。例如代理模式的类命名:LoginProxy;观察者模式命名:ResourceObserver。

4、关于加锁,下列哪些说法符合规范:( )
A、可以只锁代码区块的情况下,就不要锁整个方法体。
B、高并发的业务场景下,要考虑加锁及同步处理带来的性能损耗,能用无锁数据结构,就不要用锁。
C、能用对象锁的情况下,就不要用类锁。
D、加锁时需要保持一致的加锁顺序,否则可能会造成死锁。

5、关于方法的返回值是否可以为 null,下列哪些说法符合规范:( )
A、方法的返回值可以为 null,如果是集合,必须返回空集合。
B、方法的返回值可以为 null,不强制返回空集合,或者空对象等。
C、方法实现者必须添加注释,充分说明什么情况下会返回 null 值。
D、防止 NPE 是调用者的责任。

6、关于控制语句,下列哪些说法符合规范:( )
A、推荐 if-else 的方式可以改写成卫语句的形式。
B、尽量减少 try-catch 块内的逻辑,定义对象、变量、获取数据库连接等操作可以移到 try-catch 块外处理。
C、if ( condition) statements; 单行语句不需要使用大括号。
D、在一个 switch 块内,都必须包含一个 default 语句并且放在最后,即使它什么代码也没有。

7、关于捕获异常和抛异常,下列哪些说法符合规范:( )
A、如果需要捕获不同类型异常,为了方便处理,可以使用 catch(Exception e){…}。
B、不要捕获异常后不处理,丢弃异常信息。
C、捕获异常与抛异常,必须是完全匹配,或者捕获异常是抛异常的父类。
D、异常定义时区分 unchecked / checked 异常,避免直接使用 RuntimeException 抛出。

8、关于变量和常量定义,下列哪些符合规范:( )
A、Long a=2L;//大写的 L。
B、Long a=2l; //小写的 l。
C、常量只定义一次,不再赋值,所以不需要命名规范。
D、不要使用一个常量类维护所有常量,应该按常量功能进行归类,分开维护。

9、数组使用 Arrays.asList 转化为集合,下列说法哪些正确的:( )
A、数组元素的修改,会影响到转化过来的集合。
B、数组元素的修改,不会影响到转化过来的集合。
C、对于转换过来的集合,它的 add/remove/clear 方法会抛出: UnsupportedOperationException。
D、Arrays.asList 体现的是适配器模式,只是转换接口,后台的数据仍是数组。

10、关于并发处理,下列哪些说法符合规范:( )
A、线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
B、同步处理时,能锁部分代码区块的情况下不要锁整个方法;高并发时,同步调用应该考虑到性能损耗。
C、创建线程或线程池时,推荐给线程指定一个有意义的名称,方便出错时回溯。
D、推荐使用 Executors.newFixedThreadPool(int x)生成指定大小的线程池。

三、问答题(4 道题,共 28 分)
1、人力云(HR)业务需要建立人员表(含姓名、年龄、性别)和人员工作情况表(含工作开始时间、工作结束时间、工作单位、工作简介),请写出具体的建表语句。(8 分)

2、请列举你所了解的分库分表方式。(6 分)

3、现有业务需求要修改表结构,开发库已经修改测试完成,需要在生产库同步修改并发布新程序,请问你具体的步骤。(5 分)

4、现在有一张表 tablea,主键 key1,只有主键含有唯一索引,字段 columna,columnb,columnc,数量级 100 万,现在按照主键升序,每页 100 条分页,请写去取出第 900000 到 900100 条数据的 SQL 语句。(9 分)

答案&分析

一、单项选择
1.D 数据库大小写不敏感,应该全部用大写
2.B 选项 C 时间用数字格式存储,可以方便最后的国际化时区转化
3.B 连接语句要对每个表给出别名,对每个字段的使用都要带上表别名,否则有子查询的情况下,语句可能出现执行计划错误,甚至结果集错误。
4.C 选项 B 建议使用 NoSQL 缓存或其他可以在架构上优化的措施来减少复杂的数据库操作;选项 C 公式运算容易使得索引失效因此不建议在数据库中使用;选项 D 中的建议是因为 order by 和 group by 会使用到索引。
5.B 选项 B 索引是有代价的,对于一些值重复性较大的列最好不建索引;选项 C 也是考虑在写操作时更新索引会拉低性能。
6.C 操作一般是越谨慎越好,数据库操作最好交给专业人员来做(DBA),选项 B 是为了避免数据库出问题时互相甩锅。

二、多项选择
1.ABC 选项 C 我似乎没选,因为把 VO 当成 DO 来用了,而且现在开发都是前后台分离,很少用到页面模板;选项 D 中 POJO 确实是 DO/DTO/BO/VO 的统称,但不能把所有都命名成 XxxPOJO,会分不清该类的职责。
2.ACD 一般接口和抽象类里的抽象方法需要加上注释,而类里面的私有方法没有这个要求,也要避免注释过多的情况。
3.ABCD
4.ABCD 使用锁时要考虑锁的粒度、性能等特性,同时应该避免死锁等问题。
5.BCD 我和大部分人一样非常厌恶方法返回 null,尽量返回空集合或空对象能减少很多烦恼,但是手册中认为方法可以返回 null,防止 NPE 是调用者的责任,我觉得原因很大程度上是因为难以避免远程调用失败、序列化失败、运行时异常等场景返回 null 的情况。
6.ABD 选项 A 卫语句指的是处理完一个分支后直接返回;选项 B 强调我们分清稳定代码和非稳定代码,不要将不会出错的稳定代码也加到 try 中,这是不负责任的。
7.BCD 异常最好有业务含义,能提高后期排错效率。
8.AD 选项 B 不规范是因为 l 和 1 容易混淆。
9.ACD 最好看一下 Arrays.asList()源码。
10.ABC FixedThreadPool 不推荐使用,因为其允许的请求队列长度为 Integer.MAX_VALUE ,可能会堆积大量的请求,从而导致 OOM 。

三、简答题
第一题:表名和字段名按规范,表名前缀 HR,使用有意义的单词,单词间用_分割,有中文注释。
第二题:读写分离,冷热分离,按照特定业务纬度分表,使用 ID Hash 取余平均分表等。
第三题:
1、明确影响范围
2、预留应变空间时间
3、交由专家评估合理性和性能
4、脚本交由专人在生产库执行,记录;
5、错峰操作。
第四题:参考规范 3 性能优化 22 ,或者先查询 id,在根据 id 查询数据等都可。
补充:后一个方法为什么可行呢?因为规范要求所有删除都是逻辑删除,能保证库里的数据 id 是从 1 到 1000000 排列的。

反思

有的题目比较抠细节,但是上课好好听应该不难。
大部分规范都取自阿里 Java 编码规范,但很多数据库优化策略很有见地,在日后多作总结。

之前一直在用的老 SSD 只有 128GB,上边装了 Ubuntu 作为宿主 OS,另外常备一个 Windows7 的虚拟机,时常面临空间不够的问题,一般是用 Vmware 中 Hard Disk 的Compact Disk功能来挤出一些空间来,但是这样频繁的硬盘读写对硬盘寿命多少有些影响。
后来换新机时自带的 SSD 是 256GB 的,当时出于方便不想重装环境就用老的 SSD 替换了下来,现在抽出一块时间把数据都转移到新硬盘上,尽量不影响平时的工作。

阅读全文 »

前言

该项目是 2017 年参加学院组织的实训项目期间同小组成员开发的,是一个简化版的电商项目。

涉及到的技术

  1. 掌握高并发站点的系统架构方法。
  2. 熟悉 linux 开发环境,掌握 tomcat、mysql 服务器集群的安装与配置。
  3. 理解 web 服务器的负载均衡原理,掌握配置方法。
  4. 掌握数据库主从复制,读写分离技术。
  5. 理解缓存原理,掌握缓存在程序开发的原理。
  6. 掌握 web 站点的动静分离技术。
阅读全文 »

概念

Git 中有 4 个区域:

  • 工作区( Working Area )
  • 暂存区( Stage )
  • 本地仓库( Local Repository )
  • 远程仓库( Remote Repository )

文件有 5 种状态:

  • 未修改( Origin )

  • 已修改( Modified )

  • 已暂存( Staged )

  • 已提交( Committed )

  • 已推送( Pushed )

  • 文件刚开始处于工作区,编辑文件后可以使用git diff查看文件进行了哪些修改

  • add 命令可以添加到暂存区,可以使用git diff --cached查看文件在暂存区和本地仓库之间的区别

  • commit 提交到本地仓库,可以使用git diff master origin/master查看本地仓库分支和远程仓库分支之间的区别
    注意,Git 保存的不是文件差异或者变化量,而只是一系列文件快照,并使用一个 commit 来指向它们。

  • push 将本地仓库的代码提交到远程仓库

索引

分支

命令

status

1
$ git status -s # 短版status

diff(比较文件)

1
2
3
4
git diff --cached # ,同--staged
git diff branch1 branch2 --stat //显示出所有有差异的文件列表
git diff branch1 branch2 文件名(带路径) //显示指定文件的详细差异
git diff branch1 branch2 //显示出所有有差异的文件的详细差异

log(显示提交历史)

对比分支差异
列出最近两次提交引入的更改(-p 相当于 diff 命令)

1
$ git log -p -2

其他选项

1
2
3
4
5
6
$ git log --stat # 列出每次提交的state(相当于加state命令)
$ git log --pretty=??? # 以某种格式化输出log
$ git log --since=2.weeks # 打印最近2周的log
$ git log -S function_name # 过滤出对function_name字符串有修改的commit,另外在log最后加--file,可以限制只打印对file有修改的commit
--decorate:在结果中显示当前所处的branch
--graph:以更直观的方式区别不同的branch

打印出炫酷的效果:

1
$ git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --date=relative

ignore

.gitignore 文件中的规则匹配的文件会被 git 忽略,但是文件事先要从索引中删除。

config

1
2
3
$ git config --global credential.helper cache # 设置凭证缓存,以后就不用每次都输入密码了,但默认只保存15分钟
$ git config credential.helper 'cache --timeout=3600' # 设置凭证保存一个小时
$ git config --global credential.helper store # 长期存储

add(建立索引)

1
git add .

unmodifying(已修改->未修改)

1
2
3
git checkout .
$ git checkout -- <file>... # 若已tracked的文件被修改,可以使用这条命令恢复到未修改时的情况
git reset --hard

commit(提交)

1
2
$ git commit -a # 自动add并提交追踪过的所有文件
$ git commit --amend # 覆盖上一次的commit,比如可以修改commit message,也可以删除一些文件的索引后再次进行提交,在误提交一些配置文件后可以这样挽救

unstage(撤销 add,已暂存->已修改)

1
2
3
$ git reset HEAD <file>... # 撤销上一次的add
$ git rm -rf --cached [文件] # 仅删除索引,不删除,-r递归,-f强行移除
$ git rm -rf [文件] # 从工作目录和跟踪列表中删除

tag

1
2
3
4
5
6
7
8
$ git tag # 显示所有标签
$ git tag -a v1.4 -m "my version 1.4" # 给最近一次的commit加一个注释标签(相当于一个节点)
$ git show <tag-name> # 显示tag和被tag的commit的信息
$ git tag v1.0-lw # 加一个轻量标签,仅仅在文件中记录最近一次commit的checksum
$ git tag -a v1.2 <checksum> # 在后面指定某个commit的完整(或部分)checksum也可以加一个标签,checksum可以使用$ git log --pretty=oneline得到
$ git push origin [tagname] # 将某个标签推送到远程服务器中
$ git push origin --tags # 将所有标签推送到远程服务器
$ git checkout -b [branchname] [tagname] # 转换到另一branch

aliase

1
2
3
4
5
6
$ git config --global alias.co checkout
$ git config --global alias.br branch
$ git config --global alias.ci commit
$ git config --global alias.st status
$ git config --global alias.unstage 'reset HEAD --'
$ git config --global alias.last 'log -1 HEAD'

以后就可以使用 git ci 来 commit 了

1
$ git config --global alias.visual '!gitk'

为自己写的工具设置别名

push

1
2
3
4
$ git push origin serverfix:awesomebranch # 将serverfix代表的分支推到远程origin的awesomebranch分支上
$ git push origin --delete serverfix # 删除服务器上的一个分支
$ git push --set-upstream origin master # 将当前分支的提交上传到远程仓库的某个分支并与其建立跟踪
$ git push -u origin master # 同上

remote

1
2
3
4
5
6
7
8
9
$ git remote # 显示远程服务器名,默认赋名origin
$ git remote -v # 显示需要从该远程主机读/写时可以使用的URL
$ git remote add <shortname> <url> # 添加一个可用的远程仓库
$ git fetch [remote-name] # 获取远程仓库有而本地仓库没有的信息,但不merge
$ git pull # fetch并merge
$ git push [remote-name] [branch-name] # 向有写权限的主机push数据
$ git remote show [remote-name] # 显示远程仓库的详细信息
$ git remote rename [old] [new] # 重命名仓库的shortname
$ git remote rm [shortname] # 移除远程仓库

branch

1
2
3
4
5
6
$ git branch -v # 显示每个branch的最后一个commit
$ git branch --merged # 过滤branch,只显示并入到当前branch的branches,同样的还有--no-merged
$ git branch -u origin/serverfix # 改变当前分支track的上流分支
$ git branch -vv # 查看每个本地分支track的服务器分支(只和最近一次fetch比较)
$ git branch -r # 查看远程分支列表
$ git branch -D [branch] # 强行删除分支,不管分支有没有被并进来

checkout

1
2
3
4
5
$ git checkout -b [branch] [remotename]/[branch] # 在本地创建一个新分支,与远程的一个分支同步
$ git checkout --track origin/serverfix # 上面的缩写
$ git checkout serverfix # 上面的缩写
$ git checkout -b sf origin/serverfix # 如果将要创建的本地分支与所要track的远程分支不同名
撤销文件的修改(还未add的文件)

rebase(衍合)和 merge(合并)

  1. 实现方式:rebase 和 merge 都是用来在推送之前整合提交历史的,但是实现的方式却不同,rebase 先找出两个分支的公共祖先,然后将一个分支的从该节点之后的所有提交都当成补丁应用到另一个分支上;merge 同样是先找到分支的公共祖先,然后对两个分支的最新提交进行合并???,将合并中修改的内容生成一个新的 commit。
  2. 冲突解决:rebase 和 merge 在实施过程中都有可能会出现冲突的情况,merge 遇见冲突后会直接停止,等待手动解决冲突并重新提交 commit 后,才能再次 merge;而 rebase 遇见冲突后会暂停当前操作,开发者可以选择手动解决冲突、add 更新索引,然后 git rebase –continue 继续,或者 –skip 跳过(注意此操作中当前分支的修改会直接覆盖目标分支的冲突部分),亦或者 –abort 直接停止该次 rebase 操作。
  3. pull 命令其实相当于 fetch+merge,可以通过在后面加上 --rebase 选项来指定为 rebase 操作,或者先 fetch,之后再考虑使用 merge 还是 rebase。
  4. 不要 rebase 已经公开的提交对象,因为它会舍弃当前分支已经提交的 commit,对别人来说就像该分支被回滚了一样,如果 pull 了可能就会出问题。
    https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging#Basic-Merge-Conflicts
  5. 当有修改未 commit 时,不能进行 rebase 操作,此时可以考虑先用 git stash 命令暂存;但是 merge 可能会直接将未 commit 的内容覆盖掉。
    1
    2
    3
    4
    5
    6
    $ git rebase b # 嫁接过去,将当前分支里的补丁应用到目标分支的最后一个提交对象上,也就是在目标分支的最后一个commit的基础上重演一遍修改,最后将当前分支的???指过去
    $ git rebase --onto master server client # 将分支server上的另一分支client衍合到master上
    $ git rebase master server # 将server分支衍合到master中来
    $ git rebase -i b # 显示详细信息,包括各commit会被连接到目标分支的哪个commit后面,并且可以指定命令(策略pick、reword、edit、squash、fixup、exec)???????,默认为pick
    $ git merge b # 将目标分支合并进来
    $ git merge --ff-only # ff的意思是fast-forward,这种情况下要么当前分支已经是最新的了、要么合并是可以fast-forward的(只移动分支指针),默认情况下是--no-ff,也就是普通的合并

stash

1
2
$ git stash apply # 使用最近一次的stash记录来还原当前分支
$ git stash clear # 清空stash表

reset

统计代码量

统计每个人的代码量:

1
git log --format='%aN' | sort -u | while read name; do echo -en "$name\t"; git log --author="$name" --pretty=tformat: --numstat | awk '{ add += $1; subs += $2; loc += $1 - $2 } END { printf "added lines: %s, removed lines: %s, total lines: %s\n", add, subs, loc }' -; done

统计某个人某段时间内的代码量:

1
2
3
4
5
6
git log --since='2019-01-15' --until='2019-12-31' --format='%aN' | sort -u |
while read name; do echo -en "$name,";
git log --since='2019-01-15' --until='2019-12-31' --author="$name" --numstat
--pretty=tformat: --no-merges | awk '{ add += $1; subs += $2; loc += $1 - $2 }
END { printf "added lines, %s, removed lines, %s, total lines, %s\n", add, subs,
loc }' -; done >> XXX_19_12_31_code.csv;

回滚

Git 支持对本地代码库或远程代码库回滚。
参考:git 远程分支回滚

工作流

首先 master 分支即发布的版本,在此上分出一个 develop(huang)版本,每次发布任务时,在 develop 的基础上分出 liu、du、zhou、shen,完成各自的任务,完成后合并到 develop 上。
若 master 上出 bug 了就直接分出一个 hotfix 分支,修复后马上合并到 master 和 develop 上。
开发完成后(即 liu、du、zhou、shen 等的分支都已合并进 develop)在 develop 上分出一个 release,进行测试,最后合并到 master 上。

参考

  1. git-for-computer-scientists
  2. git - Documentation
  3. 常用.gitignore
  4. Git 少用 Pull 多用 Fetch 和 Merge
  5. 闲谈 git merge 与 git rebase 的区别
  6. 解决git误commit大文件导致不能push问题

一、参考

搭建博客

  1. 使用 Github 空间搭建 Hexo 技术博客–安装篇(基于 IntelliJ IDEA)
  2. GitHub Pages

hexo 使用

  1. hexo-指令
  2. NexT 常见问题
  3. hexo 搭建个人博客–基础篇
  4. hexo 搭建个人博客–NexT 主题优化
  5. hexo 搭建个人博客–SEO 和站点加速

在 GitHub 上写博客

  1. Writing on GitHub
  2. hexo 摸爬滚打之进阶教程
  3. hexo 分类与 tags 配置
  4. Emoji 表情
  5. 使用七牛云作为图床获取外链方式总结

添加持久链接功能

  1. hexo 链接持久化终极解决之道

添加评论功能

  1. Hexo NexT 主题中集成 gitalk 评论系统

添加网站统计功能

  1. 不蒜子
  2. Hexo+Next 主题 文章添加阅读次数,访问量等(需要额外添加不蒜子脚本
  3. Hexo 添加字数统计、阅读时长、友情链接

添加看板动画

  1. hexo 添加 live2d 看板动画

添加复选框样式

  1. 用 VPS+hexo 搭了个博客,如何让 hexo 支持复选框
  2. hexojs/hexo-renderer-marked

数学公式

  1. 在 Hexo 中渲染 MathJax 数学公式
  2. MathJax
  3. LaTEX:在线数学公式编辑器
  4. TEX Commands available in MathJax

markdown 格式化

  1. hustcc/lint-md

二、博客搭建

流程

  1. 创建 GitHub Pages 仓库
  2. 安装 hexo 博客框架
  3. 初始化 hexo 项目
  4. 提交 hexo
  5. 配置 hexo

创建 GitHub Pages 仓库

在 GitHub 上创建username.github.io 的一个项目,username 为 github 上的用户名,该仓库将成为 hexo 打包后的发布地址。

安装 hexo

1
2
3
4
5
6
7
8
9
10
11
12
# 更新node版本,需要在root下运行
npm install -g n
n stable
# 使用cnpm安装hexo
alias cnpm="npm --registry=https://registry.npm.taobao.org \
--cache=$HOME/.npm/.cache/cnpm \
--disturl=https://npm.taobao.org/dist \
--userconfig=$HOME/.cnpmrc"
cnpm install -g hexo-cli
# 遇到“Node Sass does not yet support your current environment:”的问题,需要重装node-sass模块
npm uninstall --save node-sass
npm install --save node-sass

初始化 hexo 项目

1
hexo init

创建测试页面。

1
hexo new test

本地测试。

1
2
3
hexo clean
hexo generate
hexo server

打包提交

1
2
3
hexo clean
hexo generate
hexo deploy

hexo升级

https://theme-next.js.org/docs/getting-started/upgrade.html

配置 hexo

主要是对项目下名为_config.yml的配置文件的修改。

博客中的图片显示

刚开始希望把图片直接放到 hexo 项目里,但hexo g时总是出现莫名其妙的错误,图片也没有被拷贝到 public 目录下面,部署后当然看不到。
后来希望使用图床来解决问题,需要有一个图床的客户端,比如极简图床,还需要一个存储后端,常用的如微博图床或七牛,申请七牛还需要验证身份这个过程有些麻烦,微博图床据说也不稳定,有一种图床是专业的图床既提供了前端又自带了后台存储,如sm.ms,但是怎么可能永远免费,想来想去还不如自己搭建一个简单的图片服务器。

  1. 先租一台服务器
  2. 将图片拷贝到服务器上
    1
    scp -r local_path user@address:/server_path
  3. 部署 web 服务器
    如果像我一样懒,可以直接跑一个容器当 web 服务器,nginx 作为静态 web 服务器有很高的性能,以后也可以根据需要做一些其他配置,在服务器上执行下面命令运行一个 nginx 服务器:
    1
    docker run -p 80:80 -v /path/to/resources:/usr/share/nginx/html -v /path/to/logs:/var/log/nginx nginx
  4. 备份
    以防万一,最后在 github 留了一份备份,这样一份图片就有三份备份了。
  5. 注意
    如果图片访问403了,很有可能是权限不够,因为默认ng配置里是通过nginx用户来读取本地路径下的图片的,

    默认ng配置可以通过docker exec <container ID> -it /bin/sh连到容器里查看/etc/nginx/nginx.conf,第一行可以看到配置user nginx;

上边的工作不需要太多时间,以后需要同步的话也比较方便。

HTTPS

https://tallate.github.io/73d751b6.html

三、博客内容构建

  1. categories 和 tags
    hexo 里一篇博客有 categories 和 tags 这两个属性用于对博客进行归档,有时候这两个东西的作用容易搞混,按我的理解, categories 更像是书前面目录的章节, tags 像是书后面对关键词的索引,比如目录中有一章专门讲 Java 技术栈的内容, category 就是 《 Java 》,同理可能还有《 Linux 》,这两章都讲了叫作 Semphore 的东西,所以他们都包含一个叫 Semphore 的 tag ,当然如何划分总归还是要靠个人意愿。
  2. 文章频率
    博客并不是新闻那样的实时内容平台,而是重在积累,两篇相邻的文章之间横跨好几个星期甚至几个月也很正常,如果非常频繁的发博客反而应该怀疑内容的可靠性。

内存池

buffer/cache/pool

cache(高速缓存)是一种介于寄存器和内存之间的存储器,访问速度也在他们二者之间,当用户程序请求存储空间时,会先尝试从 cache 获取,当找不到时再到内存中找,并且找到的页及其临近的几页都会被拿到 cache 中去以备下次访问。
而 buffer 出现的地方比较多,比如 java4 引入的 NIO 中就有一个 Buffer,它的主要作用是缓冲从 Channel 中取到的数据,服务器接收到网络请求时从准备到开始接收数据是有一定的时延的,当出现大量的小请求时,很容易造成资源的浪费,所以引入了缓冲区。
pool 常出现在一些框架中,常见的有数据库连接池、内存池,它和 buffer 有些区别,pool 中的对象往往可以重复利用。

malloc/free

通过调用 malloc 和 free 函数可以实现在堆上的对象分配,这是 C 语言标准库提供的最基本的手动管理内存分配的方法,但是 malloc/free 实现的其实是最基本的 指针碰撞(Bump the Pointer) 内存分配方式,每次分配时都会从空闲块链表表头开始搜索,找到足够大的一块就返回,释放时同样是在空闲块链表中遍历,找到它的分配位置,再和前后两块合并。

显然,这种方式存在一些缺陷:

  • 大量分配后空闲块链表变得很长(时间开销)
  • 产生大量内存碎片(空间开销)
  • 使用麻烦,容易造成内存泄露(申请后忘了释放)

下面是一种 malloc/free 的实现:

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
typedef long Align; //最受限类型

union header{ //块头部
struct{
union header *ptr; //空闲块链表的下一块
unsigned size; //本块的大小
}s;
Align x; //强制块对齐,即强制header最小不小于Align
};
typedef union header Header;
/******************************************/
#define MAXBYTES (unsigned)10240

static Header base;//链表
static Header *freep = NULL;//空闲链表的初始指针

/* malloc函数:存储分配 */
void *malloc(unsigned nbytes){
Header *p, *prevp;
Header *morecore(unsigned);
unsigned nunits;

//超出可分配字节数上限
if(nbytes > MAXBYTES){
fprintf(stderr, "alloc: can't allocate more than %u bytes\n", MAXBYTES);
return NULL;
}

//需要分配的块数
nunits = (nbytes + sizeof(Header) - 1) / sizeof(Header) + 1;
//空闲链表不存在
if((prevp = freep) == NULL){
base.s.ptr = freep = prevp = &base;
base.s.size = 0;
}
for(p = prevp->s.ptr; ; prevp = p, p = p->s.ptr) {
//足够分配
if(p->s.size >= nunits){
if(p->s.size == nunits){
prevp->s.ptr = p->s.ptr;
}
else{
//如果找到的块太大,则分配其末尾部分
p->s.size -= nunits;
p += p->s.size;
p->s.size = nunits;
}
freep = prevp;
return (void*)(p + 1);
}
//闭环的空闲链表
if(p == freep){
if((p = morecore(nunits)) == NULL){
return NULL;//系统无剩余存储空间
}
}
}
}

#define NALLOC 1024 //最小申请单元数
static unsigned maxalloc; //当前已分配的最大内存块的长度
/* morecore函数:向系统申请更多的存储空间 */
Header *morecore(unsigned nu){
char *cp, *sbrk(int);
Header *up;

if(nu < NALLOC){
nu = NALLOC;
}
cp = sbrk(nu * sizeof(Header));
if(cp == (char*) - 1){ //没有空间
return NULL;
}
up = (Header*)cp;
up->s.size = nu;
maxalloc = (up->s.size > maxalloc) ? up->s.size : maxalloc;
//把多余的存储空间插入到空闲区域
free((void*)(up + 1));
return freep;
}

/* free函数:将块ap放入空闲块链表中 */
void free(void *ap){
Header *bp, *p;

bp = (Header*)ap - 1; //块头
//欲free的内存块长度不能等于0或者大于当前已分配的最大内存块长度
if(bp->s.size == 0 || bp->s.size > maxalloc){
fprintf(stderr, "free: can't free %u units\n", bp->s.size);
return ;
}
//扫描空闲块链表,找到bp所处位置,它可能在两个空闲块之间,也可能在链表末尾
for(p = freep; !(bp > p && bp < p->s.ptr); p = p->s.ptr){
//被释放的块在链表的开头或末尾
if(p >= p->s.ptr && (bp > p || bp < p->s.ptr)){
break;
}
}
//与上一相邻(空闲)块合并
if(bp + bp->s.size == p->s.ptr){
bp->s.size += p->s.ptr->s.size;
bp->s.ptr = p->s.ptr->s.ptr;
}
else{//说明中间有已经分配的块
bp->s.ptr = p->s.ptr;
}
//与下一相邻(空闲)块合并
if(p + p->s.size == bp){
p->s.size += bp->s.size;
p->s.ptr = bp->s.ptr;
}
else{//说明中间有已经分配的块
p->s.ptr = bp;
}
}

简单的测试:

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct MyList {
int val;
struct MyList *next;
} MyList;

int main(int argc, char *argv[]) {
int n = 1000000;
while(n--) {
MyList *list = malloc(sizeof(MyList));
free(list);
}
return 0;
}

设计简单内存池

内存池和线程池或数据库连接池(各种池)的实现机制类似,都是事先申请一大块资源,当实际使用时,不必再去申请内存,而是直接从内存池中“拿”,如果内存池设计得好,完全可以避免直接使用 malloc/free 进行内存管理的问题。

下面的代码很大程度上参考了 这里 的实现

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
mem_pool.h
#ifndef _MEM_POOL_H
#define _MEM_POOL_H

#define BLOCKHEAD_SIZE 24 // 两个地址值合为16位,再加上8位验证码
#define MAGIC_CODE 0x123456 // 验证码(用于free内存块的时候判断是否是合法的内存块)

#include <stdio.h>
#include <stdint.h>
#include <malloc.h>

/**
* 内存块
* 1.data包含head和body两部分
* head包括指向对应block的指针、对应list的指针及验证码(MAGIC_CODE)
* 2.双向链表
* 为了free时方便从used中拿出插入到free中
*/
typedef struct MemBlock {
void *data;
struct MemBlock *prev, *next;
} MemBlock;
static MemBlock *newMemBlock(int size) {
MemBlock *block = malloc(sizeof(MemBlock));
if(! block) {
perror("malloc MemBlock failed");
return NULL;
}
block->data = malloc(BLOCKHEAD_SIZE + size);
return block;
}
/**
* 内存表
* 1.每个list保存一种size的内存块
*/
typedef struct MemList {
MemBlock *free, *used;
int size;
struct MemList *next;
} MemList;
static MemList *newMemList(int size) {
MemList *list = malloc(sizeof(MemList));
if(! list) {
perror("malloc MemList failed");
return NULL;
}
list->size = size;
return list;
}
/**
* 内存池
* 1.单例(因为是C语言,所以还未实现)
* 1.蝇量
*/
typedef struct MemPool {
MemList *head;
MemList *last;
} MemPool;
static MemPool *newMemPool() {
MemPool *pool = malloc(sizeof(MemPool));
if(! pool) {
perror("malloc MemPool failed");
return NULL;
}
return pool;
}

void* getBuf(MemPool *pool, size_t bufSize);
int freeBuf(void *buf);

#endif // _MEM_POOL_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#include "mem_pool.h"

/**
* 把内存块想象成一个int64的数组,头三个元素(head)为元数据,
* 之后的部分为分配给用户实际可用的内存
*/
void setMemoryHead(void *buf, MemList *list, MemBlock *block) {
if(buf == NULL) {return ;}
int64_t *data = (int64_t *)buf;
// 设置头部,注意指针有64位
data[0] = (int64_t)list;
data[1] = (int64_t)block;
data[2] = (int64_t)MAGIC_CODE;
}
void* getMemoryBody(void *buf) {
if(buf == NULL) {return NULL;}
int64_t *data = (int64_t *)buf;
return &data[3];
}
/**
* 从释放的内存块中可以找到它所属的list和block节点位置
* @return 0表示失败,其他表示成功
*/
int getFromBuf(void *buf, MemList** list, MemBlock** block) {
// 先转换为字节数组再计算到头部的偏移量,因为
char* cbuf = buf;
int64_t *data = (int64_t *)(buf - BLOCKHEAD_SIZE);
if(data[2] != MAGIC_CODE) {
perror("error: not a valid block");
return 0;
}
// 还原
*list = data[0];
*block = data[1];
return 1;
}

/**
* 从内存池中获取一块内存
* 内存块的大小是固定的,链表的每个节点代表一种大小的内存块
*/
void* getBuf(MemPool *pool, size_t bufSize) {
if(! pool) {return NULL;}
// list为空,表示还未分配过,尝试分配并初始化
if(! pool->head) {
pool->head = newMemList(bufSize);
}
MemList *list = pool->head;

// 查找匹配大小的list
while(list && list->size != bufSize) {
list = list->next;
}

// 没有匹配的list,分配一个,插入到表头处
if(! list) {
MemList *tmp = newMemList(bufSize);
tmp->next = pool->head;
pool->head = tmp;
list = tmp;
}

// 查找已有list中是否存在空余内存块,若没有则新建
if(! list->free) {
list->free = newMemBlock(list->size);
}

// 取一块
MemBlock *block = list->free;
list->free = block->next;
if(list->free) {
list->free->prev = NULL;
}
// 压回已使用块表
if(list->used) {
list->used->prev = block;
}
block->next = list->used;
list->used = block;

// 设置块头部
setMemoryHead(block->data, list, block);
return getMemoryBody(block->data);
}

/**
* 释放一块内存
* @return 0表示释放失败,其他数字表示成功
*/
int freeBuf(void *buf) {
MemList *list = NULL;
MemBlock *block = NULL;
if(! getFromBuf(buf, &list, &block)) {
return 0;
}

// 从used块中取出
if(block->prev) {
block->prev->next = block->next;
}
if(block->next) {
block->next->prev = block->prev;
}
// 放到free块中
if(list->free) {
list->free->prev = block;
}
block->next = list->free;
block->prev = NULL;
list->free = block;

// 操作成功
return 1;
}

简单的测试:

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
typedef struct List {
int val;
struct List *next;
} List;

int main() {
MemPool *pool = newMemPool();
time_t begin, end;
time(&begin);
srand(time(0));
List *list = NULL;
int n = 100000000;
while(n--) {
if(random() % 10 < 7) {
List *node = getBuf(pool, sizeof(List));
node->next = list;
list = node;
} else if(list) {
List *node = list;
list = list->next;
freeBuf(node);
}
}
time(&end);
printf("OK\n");
printf("costs %lf\n", difftime(end, begin));
return 0;
}

可改进的地方:

  • 线程安全,这里有个问题,是把整个池锁住还是把其中的某个表锁住?因为内存池是使用很频繁的,我更倾向于后者(锁住某个表的实现可以参考 Java 的分段锁机制)
  • 一种 size 一个 list,各 list 之间用链表串联起来,如果申请的内存块大小不固定也可能出现这个链表特别长的情况,那样就退化成了 malloc/free,我觉得解决办法是将分配的内存块大小固定成 1024B、2048B 这些量级,但这样又会多出很多内存碎片,又退化成了 malloc/free
  • 引入引用计数或可达性分析算法来管理内存,但是那样的话需要给所有类做一个公共基类,然后重载基类的’=’操作符用于修改引用数,非常麻烦,但是不这么做就退化成了 malloc/free
  • 老实说这个内存池并没有实用价值

一些框架中的内存管理

  1. Doug Lee 的 malloc/free
    ???

  2. Apache 内存池
    Apache 内存池的实现相对上面的来说有很多改进

  • 使用一个free数组来表示上面的MemList,下标每增加 1,内存节点大小增加 4K,这样可以很快定位到要分配的那个内存块链表,如果要分配的内存大小超出数组范围就直接分配到下标 0 处,所以不会有溢出的问题
    节点大小和下标间的转换用到了一个宏APR_ALIGN,使用该宏要求size为整数,boundary为 2 的幂,具体执行过程还是自己代入一组值算一算
    1
    2
    #define APR_ALIGN(size, boundary) \
    (((size)+ ((boundary) - 1)) &~((boundary) - 1))
  • 没有 used 表,分配时就从 free 中取出来返回给用户,找不到就 malloc 一个,释放时插回到 free 表中
  • 用 current_free_index 和 max_free_index 配合限制内存分配器总共能够分配的内存大小
  1. cocos2d-x 内存管理
    对 cocos2d-x 的使用经历比较短,不能很好体会它的设计理念,只列出我记得的一些点:
  • C++11 下本地存储(栈)比堆存储好,最好不要 new 对象
  • cocos2d-x 使用引用计数算法管理内存,大部分内置对象都继承自 cocos2d::Object 类
  1. MySQL 内存池
    MySQL 使用二级缓存机制管理分配的对象,第一级为缓冲池和通用内存池,他们都通过调用 malloc/free 来从操作系统分配/释放内存块,他们的区别下面再讲;第二级为内存堆,它为其他模块提供内存块分配功能
  • 缓冲池(innobase/buf/buf0buf.c、innobase/include/buf0buf.h)
    缓冲池的定义如下

  • 通用内存池(innobase/mem/mem0pool.c、innobase/include/mem0pool.h)
    内存池的定义如下

    折叠/展开代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    struct mem_pool_struct{
    byte* buf; /* memory pool */
    ulint size; /* memory common pool size */
    ulint reserved; /* amount of currently allocated
    memory */
    mutex_t mutex; /* mutex protecting this struct */
    UT_LIST_BASE_NODE_T(mem_area_t)
    free_list[64]; /* lists of free memory areas: an
    area is put to the list whose number
    is the 2-logarithm of the area size */
    };

内存池的创建函数如下所示

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
mem_pool_t*
mem_pool_create(
/*============*/
/* out: memory pool */
ulint size) /* in: pool size in bytes */
{
mem_pool_t* pool;
mem_area_t* area;
ulint i;
ulint used;

ut_a(size > 10000);

pool = ut_malloc(sizeof(mem_pool_t));

/* We do not set the memory to zero (FALSE) in the pool,
but only when allocated at a higher level in mem0mem.c.
This is to avoid masking useful Purify warnings. */

pool->buf = ut_malloc_low(size, FALSE);
pool->size = size;

mutex_create(&(pool->mutex));
mutex_set_level(&(pool->mutex), SYNC_MEM_POOL);

/* Initialize the free lists */

for (i = 0; i < 64; i++) {

UT_LIST_INIT(pool->free_list[i]);
}

used = 0;

while (size - used >= MEM_AREA_MIN_SIZE) {

i = ut_2_log(size - used);

if (ut_2_exp(i) > size - used) {

/* ut_2_log rounds upward */

i--;
}

area = (mem_area_t*)(pool->buf + used);

mem_area_set_size(area, ut_2_exp(i));
mem_area_set_free(area, TRUE);

UT_LIST_ADD_FIRST(free_list, pool->free_list[i], area);

used = used + ut_2_exp(i);
}

ut_ad(size >= used);

pool->reserved = 0;

return(pool);
}

由此可以得出通用内存池和缓冲池之间的差别

1.

1.

  • 内存堆(innobase/mem/mem0mem.c、innobase/include/mem0mem.h、innobase/include/mem0mem.ic)
    内存堆的定义如下

    折叠/展开代码
    1
    2
    /* A memory heap is a nonempty linear list of memory blocks */
    typedef mem_block_t mem_heap_t;

    可见内存堆就是内存块的链表。

  • 内存块(memory block)的定义如下

    折叠/展开代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    struct mem_block_info_struct {
    ulint magic_n;/* 魔数用于debug(标志该块内存是从我这分出去的) *//* magic number for debugging */
    char file_name[8];/* 创建内存堆的代码文件名 *//* file name where the mem heap was created */
    ulint line; /* 创建内存堆的代码行 *//* line number where the mem heap was created */
    UT_LIST_BASE_NODE_T(mem_block_t) base; /* 在第一块(表头)中定义,保存内存块链表的一些元信息,
    包括长度、表头指针、表尾指针 */
    /* In the first block in the
    the list this is the base node of the list of blocks;
    in subsequent blocks this is undefined */
    UT_LIST_NODE_T(mem_block_t) list; /* 内存块的链表(双向链表) *//* This contains pointers to next
    and prev in the list. The first block allocated
    to the heap is also the first block in this list,
    though it also contains the base node of the list. */
    ulint len; /* 当前内存块大小,以字节为单位 *//* physical length of this block in bytes */
    ulint type; /* 该内存堆的类型,内存堆的主要分类依据是:从通用内存池还是缓冲池分配 */
    /* type of heap: MEM_HEAP_DYNAMIC, or
    MEM_HEAP_BUF possibly ORed to MEM_HEAP_BTR_SEARCH */
    ibool init_block; /* TRUE if this is the first block used in fast
    creation of a heap: the memory will be freed
    by the creator, not by mem_heap_free */
    ulint free; /* 当前空闲空间位置 */
    /* offset in bytes of the first free position for
    user data in the block */
    ulint start; /* the value of the struct field 'free' at the
    creation of the block */
    byte* free_block;
    /* if the MEM_HEAP_BTR_SEARCH bit is set in type,
    and this is the heap root, this can contain an
    allocated buffer frame, which can be appended as a
    free block to the heap, if we need more space;
    otherwise, this is NULL */
    #ifdef MEM_PERIODIC_CHECK
    UT_LIST_NODE_T(mem_block_t) mem_block_list;
    /* List of all mem blocks allocated; protected
    by the mem_comm_pool mutex */
    #endif
    };

内存块的创建函数如下所示

折叠/展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
UNIV_INLINE
mem_heap_t*
mem_heap_create_func(
/*=================*/
/* out, own: memory heap */
ulint n, /* in: desired start block size,
this means that a single user buffer
of size n will fit in the block,
0 creates a default size block;
if init_block is not NULL, n tells
its size in bytes */
void* init_block, /* in: if very fast creation is
wanted, the caller can reserve some
memory from its stack, for example,
and pass it as the the initial block
to the heap: then no OS call of malloc
is needed at the creation. CAUTION:
the caller must make sure the initial
block is not unintentionally erased
(if allocated in the stack), before
the memory heap is explicitly freed. */
ulint type, /* in: MEM_HEAP_DYNAMIC, or MEM_HEAP_BUFFER
possibly ORed to MEM_HEAP_BTR_SEARCH */
char* file_name, /* in: file name where created */
ulint line /* in: line where created */
)
{
mem_block_t* block;

if (n > 0) {
// 创建指定大小的内存块
block = mem_heap_create_block(NULL, n, init_block, type,
file_name, line);
} else {
// 以默认的大小创建,MEM_BLOCK_START_SIZE的值为64B
block = mem_heap_create_block(NULL, MEM_BLOCK_START_SIZE,
init_block, type, file_name, line);
}

ut_ad(block);

UT_LIST_INIT(block->base);

/* Add the created block itself as the first block in the list */
UT_LIST_ADD_FIRST(list, block->base, block);

#ifdef UNIV_MEM_DEBUG

if (block == NULL) {

return(block);
}

mem_hash_insert(block, file_name, line);

#endif

return(block);
}

当想要扩增内存堆时调用,它会将扩增的内存块插入内存堆的末尾(mem_block_info_struct 结构中的 base 成员变量包含了表尾指针),而且每次扩增的内存块大小为前一次的两倍大

  1. 内存池和 GC 的关系
    在阅读《垃圾回收的算法与实现》时,除了某人用 GC 交到了女朋友外,使我最印象深刻的应该是前言中一位教授对 GC 和虚拟内存关系的看法:

    既然话说到这里了,我就再介绍一下我的个人看法吧。实际上,GC 相当于虚拟内存。一般的虚拟内存技术是在较小的物理内存的基础上,利用辅助存储创造一片看上去很大的“虚拟”地址空间。也就是说,GC 是扩大内存空间的技术,因此我称其为空间性虚拟存储。这样一来,GC 就成了永久提供一次性存储空间的时间轴方向的时间性虚拟存储。神奇的是,比起称为“垃圾回收”,把 GC 称为“虚拟内存”令人感觉其重要了许多。当初人们根据计算机体系结构开发了许多关于空间性虚拟存储的支持,所以大部分的计算机都标配了空间性虚拟存储。只要硬件支持,GC 性能就能稳步提升,然而现实情况是几乎没有支持 GC 的硬件,这不能不令人感到遗憾。

什么时候使用内存池及一些要点

  1. 小心内存泄露
    如果应用从内存池中获取对象后没有释放回去就很有可能会发生内存泄露的情况,和 C 语言中 malloc 后忘了 free 的情况类似。一般内存池都是单例的,这意味着内存池和整个应用的生命周期是相同的,这块没被释放的内存将一直占用内存空间,而且因为内存池中总是有对这个对象的引用,所以就算有 GC 也没有办法进行回收。
    水平有限我仅谈谈在 Web 应用中的几个解决办法:
    • 缩短内存池的生命周期,比如在一次会话期间占用一个内存池,这个内存池将会被保存在一个 SessionContext 对象中,当会话结束时就释放它
    • 在一些对象价值不是特别高的情形下(比如),可以使用 弱引用对象池 来管理对象???

参考

  1. 《C 程序设计语言》
  2. 内存池的实现 http://www.cnblogs.com/bangerlee/archive/2011/08/31/2161421.html
  3. A memory pool written with C++ https://github.com/bangerlee/mempool
  4. MySQL 内核 InnoDB 存储引擎
  5. https://www.zhihu.com/question/26190832/answer/32387918
  6. 《垃圾回收的算法与实现》
0%