设计模式-策略模式

1 背景

对于业务开发来说,业务逻辑的复杂是必然的。随着业务发展,需求只会越来越复杂,为了考虑到各种各样的情况,代码中不可避免的会出现很多 if-else。一旦代码中 if-else 过多,就会大大的影响其可读性和可维护性。

笔者曾经看到过一个支付的核心应用,这个应用支持了很多业务的线上支付功能,但是每个业务都有很多定制的需求,所以很多核心的代码中都有一大坨 if-else。每个新业务需要定制的时候,都把自己的 if 放到整个方法的最前面,以保证自己的逻辑可以正常执行。这种做法,后果可想而知。

其实,if-else 是有办法可以消除掉的,其中比较典型的并且使用广泛的就是借助策略模式和工厂模式,准确的说是利用这两个设计模式的思想,彻底消灭代码中的 if-else。本文就结合这两种设计模式,介绍如何消除 if-else,并且,还会介绍如何和 Spring 框架结合,这样读者看完本文之后就可以立即应用到自己的项目中。

2 恶心的 if-else

假设我们要做一个外卖平台,有这样的需求:

  1. 外卖平台上的某家店铺为了促销,设置了多种会员优惠(意味着很多if else 分支),其中包含超级会员折扣 8 折、普通会员折扣 9 折和普通用户没有折扣三种。希望用户在付款的时候,根据用户的会员等级,就可以知道用户符合哪种折扣策略,进而进行打折,计算出应付金额。
  2. 随着业务发展,新的需求要求专属会员要在店铺下单金额大于 30 元的时候才可以享受优惠。
  3. 接着,又有一个变态的需求,如果用户的超级会员已经到期了,并且到期时间在一周内,那么就对用户的单笔订单按照超级会员进行折扣,并在收银台进行强提醒,引导用户再次开通会员,而且折扣只进行一次。

那么,我们可以看到以下伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public BigDecimal calPrice(BigDecimal orderPrice, String buyerType) {
if (用户是专属会员) {
if (订单金额大于30元) {
returen 7折价格;
}
}

if (用户是超级会员) {
return 8折价格;
}

if (用户是普通会员) {
if(该用户超级会员刚过期并且尚未使用过临时折扣){
临时折扣使用次数更新();
returen 8折价格;
}
return 9折价格;
}
return 原价;
}

以上,就是对于这个需求的一段价格计算逻辑,使用伪代码都这么复杂,如果是真的写代码,那复杂度可想而知。

这样的代码中,有很多 if-else,并且还有很多的 if-else 的嵌套,无论是可读性还是可维护性都非常低。那么,如何改善呢?

3 策略模式

3.1 概述

接下来,我们尝试引入策略模式来提升代码的可维护性和可读性。

3.2 策略接口

1
2
3
4
5
6
7
8
9
10
/**
* @author mhcoding
*/
public interface UserPayService {

/**
* 计算应付价格
*/
public BigDecimal quote(BigDecimal orderPrice);
}

3.3 不同策略的实现类

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
/**
* @author mhcoding
*/
public class ParticularlyVipPayService implements UserPayService {

@Override
public BigDecimal quote(BigDecimal orderPrice) {
if (消费金额大于30元) {
return 7折价格;
}
}
}

public class SuperVipPayService implements UserPayService {

@Override
public BigDecimal quote(BigDecimal orderPrice) {
return 8折价格;
}
}

public class VipPayService implements UserPayService {

@Override
public BigDecimal quote(BigDecimal orderPrice) {
if(该用户超级会员刚过期并且尚未使用过临时折扣){
临时折扣使用次数更新();
returen 8折价格;
}
return 9折价格;
}
}

3.4 新的计算方式

引入了策略之后,我们可以按照如下方式进行价格计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mhcoding
*/
public class Test {

public static void main(String[] args) {
UserPayService strategy = new VipPayService();
BigDecimal quote = strategy.quote(300);
System.out.println("普通会员商品的最终价格为:" + quote.doubleValue());

strategy = new SuperVipPayService();
quote = strategy.quote(300);
System.out.println("超级会员商品的最终价格为:" + quote.doubleValue());
}
}

以上,就是一个例子,可以在代码中 New 出不同的会员的策略类,然后执行对应的计算价格的方法。

但是,真正在代码中使用,比如在一个 Web 项目中使用,上面这个 Demo 根本没办法直接用,理由如下:

  • 首先,在 Web 项目中,上面我们创建出来的这些策略类都是被 Spring 托管的,我们不会自己去 New 一个实例出来。
  • 其次,在 Web 项目中,如果真要计算价格,也是要事先知道用户的会员等级,比如从数据库中查出会员等级,然后根据等级获取不同的策略类执行计算价格方法。

那么,Web 项目中真正的计算价格的话,伪代码应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author mhcoding
*/
public BigDecimal calPrice(BigDecimal orderPrice,User user) {

String vipType = user.getVipType();

if (vipType == 专属会员) {
//伪代码:从Spring中获取超级会员的策略对象
UserPayService strategy = Spring.getBean(ParticularlyVipPayService.class);
return strategy.quote(orderPrice);
}

if (vipType == 超级会员) {
UserPayService strategy = Spring.getBean(SuperVipPayService.class);
return strategy.quote(orderPrice);
}

if (vipType == 普通会员) {
UserPayService strategy = Spring.getBean(VipPayService.class);
return strategy.quote(orderPrice);
}
return 原价;
}

通过以上代码,我们发现,代码可维护性和可读性好像是好了一些,但是好像并没有减少 if-else 啊。

具体来说策略模式的使用上还是有一个比较大的缺点的:客户端必须知道所有的策略类,并自行决定使用哪一个策略类。这就意味着客户端必须理解这些算法的区别,以便适时选择恰当的算法类。

也就是说,虽然在计算价格的时候没有 if-else 了,但是选择具体的策略的时候还是不可避免的还是要有一些 if-else。

另外,上面的伪代码中,从 Spring 中获取会员的策略对象我们是伪代码实现的,那么代码到底该如何获取对应的 Bean 呢?

接下来我们看如何借助 Spring 和工厂模式,解决上面这些问题。

4 工厂模式

4.1 工厂类

为了方便我们从 Spring 中获取 UserPayService 的各个策略类,我们创建一个工厂类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author mhcoding
*/
public class UserPayServiceStrategyFactory {
private static Map<String,UserPayService> services = new ConcurrentHashMap<String,UserPayService>();

public static UserPayService getByUserType(String type){
return services.get(type);
}

public static void register(String userType, UserPayService userPayService){
Assert.notNull(userType,"userType can't be null");
services.put(userType,userPayService);
}
}

这个 UserPayServiceStrategyFactory 中定义了一个 Map,用来保存所有的策略类的实例,并提供一个 getByUserType 方法,可以根据类型直接获取对应的类的实例。还有一个 Register 方法,这个后面再讲。

4.2 策略+工厂后的计算方式

有了这个工厂类之后,计算价格的代码即可得到大大的优化:

1
2
3
4
5
6
7
/**
* @author mhcoding
*/
public BigDecimal calPrice(BigDecimal orderPrice,User user) {
UserPayService strategy = UserPayServiceStrategyFactory.getByUserType(user.getVipType());
return strategy.quote(orderPrice);
}

以上代码中,不再需要 if-else 了,拿到用户的 vip 类型之后,直接通过工厂的 getByUserType 方法直接调用就可以了。

通过策略+工厂,我们的代码很大程度的优化了,大大提升了可读性和可维护性。

4.3 策略工厂初始化

4.3.1 概述

但是,上面还遗留了一个问题,那就是UserPayServiceStrategyFactory中用来保存所有的策略类的实例的 Map 是如何被初始化的?各个策略的实例对象如何塞进去的呢?

4.3.2 Spring Bean 的注册

还记得我们前面定义的 UserPayServiceStrategyFactory 中提供了的 Register 方法吗?他就是用来注册策略服务的。接下来,我们就想办法调用 Register 方法,把 Spring 通过 IOC 创建出来的 Bean 注册进去就行了。

这种需求,可以借用 Spring 中提供的InitializingBean接口,这个接口为 Bean 提供了属性初始化后的处理方法,它只包括afterPropertiesSet方法,凡是继承该接口的类,在 Bean 的属性初始化后都会执行该方法。那么,我们将前面的各个策略类稍作改造即可:

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
/**
* @author mhcoding
*/
@Service
public class ParticularlyVipPayService implements UserPayService,InitializingBean {

@Override
public BigDecimal quote(BigDecimal orderPrice) {
if (消费金额大于30元) {
return 7折价格;
}
}

@Override
public void afterPropertiesSet() throws Exception {
UserPayServiceStrategyFactory.register("ParticularlyVip",this);
}
}

@Service
public class SuperVipPayService implements UserPayService ,InitializingBean{

@Override
public BigDecimal quote(BigDecimal orderPrice) {
return 8折价格;
}

@Override
public void afterPropertiesSet() throws Exception {
UserPayServiceStrategyFactory.register("SuperVip",this);
}
}

@Service
public class VipPayService implements UserPayService,InitializingBean {

@Override
public BigDecimal quote(BigDecimal orderPrice) {
if(该用户超级会员刚过期并且尚未使用过临时折扣){
临时折扣使用次数更新();
returen 8折价格;
}
return 9折价格;
}

@Override
public void afterPropertiesSet() throws Exception {
UserPayServiceStrategyFactory.register("Vip",this);
}
}

只需要每一个策略服务的实现类都实现 InitializingBean 接口,并实现其 afterPropertiesSet 方法,在这个方法中调用 UserPayServiceStrategyFactory.register,并传入本类的type名和对象实例即可。

这样,在 Spring 初始化创建 VipPayService、SuperVipPayService 和 ParticularlyVipPayService 的时候,会在 Bean 的属性初始化之后,把这个 Bean 注册到 UserPayServiceStrategyFactory 中。

4.3.3 工厂模式优化

以上代码,其实还是有一些菲方可以优化:

  • 重复代码
    这里面还可以引入模板方法模式进一步精简,这里就不展开了。

  • 参数传递魔法值
    还有就是,UserPayServiceStrategyFactory.register 调用的时候,第一个参数需要传一个字符串,这里的话其实也可以优化掉。

    比如使用枚举,或者在每个策略类中自定义一个 getUserType 方法,各自实现即可。

总结

本文,我们通过策略模式、工厂模式以及 Spring 的InitializingBean,提升了代码的可读性以及可维护性,彻底消灭了一坨 if-else。

文中的这种做法,大家可以立刻尝试起来,这种实践,是我们日常开发中经常用到的,而且还有很多衍生的用法,也都非常好用。有机会后面再介绍。

其实,如果读者们对策略模式和工厂模式了解的话,文中使用的并不是严格意义上面的策略模式和工厂模式。

首先,策略模式中重要的 Context 角色在这里面是没有的,没有 Context,也就没有用到组合的方式,而是使用工厂代替了。

另外,这里面的 UserPayServiceStrategyFactory 其实只是维护了一个 Map,并提供了 Register 和 Get 方法而已,而工厂模式其实是帮忙创建对象的,这里并没有用到。

所以,读者不必纠结于到底是不是真的用了策略模式和工厂模式。而且,这里面也再扩展一句,所谓的 GOF 23 种设计模式,无论从哪本书或者哪个博客看,都是简单的代码示例,但是我们日常开发很多都是基于 Spring 等框架的,根本没办法直接用的。

所以,对于设计模式的学习,重要的是学习其思想,而不是代码实现!!!希望通过这样的文章,读者可以真正的在代码中使用上设计模式。


MySQL分库分表

介绍

问题分析

image

随着互联网及移动互联网的发展,应用系统的数据量也是成指数式增长,若采用单数据库进行数据存储,存在以下性能瓶颈:

  1. IO瓶颈:热点数据太多,数据库缓存不足,产生大量磁盘IO,效率较低。 请求数据太多,带宽不够,网络IO瓶颈。
  2. CPU瓶颈:排序、分组、连接查询、聚合统计等SQL会耗费大量的CPU资源,请求数太多,CPU出现瓶颈。

为了解决上述问题,我们需要对数据库进行分库分表处理。

image

分库分表的中心思想都是将数据分散存储,使得单一数据库/表的数据量变小来缓解单一数据库的性能问题,从而达到提升数据库性能的目的。

拆分策略

分库分表的形式,主要是两种:垂直拆分和水平拆分。而拆分的粒度,一般又分为分库和分表,所以组成的拆分策略最终如下:

image

垂直拆分

  1. 垂直分库

image

垂直分库:以表为依据,根据业务将不同表拆分到不同库中。

特点:

  • 每个库的表结构都不一样。
  • 每个库的数据也不一样。
  • 所有库的并集是全量数据。
  1. 垂直分表

image

垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同表中。

特点:

  • 每个表的结构都不一样。
  • 每个表的数据也不一样,一般通过一列(主键/外键)关联。
  • 所有表的并集是全量数据。

水平拆分

  1. 水平分库

image

水平分库:以字段为依据,按照一定策略,将一个库的数据拆分到多个库中。

特点:

  • 每个库的表结构都一样。
  • 每个库的数据都不一样。
  • 所有库的并集是全量数据。
  1. 水平分表

image

水平分表:以字段为依据,按照一定策略,将一个表的数据拆分到多个表中。

特点:

  • 每个表的表结构都一样。
  • 每个表的数据都不一样。
  • 所有表的并集是全量数据。

在业务系统中,为了缓解磁盘IO及CPU的性能瓶颈,到底是垂直拆分,还是水平拆分;具体是分库,还是分表,都需要根据具体的业务需求具体分析。

实现技术

  • shardingJDBC:基于AOP原理,在应用程序中对本地执行的SQL进行拦截,解析、改写、路由处理。需要自行编码配置实现,只支持java语言,性能较高。
  • MyCat:数据库分库分表中间件,不用调整代码即可实现分库分表,支持多种语言,性能不及前者。

image


MySQL日志

错误日志

错误日志是 MySQL 中最重要的日志之一,它记录了当 mysqld 启动和停止时,以及服务器在运行过程中发生任何严重错误时的相关信息。当数据库出现任何故障导致无法正常使用时,建议首先查看此日志。

该日志是默认开启的,默认存放目录 /var/log/,默认的日志文件名为 mysqld.log 。查看日志位置:

1
show variables like '%log_error%';
{6}
1
2
3
4
5
6
7
8
9
10
11
mysql> show variables like '%log_error%';
+----------------------------+----------------------------------------+
| Variable_name | Value |
+----------------------------+----------------------------------------+
| binlog_error_action | ABORT_SERVER |
| log_error | /var/log/mysqld.log |
| log_error_services | log_filter_internal; log_sink_internal |
| log_error_suppression_list | |
| log_error_verbosity | 2 |
+----------------------------+----------------------------------------+
5 rows in set (0.13 sec)

二进制日志

介绍

二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句。

作用:①. 灾难时的数据恢复;②. MySQL的主从复制。在MySQL8版本中,默认二进制日志是开启着的,涉及到的参数如下:

1
show variables like '%log_bin%';
{8}
1
2
3
4
5
6
7
8
-rw-r-----  1 mysql mysql       523 10月  2 00:02 binlog.000008
-rw-r----- 1 mysql mysql 9316 10月 2 14:50 binlog.000009
-rw-r----- 1 mysql mysql 398 10月 2 16:45 binlog.000010
-rw-r----- 1 mysql mysql 695 10月 3 00:25 binlog.000011
-rw-r----- 1 mysql mysql 1416 10月 3 15:36 binlog.000012
-rw-r----- 1 mysql mysql 1600 10月 4 22:30 binlog.000013
-rw-r----- 1 mysql mysql 156 10月 5 11:41 binlog.000014
-rw-r----- 1 mysql mysql 224 10月 5 11:41 binlog.index #二进制索引文件

参数说明:

  • log_bin_basename:当前数据库服务器的binlog日志的基础名称(前缀),具体的binlog文件名需要再该basename的基础上加上编号(编号从000001开始)。
  • log_bin_index:binlog的索引文件,里面记录了当前服务器关联的binlog文件有哪些。

格式

MySQL服务器中提供了多种格式来记录二进制日志,具体格式及特点如下:

日志格式 含义
STATEMENT 基于SQL语句的日志记录,记录的是SQL语句,对数据进行修改的SQL都会记录在日志文件中。
ROW 基于行的日志记录,记录的是每一行的数据变更。(默认)
MIXED 混合了STATEMENT和ROW两种格式,默认采用STATEMENT,在某些特殊情况下会自动切换为ROW进行记录。
1
show variables like '%binlog_format';
{5}
1
2
3
4
5
6
7
mysql> show variables like '%binlog_format';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| binlog_format | ROW |
+---------------+-------+
1 row in set (0.01 sec)

如果我们需要配置二进制日志的格式,只需要在 /etc/my.cnf 中配置 binlog_format 参数即可。

日志刷盘

事务执行过程中,先将日志写(write)到 binlog cache,事务提交时再把 binlog cache 写(fsync)到 binlog 文件中,一个事务的 binlog 是不能被拆开的,所以不论这个事务多大也要确保一次性写入

事务提交时执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache

write 和 fsync 的时机由参数 sync_binlog 控制的:

  • sync_binlog=0:表示每次提交事务都只 write,不 fsync
  • sync_binlog=1:表示每次提交事务都会执行 fsync
  • sync_binlog=N(N>1):表示每次提交事务都 write,但累积 N 个事务后才 fsync,但是如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志

查看

由于日志是以二进制方式存储的,不能直接读取,需要通过二进制日志查询工具 mysqlbinlog 来查看,具体语法:

1
2
3
4
5
6
7
mysqlbinlog [ 参数选项 ] logfilename

参数选项:
-d 指定数据库名称,只列出指定的数据库相关操作。
-o 忽略掉日志中的前n行命令。
-v 将行事件(数据变更)重构为SQL语句
-vv 将行事件(数据变更)重构为SQL语句,并输出注释信息

删除

对于比较繁忙的业务系统,每天生成的binlog数据巨大,如果长时间不清除,将会占用大量磁盘空间。可以通过以下几种方式清理日志:

指令 含义
reset master 删除全部 binlog 日志,删除之后,日志编号,将从 binlog.000001重新开始
purge master logs to ‘binlog.*’ 删除 * 编号之前的所有日志
purge master logs before ‘yyyy-mm-dd hh24:mi:ss’ 删除日志为 “yyyy-mm-dd hh24:mi:ss” 之前产生的所有日志

也可以在mysql的配置文件中配置二进制日志的过期时间,设置了之后,二进制日志过期会自动删除。

1
show variables like '%binlog_expire_logs_seconds%';

查询日志

查询日志中记录了客户端的所有操作语句,而二进制日志不包含查询数据的SQL语句。默认情况下,查询日志是未开启的。

1
show variables like '%general%';
{5,6}
1
2
3
4
5
6
7
8
mysql> show variables like '%general%';
+------------------+--------------------------+
| Variable_name | Value |
+------------------+--------------------------+
| general_log | OFF |
| general_log_file | /var/lib/mysql/frx01.log |
+------------------+--------------------------+
2 rows in set (0.00 sec)

如果需要开启查询日志,可以修改MySQL的配置文件 /etc/my.cnf 文件,添加如下内容:

1
2
3
4
5
6
7
#该选项用来开启查询日志 , 可选值 : 0 或者 1 ; 0 代表关闭, 1 代表开启

general_log=1

#设置日志的文件名 , 如果没有指定, 默认的文件名为 host_name.log

general_log_file=mysql_query.log

开启了查询日志之后,在MySQL的数据存放目录,也就是 /var/lib/mysql/ 目录下就会出现mysql_query.log 文件。之后所有的客户端的增删改查操作都会记录在该日志文件之中,长时间运行后,该日志文件将会非常大。

慢查询日志

慢查询日志记录了所有执行时间超过参数 long_query_time 设置值并且扫描记录数不小于
min_examined_row_limit 的所有的SQL语句的日志,默认未开启。long_query_time 默认为10 秒,最小为 0, 精度可以到微秒。

如果需要开启慢查询日志,需要在MySQL的配置文件 /etc/my.cnf 中配置如下参数:

1
2
3
4
5
6
7
#慢查询日志

slow_query_log=1

#执行时间参数

long_query_time=2

默认情况下,不会记录管理语句,也不会记录不使用索引进行查找的查询。可以使用
log_slow_admin_statements和 更改此行为 log_queries_not_using_indexes,如下所述。

1
2
3
4
5
6
7
#记录执行较慢的管理语句

log_slow_admin_statements =1

#记录执行较慢的未使用索引的语句

log_queries_not_using_indexes = 1

上述所有的参数配置完成之后,都需要重新启动MySQL服务器才可以生效。

{12-17}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[root@frx01 mysql]# tail -f frx01-slow.log
# Query_time: 4.687803 Lock_time: 0.000077 Rows_sent: 1 Rows_examined: 0
use frx01;
SET timestamp=1664871559;
SELECT COUNT(*) FROM `tb_user`;
/usr/sbin/mysqld, Version: 8.0.26 (MySQL Community Server - GPL). started with:
Tcp port: 3306 Unix socket: /var/lib/mysql/mysql.sock
Time Id Command Argument
/usr/sbin/mysqld, Version: 8.0.26 (MySQL Community Server - GPL). started with:
Tcp port: 3306 Unix socket: /var/lib/mysql/mysql.sock
Time Id Command Argument
# Time: 2022-10-05T13:40:50.099040Z
# User@Host: root[root] @ localhost [] Id: 8
# Query_time: 3.980600 Lock_time: 0.000070 Rows_sent: 0 Rows_examined: 1000000
use frx01;
SET timestamp=1664977246;
select * from tb_user limit 1000000,10;