开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Docker 搭建 Redis Cluster集群 每一步都

发表于 2021-08-05

这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战

之前无论学什么东西,总感觉只要会写小Demo就完事了。但随着学习的深入,(内卷)接触的越来越多,集群、JVM、数据结构、算法、底层、Liunx 系统,任重而道远啊。

分享一句很喜欢的话:“八小时谋生活,八小时外谋发展”。

如果你也喜欢,那一起共勉😁😁

一、环境

  • 阿里云服务器 CentOS 8
  • docker版本为20.10.7
  • redis 镜像 (拉取为默认最新镜像)

在这里插入图片描述

在这里插入图片描述

大致步骤:😜

  1. 下载Redis镜像
  2. 编写Redis配置文件
  3. 启动Redis 容器
  4. 创建Redis Cluster 集群。
  5. 进行实际测试

二、前期准备

2.1、搜索、拉取redis镜像

1
2
bash复制代码docker search redis
docker pull redis

在这里插入图片描述

在这里插入图片描述

2.2、Docker 容器网络

  1. 创建虚拟网卡😄

创建虚拟网卡,主要是用于redis-cluster能于外界进行网络通信,一般常用桥接模式。

1
bash复制代码docker network create myredis
  1. 查看Docker 网卡信息
1
bash复制代码docker network ls

在这里插入图片描述

3、查看dockerr网络详细信息

1
bash复制代码docker network inspect myredis

在这里插入图片描述

4、补充(删除网卡信息、帮助命令)

1
2
bash复制代码docker network rm myredis #删除网卡命令 多个中间 空格隔开
docker network --help #显示可带参数等

2.3、编写配置文件

此处用到了一点 shlle 编程中 的一些命令,让我们操作更加便利。😃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bash复制代码for port in $(seq 6379 6384); 
do
mkdir -p /home/redis/node-${port}/conf
touch /home/redis/node-${port}/conf/redis.conf
cat << EOF > /home/redis/node-${port}/conf/redis.conf
port ${port}
requirepass 1234
bind 0.0.0.0
protected-mode no
daemonize no
appendonly yes
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
cluster-announce-ip 服务器就填公网ip,或者内部对应容器的ip
cluster-announce-port ${port}
cluster-announce-bus-port 1${port}
EOF
done

命令解释:😊

  • port:节点端口;
  • requirepass:设置密码,访问时需要验证
  • protected-mode:保护模式,默认值 yes,即开启。开启保护模式以后,需配置 bind ip 或者设置访问密码;关闭保护模式,外部网络可以直接访问;
  • daemonize:是否以守护线程的方式启动(后台启动),默认 no;
  • appendonly:是否开启 AOF 持久化模式,默认 no;
  • cluster-enabled:是否开启集群模式,默认 no;
  • cluster-config-file:集群节点信息文件;
  • cluster-node-timeout:集群节点连接超时时间;
  • cluster-announce-ip:集群节点 IP
    • 注意: 如果你想要你的redis集群可以供外网访问,这里直接填 服务器的IP 地址即可
    • 如若为了安全,只是在服务器内部进行访问,这里还需要做一些修改。
  • cluster-announce-port:集群节点映射端口;
  • cluster-announce-bus-port:集群节点总线端口。

redis 在官网上有说明为什么需要映射两个端口 :redis官网

在这里插入图片描述


执行命令完:

在这里插入图片描述

我们通过tree 命令查看目录结构:(如果没有 tree 命令先安装 yum install -y tree)

在这里插入图片描述


接下来就是启动容器拉

三、启动容器

3.1、启动redis容器

因为要启动六个容器,一个一个去启动,肯定是麻烦丫。就再次借助shell编程的力量。

1
2
3
4
5
6
7
8
bash复制代码for port in $(seq 6379 6384); \
do \
docker run -it -d -p ${port}:${port} -p 1${port}:1${port} \
--privileged=true -v /home/redis/node-${port}/conf/redis.conf:/usr/local/etc/redis/redis.conf \
--privileged=true -v /home/redis/node-${port}/data:/data \
--restart always --name redis-${port} --net myredis \
--sysctl net.core.somaxconn=1024 redis redis-server /usr/local/etc/redis/redis.conf
done

解释:🤑

  • -it:交互
  • -d:后台运行,容器启动完成后打印容器
  • –privileged:是否让docker 应用容器 获取宿主机root权限(特殊权限-)
  • -p :端口映射
  • -v:文件挂载
  • –sysctl参数来设置系统参数,通过这些参数来调整系统性能
  • –restart always:在容器退出时总是重启容器
  • –name :给容器取名
  • –net myredis :使用我们创建的虚拟网卡 (想详细了解,可以去看看Docker 网络方面知识)

执行完成:

在这里插入图片描述

亦可使用docker ps -a 查看运行中容器。

在这里插入图片描述

可以看到已全部启动成功。

👨‍🔧

3.2、创建Redis Cluster集群

可随意选择一个节点进入,创建Redis集群。

1、进入redis-6379 容器

1
bash复制代码docker exec -it redis-6379 /bin/bash

在这里插入图片描述

2、创建集群

1
bash复制代码redis-cli  -a 之前设置的密码 --cluster create 配置文件中的IP地址:6379 IP地址:6380 IP地址:6381 IP地址:6382 IP地址:6383 IP地址:6384   --cluster-replicas 1

在这里插入图片描述

在这里插入图片描述

显示这样的画面就表示已经成功拉。

3、查看节点相关信息

进入容器后,通过redis-cli -c -a 1234,进入redis。👼

在这里插入图片描述

输入cluster info 查看集群信息

在这里插入图片描述

也可输入cluster nodes 查看所有节点相关信息

在这里插入图片描述


👨‍💻下一步就是进入测试阶段拉。😀

四、测试

4.1、本机测试

在这里插入图片描述

在这里插入图片描述

我们在 6381中 set 进去,同时在 6379 中能够取出来,这代表我们已经成功拉😁

4.2、外网测试

进入windows中 你redis下载的目录中,进入cmd。

在这里插入图片描述

测试说明 我们已经可以连接拉。

那么我们就开启两个cmd 窗口,用不一样的端口进入。

在这里插入图片描述

在这里插入图片描述

到此,可以证明我们的集群是已经搭建成功拉。

4.3、出错可能会用到的命令

批量停止容器

1
2
3
4
bash复制代码 for port in $(seq 6379 6384); 
 do
 docker stop redis-${port}
 done

批量删除容器

1
2
3
4
bash复制代码 for port in $(seq 6379 6384); 
 do
 docker rm redis-${port}
 done

4.4、可能会出的错

一直卡在Waiting for the cluster to join ……

注: 如果是阿里云或者腾讯云上的服务器 ,要记得打开安全组规则, 6379~6384和16379 ~16384都要打开。

如果是虚拟机上,可能牵扯到防火墙,这个得注意一下。

五、博主自言

👩‍💻

如若存在错误,欢迎大家不啬赐教!!!

如若存在疑惑或执行错误,请大家评论或私信,定会第一时间回复。

一起继续努力,或者啊,咱们一起🛌 ☺。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

设计模式最佳套路5 —— 愉快地使用工厂方法模式 什么是工厂

发表于 2021-08-05

本篇是设计模式的第五篇,前四篇可见:

「设计模式最佳套路1——愉快地使用策略模式」

「设计模式最佳套路2——愉快地使用管道模式」

「设计模式最佳套路3——愉快地使用代理模式」

「设计模式最佳套路4——愉快地使用模板模式」

什么是工厂方法模式

工厂方法模式(Factory Method Pattern)也被称为多态工厂模式,其定义了一个创建某种产品的接口,但由子类决定要实例化的产品是哪一个,从而把产品的实例化推迟到子类。

image.png

何时使用工厂方法模式

工厂模式一般配合策略模式一起使用,当系统中有多种产品(策略),且每种产品有多个实例时,此时适合使用工厂模式:每种产品对应的工厂提供该产品不同实例的创建功能,从而避免调用方和产品创建逻辑的耦合,完美符合迪米特法则(最少知道原则)。

愉快地使用工厂方法模式

背景

在平常开发中,我们经常会在 Spring 中实现诸如这样的功能:收集某一类具有共同特征的 Bean(都实现了某个接口或者都打上了某个注解等),然后放入容器中(一般是 Map),使用的时候根据 Bean 的标识,来获取到对应的 Bean。比如我之前文章中的 通过表单标识获得表单对应提交处理器的 FormDataHandlerFactory:

1
2
3
4
typescript复制代码@Componentpublic class FormDataHandlerFactory {
private static final Map<String, FormDataHandler> FORM_DATA_HANDLER_MAP = new HashMap<>(16);
/** * 根据表单标识,获取对应的 Handler * * @param formCode 表单标识 * @return 表单对应的 Handler */ public FormDataHandler getHandler(String formCode) { return FORM_DATA_HANDLER_MAP.get(formCode); }
@Autowired public void setFormDataHandlers(List<FormDataHandler> handlers) { for (FormDataHandler handler : handlers) { FORM_DATA_HANDLER_MAP.put(handler.getFormCode(), handler); } }}

通过表单项类型获得表单项转换器的 FormItemConverterFactory:

1
2
3
4
typescript复制代码@Componentpublic class FormItemConverterFactory {
private static final EnumMap<FormItemTypeEnum, FormItemConverter> CONVERTER_MAP = new EnumMap<>(FormItemTypeEnum.class);
/** * 根据表单项类型获得对应的转换器 * * @param type 表单项类型 * @return 表单项转换器 */ public FormItemConverter getConverter(FormItemTypeEnum type) { return CONVERTER_MAP.get(type); }
@Autowired public void setConverters(List<FormItemConverter> converters) { for (final FormItemConverter converter : converters) { CONVERTER_MAP.put(converter.getType(), converter); } }}

在我见过的系统中,看到过非常多类似的代码,每次需要这样的功能,就是定义一个新的 XxxFactory,甚至还有直接在调用者里面直接写上这些获取对应 Bean 的代码,直接违反 单一原则。在这个时候,其实我们已经趋近于使用工厂方法模式,我们更倾向于称这种 XxxFactory 为简单工厂。不停地使用这种简单工厂的问题在于会导致 重复的代码,因而也就自然而然的违背了 DRY 原则(Don’t Repeat Yourself)。虽然重复的代码并不多,但是对于我们 Programmer 来说,写重复的代码无异于往我们脸上吐唾沫 —— 是可忍,孰不可忍!

所以接下来基于上面这个场景,我分享一下我目前基于 Spring 实现工厂方法模式的 “最佳套路”(如果你有更好的套路,欢迎赐教和讨论哦)~

方案

其实设计模式的核心就在于,找出变化的部分,然后对变化进行抽象和封装,从而使得代码能够满足面向对象的基本原则。对于工厂方法模式来说,变化的是产品、工厂,因而我们可以先定义出抽象的产品和抽象的工厂。

抽象的产品(策略):

1
2
csharp复制代码public interface Strategy<T> {
/** * 获得策略的标识 */ T getId();}

每个产品必须实现 Strategy 接口,代表每个产品必须有一个唯一的标识。

抽象的策略工厂:

1
2
3
4
5
6
7
8
9
java复制代码public abstract class StrategyFactory<T, S extends Strategy<T>>                 implements InitializingBean, ApplicationContextAware {
private Map<T, S> strategyMap;
private ApplicationContext appContext;
/** * 根据策略 id 获得对应的策略的 Bean * * @param id 策略 id * @return 策略的 Bean */ public S getStrategy(T id) { return strategyMap.get(id); }
/** * 获取策略的类型(交给子类去实现) * * @return 策略的类型 */ protected abstract Class<S> getStrategyType();
@Override public void afterPropertiesSet() { // 获取 Spring 容器中,所有 S 类型的 Bean Collection<S> strategies = appContext.getBeansOfType(getStrategyType()).values();
strategyMap = Maps.newHashMapWithExpectedSize(strategies.size());
// 将所有 S 类型的 Bean 放入到 strategyMap 中 for (final S strategy : strategies) { T id = strategy.getId(); strategyMap.put(id, strategy); } }
@Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { appContext = applicationContext; }}

Spring 容器在启动的时候,会去扫描工厂指定的类型(Class < S >)的 Bean,并将其注册到工厂中(加入到 strategyMap)。所以对于工厂中产品的生产过程,借助 Spring,我们躺好就行。

接下来基于我们的抽象产品和抽象工厂,我们重构上面的两个 Factory:

通过表单标识获得表单对应提交处理器的 FormDataHandlerFactory

1
2
scala复制代码@Componentpublic class FormDataHandlerFactory extends StrategyFactory<String, FormDataHandler> {
@Override protected Class<FormDataHandler> getStrategyType() { return FormDataHandler.class; }}

FormDataHandlerFactory 只需要指定一下其产品类型为 FormDataHandler。当然,FormDataHandler 我们也需要改造一下:

1
2
3
4
typescript复制代码public interface FormDataHandler extends Strategy<String> {
@Override default String getId() { return getFormCode(); }
String getFormCode();
CommonResponse<Object> submit(FormSubmitRequest request);}

通过表单项类型获得表单项转换器的 FormItemConverterFactory

1
2
scala复制代码@Componentpublic class FormItemConverterFactory extends StrategyFactory<FormItemTypeEnum, FormItemConverter> {
@Override protected Class<FormItemConverter> getStrategyType() { return FormItemConverter.class; }}

此时,FormItemConverterFactory 也只需要指定一下产品的类型,不再会写重复代码。同理,需要改造一下 FormItemConverter:

1
2
3
4
csharp复制代码public interface FormItemConverter extends Strategy<FormItemTypeEnum> {
@Override default FormItemTypeEnum getId() { return getType(); }
FormItemTypeEnum getType();
FormItem convert(FormItemConfig config);}

image.png

如果这个时候新加一个 通过列表标识获得列表数据拉取器的 ListDataFetcherFactory,那么首先定义出获取列表数据的接口(产品):

1
typescript复制代码public interface ListDataFetcher extends Strategy<String> {        CommonResponse<JSONObject> fetchData(ListDataFetchRequest request);}

然后再实现 ListDataFetcherFactory(工厂):

1
2
scala复制代码@Componentpublic class ListDataFetcherFactory extends StrategyFactory<String, ListDataFetcher> {
@Override protected Class<ListDataFetcher> getStrategyType() { return ListDataFetcher.class; }}

通过抽象产品 Strategy 和抽象工厂 StrategyFactory,我们的代码完美符合了 DRY 原则。

优化

借助反射

借助反射,我们还可以使得工厂代码变得更加简单:因为如果父类包含泛型参数,且子类对泛型参数进行了具体化,那么这个具体化的泛型类型,可在运行时获取到。基于这个特性,我们可以改造 StrategyFactory:

1
2
3
4
5
scala复制代码public abstract class StrategyFactory<T, S extends Strategy<T>>                implements InitializingBean, ApplicationContextAware {
...
/** * 通过反射获取策略的类型 * * @return 策略的类型 */ protected Class<S> getStrategyType() { // getClass 获取当前运行时实例的类,getGenericSuperclass 获得泛型父类 Type superclass = getClass().getGenericSuperclass(); ParameterizedType pt = (ParameterizedType) superclass; Type[] actualTypeArguments = pt.getActualTypeArguments(); // 获得索引为 1 的实际参数类型,即第二个实际参数的类型 Type actualTypeArgument = actualTypeArguments[1]; @SuppressWarnings("unchecked") Class<S> result = (Class<S>) actualTypeArgument;
return result; }
...}

那么上面三个 Factory 写起来就更简单了:

1
java复制代码@Componentpublic class FormDataHandlerFactory extends StrategyFactory<String, FormDataHandler> {}
1
scala复制代码@Componentpublic class FormItemConverterFactory extends StrategyFactory<FormItemTypeEnum, FormItemConverter> {}

`- \

1
scala复制代码@Componentpublic class ListDataFetcherFactory extends StrategyFactory<String, ListDataFetcher> {}

组合优先于继承

上述的方案是通过继承,并借助泛型的反射功能,由子类来指定策略( S getStrategyType)的类型。如果工厂类型较多,那么每次新加一个工厂类,容易导致 “类爆炸”。对于上述的方案,变化的部分就是策略的类型,除了继承,我们还可以通过组合来解决这个变化。修改我们的 StrategyFactory:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class StrategyFactory<T, S extends Strategy<T>>        implements InitializingBean, ApplicationContextAware {
private final Class<S> strategyType;
private Map<T, S> strategyMap;
private ApplicationContext appContext;
/** * 创建一个策略工厂 * * @param strategyType 策略的类型 */ public StrategyFactory(Class<S> strategyType) { this.strategyType = strategyType; }
/** * 根据策略 id 获得对应的策略的 Bean * * @param id 策略 id * @return 策略的 Bean */ public S getStrategy(T id) { return strategyMap.get(id); }
@Override public void afterPropertiesSet() { // 获取 Spring 容器中,所有 S 类型的 Bean Collection<S> strategies = appContext.getBeansOfType(strategyType).values();
strategyMap = Maps.newHashMapWithExpectedSize(strategies.size());
// 将 所有 S 类型的 Bean 放入到 strategyMap 中 for (final S strategy : strategies) { T id = strategy.getId();
strategyMap.put(id, strategy); } }
@Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { appContext = applicationContext; }}

此时 StrategyFactory 不再是抽象类,并且为 StrategyFactory 引入一个新的属性 strategyType,并且在构造 StrategyFactory 就必须设置当前工厂中的策略(产品)类型。那么对于 FormDataHandlerFactory、FormItemConverterFactory 和 ListDataFetcherFactory,我们不需要再通过继承产生,直接通过配置进行组合即可:

1
2
3
4
csharp复制代码@Configurationpublic class FactoryConfig {
@Bean public StrategyFactory<String, FormDataHandler> formDataHandlerFactory() { return new StrategyFactory<>(FormDataHandler.class); }
@Bean public StrategyFactory<FormItemTypeEnum, FormItemConverter> formItemConverterFactory() { return new StrategyFactory<>(FormItemConverter.class); }
@Bean public StrategyFactory<String, ListDataFetcher> listDataFetcherFactory() { return new StrategyFactory<>(ListDataFetcher.class); }}

全域营销团队

战斗在阿里电商的核心地带,负责连接供需两端,支持电商营销领域的各类产品、平台和解决方案,其中包括聚划算、百亿补贴、天猫U先、天猫小黑盒、天猫新品孵化、品牌号等重量级业务。我们深度参与双11、618、99划算节等年度大促,不断挑战技术的极限!我们致力于打造幸福感极强的技术团队,有深耕电商精研技术的老司机,也有朝气蓬勃的小萌新,更有可颜可甜的小姐姐,期待具有好奇心和思考力的你的加入!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

设计模式最佳套路4 —— 愉快地使用模板模式 什么是模板模式

发表于 2021-08-05

本篇为设计模式第四篇,前三篇戳下方链接阅读:

「设计模式最佳套路1——愉快地使用策略模式」

「设计模式最佳套路2——愉快地使用管道模式」

「设计模式最佳套路3——愉快地使用代理模式」

什么是模板模式

模板模式(Template Pattern) 又叫模板方法模式,其定义了操作的流程,并将流程中的某些步骤延迟到子类中进行实现,使得子类在不改变操作流程的前提下,即可重新定义该操作的某些特定步骤。例如做菜,操作流程一般为 “准备菜”->“放油”->“炒菜”->“调味”->“装盘”,但可能对于不同的菜要放不同类型的油,不同的菜调味方式也可能不一样。

何时使用模板模式

当一个操作的流程较为复杂,可分为多个步骤,且对于不同的操作实现类,流程步骤相同,只有部分特定步骤才需要自定义,此时可以考虑使用模板模式。如果一个操作不复杂(即只有一个步骤),或者不存在相同的流程,那么应该使用策略模式。从这也可看出模板模式和策略模式的区别:策略模式关注的是多种策略(广度),而模板模式只关注同种策略(相同流程),但是具备多个步骤,且特定步骤可自定义(深度)。

愉快地使用模板模式

背景

我们平台的动态表单在配置表单项的过程中,每新增一个表单项,都要根据表单项的组件类型(例如 单行文本框、下拉选择框)和当前输入的各种配置来转换好对应的 Schema 并保存在 DB 中。一开始,转换的代码逻辑大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ini复制代码public class FormItemConverter {
/** * 将输入的配置转变为表单项 * * @param config 前端输入的配置 * @return 表单项 */ public FormItem convert(FormItemConfig config) { FormItem formItem = new FormItem();
// 公共的表单项属性 formItem.setTitle(config.getTitle()); formItem.setCode(config.getCode()); formItem.setComponent(config.getComponent());
// 创建表单组件的属性 FormComponentProps props = new FormComponentProps(); formItem.setComponentProps(props); // 公共的组件属性 if (config.isReadOnly()) { props.setReadOnly(true); }
FormItemTypeEnum type = config.getType();
// 下拉选择框的特殊属性处理 if (type == ComponentTypeEnum.DROPDOWN_SELECT) { props.setAutoWidth(false);
if (config.isMultiple()) { props.setMode("multiple"); } }
// 模糊搜索框的特殊属性处理 if (type == ComponentTypeEnum.FUZZY_SEARCH) { formItem.setFuzzySearch(true); props.setAutoWidth(false); }
// ... 其他组件的特殊处理
// 创建约束规则 List<FormItemRule> rules = new ArrayList<>(2); formItem.setRules(rules);
// 每个表单项都可有的约束规则 if (config.isRequired()) { FormItemRule requiredRule = new FormItemRule(); requiredRule.setRequired(true); requiredRule.setMessage("请输入" + config.getTitle());
rules.add(requiredRule); }
// 文本输入框才有的规则 if (type == ComponentTypeEnum.TEXT_INPUT || type == ComponentTypeEnum.TEXT_AREA) { Integer minLength = config.getMinLength();
if (minLength != null && minLength > 0) { FormItemRule minRule = new FormItemRule(); minRule.setMin(minLength); minRule.setMessage("请至少输入 " + minLength + " 个字");
rules.add(minRule); }
Integer maxLength = config.getMaxLength();
if (maxLength != null && maxLength > 0) { FormItemRule maxRule = new FormItemRule(); maxRule.setMax(maxLength); maxRule.setMessage("请最多输入 " + maxLength + " 个字");
rules.add(maxRule); } }
// ... 其他约束规则
return formItem; }}

很明显,这份代码违反了 开闭原则(对扩展开放,对修改关闭):如果此时需要添加一种新的表单项(包含特殊的组件属性),那么不可避免的要修改 convert 方法来进行新表单项的特殊处理。观察上面的代码,将配置转变为表单项 这个操作,满足以下流程:

  1. 创建表单项,并设置通用的表单项属性,然后再对不同表单项的特殊属性进行处理
  2. 创建组件属性,处理通用的组件属性,然后再对不同组件的特殊属性进行处理
  3. 创建约束规则,处理通用的约束规则,然后再对不同表单项的特性约束规则进行处理
    这不正是符合模板模式的使用场景(操作流程固定,特殊步骤可自定义处理)吗?基于上面这个场景,下面我就分享一下我目前基于 Spring 实现模板模式的 “最佳套路”(如果你有更好的套路,欢迎赐教和讨论哦)~

方案

定义出模板

即首先定义出表单项转换的操作流程,即如下的 convert 方法(使用 final 修饰,确保子类不可修改操作流程):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scss复制代码public abstract class FormItemConverter {
/** * 子类可处理的表单项类型 */ public abstract FormItemTypeEnum getType();
/** * 将输入的配置转变为表单项的操作流程 * * @param config 前端输入的配置 * @return 表单项 */ public final FormItem convert(FormItemConfig config) { FormItem item = createItem(config); // 表单项创建完成之后,子类如果需要特殊处理,可覆写该方法 afterItemCreate(item, config);
FormComponentProps props = createComponentProps(config); item.setComponentProps(props); // 组件属性创建完成之后,子类如果需要特殊处理,可覆写该方法 afterPropsCreate(props, config);
List<FormItemRule> rules = createRules(config); item.setRules(rules); // 约束规则创建完成之后,子类如果需要特殊处理,可覆写该方法 afterRulesCreate(rules, config);
return item; }
/** * 共用逻辑:创建表单项、设置通用的表单项属性 */ private FormItem createItem(FormItemConfig config) { FormItem formItem = new FormItem();
formItem.setCode(config.getCode()); formItem.setTitle(config.getTitle()); formItem.setComponent(config.getComponent());
return formItem; }
/** * 表单项创建完成之后,子类如果需要特殊处理,可覆写该方法 */ protected void afterItemCreate(FormItem item, FormItemConfig config) { }
/** * 共用逻辑:创建组件属性、设置通用的组件属性 */ private FormComponentProps createComponentProps(FormItemConfig config) { FormComponentProps props = new FormComponentProps();
if (config.isReadOnly()) { props.setReadOnly(true); }
if (StringUtils.isNotBlank(config.getPlaceholder())) { props.setPlaceholder(config.getPlaceholder()); }
return props; }
/** * 组件属性创建完成之后,子类如果需要特殊处理,可覆写该方法 */ protected void afterPropsCreate(FormComponentProps props, FormItemConfig config) { }
/** * 共用逻辑:创建约束规则、设置通用的约束规则 */ private List<FormItemRule> createRules(FormItemConfig config) { List<FormItemRule> rules = new ArrayList<>(4);
if (config.isRequired()) { FormItemRule requiredRule = new FormItemRule(); requiredRule.setRequired(true); requiredRule.setMessage("请输入" + config.getTitle());
rules.add(requiredRule); }
return rules; }
/** * 约束规则创建完成之后,子类如果需要特殊处理,可覆写该方法 */ protected void afterRulesCreate(List<FormItemRule> rules, FormItemConfig config) { }}

模板的实现

针对不同的表单项,对特殊步骤进行自定义处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
scala复制代码/** * 下拉选择框的转换器 */@Componentpublic class DropdownSelectConverter extends FormItemConverter {
@Override public FormItemTypeEnum getType() { return FormItemTypeEnum.DROPDOWN_SELECT; }
@Override protected void afterPropsCreate(FormComponentProps props, FormItemConfig config) { props.setAutoWidth(false);
if (config.isMultiple()) { props.setMode("multiple"); } }}
/** * 模糊搜索框的转换器 */@Componentpublic class FuzzySearchConverter extends FormItemConverter {
@Override public FormItemTypeEnum getType() { return FormItemTypeEnum.FUZZY_SEARCH; }
@Override protected void afterItemCreate(FormItem item, FormItemConfig config) { item.setFuzzySearch(true); }
@Override protected void afterPropsCreate(FormComponentProps props, FormItemConfig config) { props.setAutoWidth(false); }}
/** * 通用文本类转换器 */public abstract class CommonTextConverter extends FormItemConverter {
@Override protected void afterRulesCreate(List<FormItemRule> rules, FormItemConfig config) { Integer minLength = config.getMinLength();
if (minLength != null && minLength > 0) { FormItemRule minRule = new FormItemRule(); minRule.setMin(minLength); minRule.setMessage("请至少输入 " + minLength + " 个字");
rules.add(minRule); }
Integer maxLength = config.getMaxLength();
if (maxLength != null && maxLength > 0) { FormItemRule maxRule = new FormItemRule(); maxRule.setMax(maxLength); maxRule.setMessage("请最多输入 " + maxLength + " 个字");
rules.add(maxRule); } }}
/** * 单行文本框的转换器 */@Componentpublic class TextInputConverter extends CommonTextConverter {
@Override public FormItemTypeEnum getType() { return FormItemTypeEnum.TEXT_INPUT; }}
/** * 多行文本框的转换器 */@Componentpublic class TextAreaConvertor extends FormItemConverter {
@Override public FormItemTypeEnum getType() { return FormItemTypeEnum.TEXT_AREA; }}

制作简单工厂

1
2
3
4
typescript复制代码@Componentpublic class FormItemConverterFactory {
private static final EnumMap<FormItemTypeEnum, FormItemConverter> CONVERTER_MAP = new EnumMap<>(FormItemTypeEnum.class);
/** * 根据表单项类型获得对应的转换器 * * @param type 表单项类型 * @return 表单项转换器 */ public FormItemConverter getConverter(FormItemTypeEnum type) { return CONVERTER_MAP.get(type); }
@Autowired public void setConverters(List<FormItemConverter> converters) { for (final FormItemConverter converter : converters) { CONVERTER_MAP.put(converter.getType(), converter); } }}

投入使用

1
2
3
4
5
6
java复制代码@Componentpublic class FormItemManagerImpl implements FormItemManager {
@Autowired private FormItemConverterFactory converterFactory;
@Override public List<FormItem> convertFormItems(JSONArray inputConfigs) { return IntStream.range(0, inputConfigs.size()) .mapToObj(inputConfigs::getJSONObject) .map(this::convertFormItem) .collect(Collectors.toList()); }
private FormItem convertFormItem(JSONObject inputConfig) { FormItemConfig itemConfig = inputConfig.toJavaObject(FormItemConfig.class); FormItemConverter converter = converterFactory.getConverter(itemConfig.getType());
if (converter == null) { throw new IllegalArgumentException("不存在转换器:" + itemConfig.getType()); }
return converter.convert(itemConfig); }}

Factory 只负责获取 Converter,每个 Converter 只负责对应表单项的转换功能,Manager 只负责逻辑编排,从而达到功能上的 “低耦合高内聚”。

设想一次扩展

此时要加入一种新的表单项 —— 数字选择器(NUMBER_PICKER),它有着特殊的约束条件:最小值和最大值,输入到 FormItemConfig 时分别为 minNumer 和 maxNumber。

1
2
3
4
5
6
7
8
scala复制代码@Componentpublic class NumberPickerConverter extends FormItemConverter {
@Override public FormItemTypeEnum getType() { return FormItemTypeEnum.NUMBER_PICKER; }
@Override protected void afterRulesCreate(List<FormItemRule> rules, FormItemConfig config) { Integer minNumber = config.getMinNumber(); // 处理最小值 if (minNumber != null) { FormItemRule minNumRule = new FormItemRule();
minNumRule.setMinimum(minNumber); minNumRule.setMessage("输入数字不能小于 " + minNumber);
rules.add(minNumRule); }
Integer maxNumber = config.getMaxNumber(); // 处理最大值 if (maxNumber != null) { FormItemRule maxNumRule = new FormItemRule();
maxNumRule.setMaximum(maxNumber); maxNumRule.setMessage("输入数字不能大于 " + maxNumber);
rules.add(maxNumRule); } }}

此时,我们只需要添加对应的枚举和实现对应的 FormItemConverter,并不需要修改任何逻辑代码,因为 Spring 启动时会自动帮我们处理好 NUMBER_PICKER 和 NumberPickerConverter 的关联关系 —— 完美符合 “开闭原则”。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Redis基础(三)—— 基本命令与数据类型

发表于 2021-08-05

这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战

1、Redis基本命令

1
2
3
4
5
6
7
8
properties复制代码# 切换数据库
SELECT index
# 当前数据库有的数据量
DBSIZE
# 清空当前数据库内容
FLUSHDB
# 清空所有数据库内容
FLUSHALL

1.1 key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
properties复制代码# 删除指定key(一个或多个)
DEL key [key ...]
# 序列化指定key的值
DUMP key
# 查询key是否存在
EXISTS key

# 设置一个key的过期时间(s)
EXPIRE key seconds
# 获取key的有效时间(s)(-1,永久有效;-2,无效,没有该key)
TTL key
# 移除key的过期时间
PERSIST key

# 查找所有匹配给定的模式的键(通配符: *: 代表所有; ?: 代表一个字符)
KEYS pattern
# 返回一个随机的key
RANDOMKEY
# 将一个key重命名
RENAME key newkey
# 重命名一个key,新的key必须是一个不存在的key
RENAMENX key newkey
# 移动一个key到另一个数据库
MOVE key db

EXPIRE key命令,应用场景

  • 限时的优惠活动信息
  • 手机验证码
  • 限制网站访客访问频率

1.2 key命名规范

​ redis中单个key存入512M大小。

​ NOSQL中数据与数据间是没有任何关联的,通过命名来解决。

  1. key不要太长,尽量不要超过1024字节,这不仅消耗内存,而且降低查询效率;
  2. key也不要太短,太短的话,可读性降低;
  3. 在一个项目中,key最好使用统一的命名模式,例如:user:id :name、user&id&name;
  4. key的名称区分大小写,命令不区分大小写。

2、Redis 数据类型

2.1 String

2.1.1 简介:

string 是 redis最基本的数据类型,一个key对应一个value,一个键最大能存储512MB;

string 类型是二进制安全的,意思是 redis 的string 可以包含任何数据,比如:jpg图片或序列化的对象。

二进制安全是指,在传输数据时,保证二进制数据的信息安全,也就是不被篡改、破译等,如果被攻击,能够及时检测出。

二进制安全特点:

  • 编码、解码发生在客户端完成,执行效率高;
  • 不需要频繁的编码解码,不会出现乱码

2.1.2 string命令

1
2
3
4
5
6
7
8
9
properties复制代码# 赋值语法
# 设置给定 key的值,如果key已经存在值,覆盖旧值,且无视类型
SET key value
# key不存在时,为key赋值;key存在,命令失效
SETNX key value // 重要,分布式锁问题
# 为多个key-value赋值
MSET key value [key value...]
# 追加一个值到key上,返回字符串长度
APPEND key value
1
2
3
4
5
6
7
8
9
10
11
properties复制代码# 取值语法
# 返回 key 的value
GET key
# 获取存储在key上的值的一个字符串,start - end表示取值范围(0-x)
GETRANGE key start end
# 设置一个key的值,并获取设置前的值(应用场景广泛)
GETSET key value
# 获取指定key值的长度
STRLEN key
# 返回位的值存储在关键的字符串值的偏移量
GETBIT key offset
1
2
3
properties复制代码# 删除语法
# 删除指定key,如果存在,返回数字类型(删除的个数)
DEL key
1
2
3
4
5
6
7
8
9
properties复制代码# 自增、自减,并返回结果数
# 将key存储的值自增加一,如果key不存在,那么key的值会先初始为0,再执行incr操作
INCR key
# 将key存储的值自增自定义的数,如果key不存在,那么key的值会先初始为0,在执行incrby操作
INCRBY key increment
# 自减一
DECR key
# 自减自定义的值
DECRBY key increment

2.1.3 应用场景

  • String 通常用于保存单个字符串或JSON字符串数据;
  • 因String是二进制安全的,所以完全可以把一个图片文件的内容作为字符串来存储;
  • 计数器,(常规key-value 缓存应用,常规技术:阅读数、评论数)

**INCR 等指令本身就是具有原子操作的特性,**所以我们完全可以利用 INCR、INCRBY、DECR、DECRBY等指令来实现原子计数的效果。

不少网站都利用redis的这个特性来实现业务上的统计计数需求。

2.2 hash类型

2.2.1 简介

  • hash 是一个string类型的 field 和 value 的映射表,hash特别适合用与存储对象。
  • redis 中可以存储 2^32 - 1 键值对(40多亿),可以看做具有KEY和VALUE的map容器,该类型非常适合于存储值对象的信息,如:uname,ugender,uage。该类型的数据仅占用很少的磁盘空间(相比于JSON)。

2.2.2 hash命令

1
2
3
4
5
properties复制代码# 赋值语法
# 为指定的key,设定key/value.相当于 key:对象名 field:属性名 value:属性值
HSET key field value
# 同时将多个 field-value(域-值)对设置到哈希表key中
HMSET key field value [field value]...
1
2
3
4
5
6
7
8
9
10
11
properties复制代码# 取值语法
# 取出 key 的field的值
HGET key field
# 取出 key 中多个field的值
HMGET key field [field...]
# 获取 key 的所有 field 和 value
HGETALL key
# 获取key中所有field
HKEYS key
# 获取 key 中field的数量
HLEN key
1
2
3
properties复制代码# 删除语法
# 删除key中一个或多个field,当key的所有field的值都删除完了,redis会删除这个key
HDEL key field [field...]
1
2
3
4
5
6
7
8
9
properties复制代码# 其他语法
# 只有key不存在时,设置字段的值
HSETNX key field value
# 为哈希表key中的指定字段的整数值加上增量increment
HINCRBY key field increment
# 为哈希表key中的指定字段的浮点数值加上增量increment
HINCRBYFLOAT key field increment
# 查看哈希表key中,指定的field是否存在
HEXISTS key field

2.2.3 应用场景

  • 常用于存储一个对象;
  • 为什么不用string存储一个对象?
+ hash是最接近关系型数据库结构的数据类型,可以将数据库一条记录或程序中一个对象转换成hashmap存放到redis中;
+ 用string存储对象的两种方式:


    - 第一种,将用户id作为查找的key,把其他信息封装成一个对象以序列化的方式存储(json)。缺点:增加了序列化/反序列化的开销,并且在需要修改其中一项信息中,需要把整个对象取出,并且修改操作需要对并发进行保护,引入CAS等复杂问题;
1
properties复制代码例:key:id,value:{json串}
- 第二种,这个用户信息对象有多少成员就存成多少个key-vale对,用户ID+对应属性的名称作为唯一表示来取得对应属性的值。(例:set user:id 1;set user:name xiaojian;set user:gender "男" )虽然省去了序列化开销和并发问题,但是用户ID为重复存储,如果存在大量这样的数据,内存浪费巨大。
1
2
3
4
properties复制代码例:
key:user:id 1
user:name xiaojian
user:age 22

2.3 List类型

2.3.1 简介

简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或尾部(右边)

List 存入的元素的结构就像栈堆,先进后出

类似于 Java 的 LinkedList

1
2
3
4
5
6
7
8
9
10
11
12
13
properties复制代码127.0.0.1:6379> lpush list2 1
(integer) 1
127.0.0.1:6379> lpush list2 2
(integer) 2
127.0.0.1:6379> lpush list2 3
(integer) 3
127.0.0.1:6379> lpush list2 3
(integer) 4
127.0.0.1:6379> lrange list2 0 -1
1) "3"
2) "3"
3) "2"
4) "1"

2.3.2 List命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
properties复制代码# 赋值语法
# 从列表左边存入一个或多个元素
LPUSH key value[value...]
# 当列表存在时,从列表左边存入一个(列表不存在,无法存入)
LPUSHX key value
# 从列表右边存入一个或多个元素
RPUSH key value[value...]
# 当列表存在时,从列表右边存入一个(列表不存在,无法存入)
RPUSHX key value

# 在列表中的另一个元素之前或之后插入一个元素
LINSERT key BEFORE|AFTER pivot value
如:linsert list1 BEFORE "c" "b" # 在元素 "c" 之前插入 "b"

# 根据索引,设置列表里面一个元素的值
LSET key index value
1
2
3
4
5
6
7
8
9
properties复制代码# 取值语法
# 通过其索引获取一个元素
LINDEX key index
# 从列表获取指定范围内的元素,start和stop偏移量为 -1 指最后一个元素,-2指倒数第二个,一次类推
LRANGE key start stop
# 获取列表长度
LLEN key
# 截取指定范围的数据,列表中的数据改变成截取的数据
LTRIM key start stop
1
2
3
4
5
6
7
8
properties复制代码# 删除语法
# 从列表最左边移除一个元素,并返回这个元素
LPOP key
# 从列表最右边移除一个元素,并返回这个元素
RPOP key
# 从存于 key 的列表里移除前 count 次出现的值为 value 的元素。
# count为正数时,移除前count个;为负数时,移除后count个;为0,移除所有value
LREM key count value

2.3.3 应用场景

  • 对数据量显示、关注列表、粉丝列表、留言评价等…分页、热点新闻(Top5)等;

利用 LRANGE 还可以很方便的实现分页的功能;在博客系统中,每篇博文的评论也可以存入一个单独的list中。

  • 任务队列

list通常用来实现一个消息队列,而且可以确保先后顺序,不必像MySQL那样还需要通过 ORDER BY来进行排序

1
2
3
4
5
6
7
properties复制代码#任务队列介绍(生产者和消费者模式):
# 在处理web客户端发送的命令请求时,某些操作的执行时间可能会比我们预期的更长一些,通过将待执行任务的相关信息放入队
列里面,并在之后对队列进行处理,用户可以推迟执行那些需要一段时间才能完成的操作,这种将工作交给任务处理器来执行的做法
被称为任务队列(task queue)。

RPOPLPUSH source destination
# 一处列表的最后一个元素,并将该元素添加到另一个列表并返回(对同一个list使用,把最后一个元素,调到首个)

2.4 Set 类型

2.4.1 简介

不允许存在重复元素的集合,无序

Redis集合时通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1),

set是通过 hashtable 实现的,

集合中的最大成员数为 2^32^ - 1(4294967295,每个集合可存储40多亿个成员)

类似 Java 中的成员 Hashtable 集合

2.4.2 Set 命令

1
2
3
properties复制代码# 赋值语法
# 添加一个或多个元素
SADD key member [member...]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
properties复制代码# 取值语法
# 获取集合中所有元素
SMEMBERS key
# 获取集合长度
SCARD key
# 判断 member 元素是否是集合 key 的成员(开发中:验证是否存在判断)
SISMEMBER key member
# 返回集合中一个或多个随机数
SRANDMEMBER key [count]

# 差集语法:
SDIFF key1 [key2] : 返回给定 key1 集合与其他集合的差集
SDIFFSTORE destination key1 [key2] : 返回给定所有集合的差集并存储在 destination 中(destination是一个新建的key的名称)
# 交集语法:
SINTER key1 [key2] : 返回给定所有集合的交集(共有数据)
SINTERSTORE destination key1 [key2] : 返回给定所有集合的交集并存储在 destination 中(destination是一个新建的key的名称)
# 并集语法:
SUNION key1 [key2] : 返回所有给定集合的并集
SUNIONSTORE destination key1 [key2] : 返回给定所有集合的并集并存储在 destination
1
2
3
4
5
6
7
properties复制代码# 删除语法
# 删除一个或多个元素
SREM key member1 [member2]
# 移除并返回集合的一个随机元素
SPOP key [count]
# 将 member 元素从 source 集合移动到 destination 集合
SMOVE source destination member

2.4.3 应用场景

常应用于:对两个集合间的数据 [计算] 进行交集、并集、差集运算

  • 利用集合操作,可以取不同兴趣圈子的交集,以非常方便的实现如共同关注、共同喜好、二度好友等功能。对上面的所有集合操作,你还可以使用不同的命令选择将结果返回给客户端还是存储到一个新的集合中。
  • 利用唯一性,可以统计访问网站的所有独立 IP、存取当天(或某天)的活跃用户列表。

2.5 ZSet 类型

2.5.1 简介

有序集合(sorted set)

不允许重复元素,且元素有序

插入元素时都会关联一个double类型的分数(score),以分数从小到大排序

有序集合的成员是唯一的,但分数(score)却可以重复

(我们将在redis中的有序集合叫做zsets,这是因为在 redis 中,有序集合相关的操作指令都是以z开头的)

2.5.2 ZSet 命令

1
2
properties复制代码# 赋值语法
ZADD key score member [score member...]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
properties复制代码# 取值语法
# 获取有序集合的成员数
ZACARD key
# 计算在有序集合中指定区间分数的成员数
ZCOUNT key min max
# 返回有序集合中指定成员的索引
ZRANK key member

# 通过索引区间返回有序集合中指定区间的成员(0,-1)
ZRANGE key start end [WITHSCORES]
# 通过分数返回有序集合指定区间内的成员
ZRANGBYSCORE key min max [WITHSCORES] [LIMIT]

# 返回有序集合指定区间内的成员,通过索引,分数从高到低
ZREVRANGE key start stop [WITHSCORES]
# 返回有序集合指定分数区间内的成员,通过索引,分数从高到低
ZREVRANGEBySCORE key max min [WITHSCORES]
1
2
3
4
5
properties复制代码# 删除语法
# 移除集合
DEL key
# 移除有序集合中的一个或多个成员
ZREM key member [member ...]

2.5.3 应用场景

常应用于:排行榜

销量排名,积分排名等

2.6 HyperLogLog

简介

1
2
复制代码Redis 在2.8.9 版本添加了 HyperLogLog 结构
Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或体积非常大时,计算基数所需的空间总是固定的,并且是很小的
1
2
复制代码在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。
1
2
3
markdown复制代码什么是基数?
比如数据集{1,3,5,7,5,7,8},那么这个数据集的基数集为{1,3,5,7,8},基数(不重复元素)为5。
基数估计就是在误差可接受的范围内,快速计算基数.

2.6.1 常用命令

1
2
3
4
5
6
properties复制代码# 添加指定元素到 HyperLogLog 中
PFADD key element [element ...]
# 返回给定 HyperLogLog 的基数估算值
PFCOUNT key [key ...]
# 将多个 HyperLogLog 合并为一个 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]

2.6.2 应用场景

基数不大,数据量不大就用不上,会有点大材小用浪费空间

有局限性,就是只能统计基数数量,而没有办法知道具体内容是什么

1
2
3
4
5
6
tex复制代码统计注册 IP 数
统计每日访问 IP 数
统计页面实时 UV 数
统计在线用户数
统计用户每天搜索不同词条的个数
统计真是文章阅读数

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

秒杀系统架构图该怎么画?手把手教你!

发表于 2021-08-05

泪目,不堪回首!

博主毕业4年了,最近秋招开始了,每次回想起自己的秋招,都感觉到当时自己特别的可惜(菜是原罪),自己当时简历上面的项目,只有一个 农资电商平台,当时的秒杀系统还没有那么普及(简历人均秒杀系统)。

第一次微众面试

当年自己的八股文背的其实还可以,但是自己的项目就只是一个单机系统,分布式? 微服务? 什么玩意?,还记得当时微众面试,是二面,在一个酒店房间,面试官笑嘻嘻的看着我,说让我先画一下我项目里面的农资电商平台, 我脑子嗡嗡叫,啥? 咋画, 就一个安卓系统,一个前端页面,和一个后台系统?

大概长这样子

image.png

我擦,这也太简单了吧, 我是不是该画复杂一点? 或者说,我这个能叫架构吗?就这样,犹豫之间,毛线都没有画出来… 我记得当时好像画了个这样子的玩意。。 毫无意外的,嗝屁了~

1
复制代码这玩意有点四不像,不说了,丢脸~

image.png

第二次微众面试

第二次微众面试,毕业有快一年了,抱着试一下的心态,找了个师姐内推, 那时候我在干啥呢,在搞爬虫。公司离微众比较近,就在金蝶那边,下班了溜过去,跟面试官吧啦了一会八股文,好家伙,没一会就掏出了一张纸:

来画一下你们现在这个爬虫系统的架构图!

当时系统的部署架构长这样吧, 比上面的看起来还简单一点。

image.png

但是,我就是画不出手啊!!! 心里想着太简单了啊!! 这玩意能叫架构吗?

摊牌了, 我不会画!

现在想起来,真的太憋屈了,年轻啊! 那如果现在来回头看的话,能怎么画呢?

单体系统的部署架构图

image.png

爬虫系统的分层架构图

image.png

爬虫系统的业务架构

image.png

架构图

从上面的各个方向描述架构来看,其实即使是单体系统 也能够画出不一般的架构图!(为啥当时我就不会呢!)

最近在看架构相关的内容(华仔的课),在4+1 视图里面,从多方面描述了我们的系统,可以参考下面的描述,

image.png

你的秒杀系统,架构是怎么样的?

单体系统

不管你们简历吹的多牛逼,我猜你们的服务,大部分都是长这个样子的,猜对的话点个关注, 只有浏览器是分布式的。

image.png

那我该如何去描述我的单体系统呢?

架构设计的三大原则:

  • 简单原则
  • 合适原则
  • 演进原则

每一条原则都符合我们大学做的秒杀系统啊!!

简单原则: 一个系统就可以满足我们秒杀服务的所有动作,没有太多的中间件依赖

合适原则:在我们的实践项目中,单体系统是最适合不过的了。(主要是没钱啊!拆分服务,引入中间件,部署集群,都得钱啊!)

演进原则:这个比较好理解,没有什么系统架构是一出生就定下来的,是随着时间,业务需求,不断演变出来的。

总结:

我们架构的优势: 成本低,系统复杂度低,维护成本低,快速定位问题

劣势: 稳定性差,并发量低,扩展性弱等

在梳理架构时,每个方案都有他的优势和缺点,所以需要了解你目前方案的优缺点。才能更好的向面试官展示你的系统!

服务拆分

好家伙,参加了个科创比赛,资金到位了,能买更多机器了,那不得将服务优化一下,拆分个微服务系统出来!

image.png

在这个服务拆分的架构中,我们做了哪些动作?

  • 静态资源隔离(CDN加速)
  • 代理服务器(Nginx)
  • 服务拆分,应用独立部署
  • 服务rpc通信 (rpc框架 & 注册中心)

1、前后端分离

在单体系统中,我们的静态资源(Html,JS,CSS 和 IMG)可能都是通过我们服务端进行返回,存在的问题是:

  • 前端代码维护成本比较高(全栈开发成本也高)
  • 前端代码发布,需要整个系统进行发布
  • 服务器带宽,请求资源占用等

那么通过前后端分离所带来的好处就很明显了:

  • 代码独立维护(低耦合),发布成本低(高效率)
  • 前后端通过接口交互动态数据
  • CDN资源访问加速,减少后端服务压力(高性能)

2、反向代理

反向代理的作用比较明显, 由于我们服务拆分成多个,那么我们和前端进行交互时,需要提供一个通用的入口。而这个入口,就是我们的反向代理服务器(Nginx)。 例如: 服务域名:https://www.jiuling.com ,根据restful规范,我们可以通过 https://www.jiuling.com/user/1.0/login 将请求转发到 用户服务的登录接口中。

3.进程间通信

随着服务的拆分,在部分功能的实现上,就会涉及到服务间相互调用的情况,例如:

image.png

在常见的实现方案上,我们会采用 注册中心 和 RPC框架,来实现这一能力。而我们比较常用的实现方案就是 zookeeper & dubbo。

image.png

为什么要使用 RPC 框架?

当我们提到使用 RPC框架 的时候,是否有去思考过,为什么要使用 RPC框架? 每个服务提供 RESTful 接口,不是也能够完成服务间通信吗?

这里就需要进行对比 RPC 和 RESTful 的区别了:

  • 数据报文小&传输效率快: RPC简化了传输协议中一些必要的头部信息,从而加快了传输效率。
  • 开发成本低:例如 Dubbo框架,封装好了服务间调用的逻辑(如:反射,建连和超时控制等),只需要开发相应的接口和数据模型即可。
  • 服务治理: 在分布式场景下,我们的服务提供者不止一台,那么就涉及到 服务健康,负载均衡和服务流控等情况需要处理,而这部分能力在rpc & 注册中心 的架构下,都已经满足了。

说完优点后,再来分析一下,RPC的缺点:

  • 耦合性强: 相较于 RESTful而言,RPC 框架在跨语言的场景下实现比较困难。并且版本依赖比较强。服务脱离了当前内网环境后,无法正常提供服务,迁移成本高。
  • 内网调用: RPC更适合内网传输,在公网环境下,显得没那么安全。

分布式微服务

在上一个版本的服务拆分中, 我们根据不同的业务边界,功能职责,划分出了多个子系统,而针对不同的系统,他所承受的负载压力是不一样的,例如: 订单服务的每个请求处理耗时较长(其他服务压力不大),为了挺升我们的下单量,我们可以只扩容订单服务即可,这就是我们在服务拆分所带来的收益,性能使用率提升!

image.png

从上面的图我们可以看到,有些服务出现了不同的重影,每一个方块,可以理解为一台机器,在这个架构中, 为了保证我们的下单成功率,以及下单量,我们主要将服务器集中在了订单服务。

除此之前,再来看看我们的中间件集群部署:

  • mysql 主从架构: 读写分离,减轻主库压力,确保数据能正常写入,保障订单数据落库.
  • zookeeper 主从架构: 保障注册中心可用,避免导致全链路雪崩。
  • redis 哨兵集群: 避免redis宕机导致大流量直接打到数据库中。

小结

到这里为止,一般我们自己开发的系统,也就基本完成了整个秒杀系统的演进了。可能大伙一直有个疑问,为什么少了我们最熟悉的MQ呢?

在整个调用链路中,我都是以同步调用的方式去讲述这一个秒杀系统的架构,因为这个已经满足我们当前的流量诉求了,在架构设计的原则里面,提到的,合适原则,和演进原则。在当前满足流量需求的情况下,我们需要先思考引入消息中间件,带来的问题是什么? 解决的问题又是什么? 在权衡利弊后,才是我们决策是否要使用这个方案的时候。

高性能

在上述架构演进的过程中,我们通过服务拆分,垂直扩容,分布式部署等方式,提升了我们架构的性能和稳定性,对于我们自研阶段的架构演进已经是足够满足我们的流量诉求了,但如果我们想继续优化我们的系统,提升服务性能,可以从以下几个方面进行优化:

  • 资源预热
  • 缓存预热
  • 异步调用

1、资源预热

在上面的服务拆分阶段, 我们就提到了资源动静分离, 这里的静态资源包括:html,js,css,img 等。我们活动阶段,可以通过后台管理系统,将商品服务中的活动的静态资源预热到CDN,加速资源的访问。

1
2
3
复制代码资源预热: 通过预先将资源加载到CDN
回源: CDN找不到资源后,会触发源站(商品服务)调用,进行查询对应资源,如果源站存在该资源,则会返回到CDN中进行缓存。
OSS: 实际存储静态资源的服务(可参考阿里云OSS)

image.png

上面有反复提到,引入一个技术的时候,需要同时考虑它所带来的利和弊,那么 CDN的风险是什么呢?

  • 成本 : 比较直接,就是得多花钱!
  • 带宽 : 在大流量的访问下, CDN 是否能支撑那么多的带宽,每个服务器能支撑的流量是有限的,需要考虑CDN是否能支撑业务的访问量。
  • CDN命中率: 在CDN命中率低的情况下,比如活动图片,每一个小时都会发生改变,那么每次图片的替换,都会触发回源操作,这时候的资源访问效率反而有所下降。

2、缓存预热

与上面的静态资源加速相对比,动态数据则需要通过缓存进行性能上的优化,老生常谈,为什么redis 那么快?

  • 单线程(redis的性能瓶颈并不在这,所以这个不算优势)
  • 多路I/O复用模型
  • 数据结构简单
  • 基于内存操作

image.png

引入 redis 带来的风险主要有:

  • reids 宕机: 单机部署的情况下,会导致大量的服务调用超时,最终引起服务雪崩。可通过Sentinel集群优化。
  • 缓存击穿:大流量下,缓存MISS和缓存过期等情况,会导致请求穿透到数据库,如果数据库扛不住压力,会造成服务雪崩。可以通过 布隆过滤器进行优化。
  • 数据一致性: 缓存数据与DB 的数据一致性问题,需要通过更新策略进行保障。

3、异步调用

通过异步的方式,将减库存成功的用户,通过消息的方式,发送给订单服务,进行后续的下单操作。可以在短时间内,将所有的商品销售出去。整体的流程如下图所示:

MQ异步调用为什么能过提升我们服务的吞吐量呢?

主要原因在于,通过异步调用的方式,我们将消息投递过去了,就完成了这一次的请求处理,那么性能的瓶颈,由订单服务,转移到了秒杀服务这里。通过减少调用依赖,从而提升了整体服务的吞吐量。

image.png

MQ 带来的常见问题:

  • 数据一致性
  • 重复消费:由于生产者重复投递消息,或者消费缓慢导致重复推送消息。需要通过加锁,消费幂等来保证消费正常。
  • 消息堆积: 生产能力远大于消费能力情况下,会导致消息堆积。
  • MQ可用性:MQ宕机的情况下,需要支持同步调用切换。

这里不做详细介绍,后面会专门写一篇MQ相关的文章。

高可用

能看到这里真不容易,感谢大家的支持。关于可用性这里,之前有写过一篇 # 《高可用实战》-B站蹦了,关我A站什么事? 感兴趣可以看一下。

高可用主要可以从:

  • 动态扩容: 根据服务压力,针对不同服务进行动态扩容。
  • 限流熔断: 可参考我之前的文章: # 《高可用实战》-B站蹦了,关我A站什么事?
  • 异地多活: 通过多机房部署,避免物理攻击!

同城双活

部署在同一个城市不同区的机房,用专用网络连接。两个机房距离一般就是几十千米,网络传输速度几乎和同一个机房相同,降低了系统复杂度、成本。

image.png

这个模式无法解决极端的灾难情况,例如某个城市的地震、水灾,此方式是用来解决一些常规故障的,例如机房的火灾、停电、空调故障。

异地多活

在上述模式中,没办法解决城市级别的服务容灾,比如水灾,地震等情。而通过异地多活的部署方案,则可以解决这种问题。

但是每个方案都是存在利和弊的,那么异地多活的弊端主要体现在网络传输和数据一致性的问题上!

1
2
3
4
5
css复制代码跨城异地主要问题就是网络传输延迟,例如北京到广州,正常情况下的RTT(Round-Trip Time 往返时延)是50毫秒,
当遇到网络波动等情况,会升到500毫秒甚至1秒,而且会有丢包问题。

物理距离必然导致数据不一致,这就得从“数据”特性来解决,
如果是强一致性要求的数据(如存款余额),就无法做异地多活。

点关注,不迷路

图片地址:draw.io原图

好了各位,以上就是这篇文章的全部内容了,我后面会每周都更新几篇高质量的大厂面试和常用技术栈相关的文章。感谢大伙能看到这里,如果这个文章写得还不错, 求三连!!! 感谢各位的支持和认可,我们下篇文章见!

我是 九灵 ,有需要交流的童鞋可以关注公众号:Java 补习课,掌握第一手资料! 如果本篇博客有任何错误,请批评指教,不胜感激 !

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

惊!Go里面居然有这样精妙的小函数!

发表于 2021-08-05

来自公众号:[Gopher指北](https://isites.gitlab.io/gopher/)

各位哥麻烦腾个道,前面是大型装逼现场。

首先老许要感谢他人的认同,这是我乐此不彼的动力,同时我也需要反思。这位小姐姐还是比较委婉, 但用我们四川话来说,前一篇文章的标题是真的cuo。

老许反复思考后决定哗众取宠一波,感叹号双连取名曰“惊!Go里面居然有这样精妙的小函数!”。下面就让我们来看看和标题没那么符合的一些小函数。

返回a/b向上舍入最接近的整数

1
2
3
go复制代码func divRoundUp(n, a uintptr) uintptr {
return (n + a - 1) / a
}

这个方法用过的人应该不少,最典型的就是分页计算。

判断x是否为2的n次幂

1
2
3
go复制代码func isPowerOfTwo(x uintptr) bool {
return x&(x-1) == 0
}

这个也挺容易理解的,唯一需要注意的是x需要大于0,因为该等式0也是成立的。

向上/下将x舍入为a的倍数,且a必须是2的n次幂

1
2
3
4
5
6
7
8
9
go复制代码// 向上将x舍入为a的倍数,例如:x=6,a=4则返回值为8
func alignUp(x, a uintptr) uintptr {
return (x + a - 1) &^ (a - 1)
}

// 向上将x舍入为a的倍数,例如:x=6,a=4则返回值为4
func alignDown(x, a uintptr) uintptr {
return x &^ (a - 1)
}

在这里老许再次明确一个概念,2的n次幂即为1左移n位。然后上述代码中^为单目运算法按位取反,则^ (a - 1)的运算结果是除了最低n位为0其余位全为1。剩余的部分则是一个简单的加减运算以及按位与。

上述代码分开来看每一部分都认识,合在一起就一脸懵逼了。幸运的是,经过老许的不懈努力终于找到了一种能够理解的方式。

以x=10,a=4为例。a为2的2次幂即1左移2位。x可看作两部分之和,第一部分x1为0b1000,第二部分x2为0b0011。x的拆分方式是1左移n位可得到a来决定的,即x的最低n位为x2,x1则为x-x2。因此x1相当于0b10左移2位得到,即x1已经是a的整数倍,此时x2只要大于0则x2+a-1一定会向前进1,x1+1或x1不就是x向上舍入的a的整数倍嘛,最后和^ (a - 1)进行与运算将最低2位清零得到最终的返回结果。

有一说一,我肯定是写不出这样的逻辑,这也令我不得不感叹大佬们对计算机的理解简直出神入化。这样的函数牛逼归牛逼,但是在实际开发中还是尽量少用。一是有使用场景的限制(a必须为2的n次幂),二是不易理解,当然炫技和装逼除外(性能要求极高也除外)。

布尔转整形

1
2
3
4
go复制代码// bool2int returns 0 if x is false or 1 if x is true.
func bool2int(x bool) int {
return int(uint8(*(*uint8)(unsafe.Pointer(&x))))
}

如果让我来写这个函数,一个稀松平常的switch就完事儿,而现在我又多了一种装逼的套路。老许在这里特别友情提示,字节切片和字符串也可使用上述方式进行相互转换。

计算不同类型最低位0的位数

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
go复制代码var ntz8tab = [256]uint8{
0x08, ..., 0x00,
}
// Ctz8 returns the number of trailing zero bits in x; the result is 8 for x == 0.
func Ctz8(x uint8) int {
return int(ntz8tab[x])
}

const deBruijn32ctz = 0x04653adf

var deBruijnIdx32ctz = [32]byte{
0, 1, 2, 6, 3, 11, 7, 16,
4, 14, 12, 21, 8, 23, 17, 26,
31, 5, 10, 15, 13, 20, 22, 25,
30, 9, 19, 24, 29, 18, 28, 27,
}

// Ctz32 counts trailing (low-order) zeroes,
// and if all are zero, then 32.
func Ctz32(x uint32) int {
x &= -x // isolate low-order bit
y := x * deBruijn32ctz >> 27 // extract part of deBruijn sequence
i := int(deBruijnIdx32ctz[y]) // convert to bit index
z := int((x - 1) >> 26 & 32) // adjustment if zero
return i + z
}

const deBruijn64ctz = 0x0218a392cd3d5dbf

var deBruijnIdx64ctz = [64]byte{
0, 1, 2, 7, 3, 13, 8, 19,
4, 25, 14, 28, 9, 34, 20, 40,
5, 17, 26, 38, 15, 46, 29, 48,
10, 31, 35, 54, 21, 50, 41, 57,
63, 6, 12, 18, 24, 27, 33, 39,
16, 37, 45, 47, 30, 53, 49, 56,
62, 11, 23, 32, 36, 44, 52, 55,
61, 22, 43, 51, 60, 42, 59, 58,
}

// Ctz64 counts trailing (low-order) zeroes,
// and if all are zero, then 64.
func Ctz64(x uint64) int {
x &= -x // isolate low-order bit
y := x * deBruijn64ctz >> 58 // extract part of deBruijn sequence
i := int(deBruijnIdx64ctz[y]) // convert to bit index
z := int((x - 1) >> 57 & 64) // adjustment if zero
return i + z
}

Ctz8、Ctz32和Ctz64分别计算无符号8、32、64位数最低位为0的个数,即某个数左移的位数。

函数的作用通过翻译倒是能理解,我也能深刻的明白这是典型的空间换时间,然而要问一句为什么我是万万答不上来的。不过老许已经替你们找好了答案,答案就藏在这篇Using de Bruijn Sequences to Index a 1 in a Computer Word论文中。欢迎巨佬们去挑战一下,而我只想坐享其成,那么在巨佬们分析完这篇论文之前就让这些函数安家在我的收藏栏里方便以后炫技。

这里特别说明,术业有专攻,我们不一定要所有东西都会,但要尽可能知道有这么一个东西存在。这即是老许为自己找的一个不去研究此论文的接口,也是写下此篇文章的意义之一(万一有人提到了Bruijn Sequences关键词,我们也不至于显得过分无知)。

math/bits包中的部分函数

如果有人知道这个包,那请原谅我的无知直接跳过本部分即可。老许发现这个包是源于ntz8tab变量所在文件runtime/internal/sys/intrinsics_common.go中的一句注释。

1
css复制代码// Copied from math/bits to avoid dependence.

作为一个资深的CV工程师, 看到这句的第一反应就是我终于可以挺直腰杆了。适当Copy代码不丢人!

math/bits这个包函数较多,老许挑几个介绍即可,其余的还请各位读者自行挖掘。

LeadingZeros(x uint) int: 返回x所有高位为0的个数。

TrailingZeros(x uint) int: 返回x最低位为0的个数。

OnesCount(x uint) int:返回x中bit位为1的个数。

Reverse(x uint) uint: 将x按bit位倒序后再返回。

Len(x uint) int: 返回表示x的有效bit位个数(高位中的0不计数)。

ReverseBytes(x uint) uint: 将x按照每8位一组倒序后返回。

将x逃逸至堆

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码// Dummy annotation marking that the value x escapes,
// for use in cases where the reflect code is so clever that
// the compiler cannot follow.
func escapes(x interface{}) {
if dummy.b {
dummy.x = x
}
}

var dummy struct {
b bool
x interface{}
}

老许是在reflect.ValueOf函数中发现此函数的调用,当时就觉着挺有意思。如今再次回顾也依旧佩服不已。读书是和作者的对话,阅读源码是和开发者的对话,看到此函数就仿佛看到Go语言开发者们和编译器斗智斗勇的场景。

让出当前Processor

1
2
3
4
5
6
7
go复制代码
// Gosched yields the processor, allowing other goroutines to run. It does not
// suspend the current goroutine, so execution resumes automatically.
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}

让出当前的Processor,允许其他goroutine执行。在实际的开发当中老许还未遇到需要使用此函数的场景,但多了解总是有备无患。

最后,衷心希望本文能够对各位读者有一定的帮助。

注:

  1. 写本文时, 笔者所用go版本为: go1.16.6

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

图文实例解析,InnoDB 存储引擎中行锁的三种算法

发表于 2021-08-05

前文提到,对于 InnoDB 来说,随时都可以加锁(关于加锁的 SQL 语句这里就不说了,忘记的小伙伴可以翻一下上篇文章),但是并非随时都可以解锁。具体来说,InnoDB 采用的是两阶段锁定协议(two-phase locking protocol):即在事务执行过程中,随时都可以执行加锁操作,但是只有在事务执行 COMMIT 或者 ROLLBACK 的时候才会释放锁,并且所有的锁是在同一时刻被释放。

并且,行级锁只在存储引擎层实现,而对于 InnoDB 存储引擎来说,行级锁又分三种,或者说有三种行级锁算法:

  • Record Lock:记录锁
  • Gap Lock:间隙锁
  • Next-Key Lock:临键锁

下面,我们来详细解释下这三种行锁算法。

Record Lock 记录锁

顾名思义,记录锁就是为某行记录加锁,事实上,它封锁的是该行的索引记录。如果表在建立的时候没有设置任何一个索引,那么这时 InnoDB 存储引擎会使用 “隐式的主键” 来进行锁定。

所谓隐式的主键就是指:如果在建表的时候没有指定主键,InnoDB 存储引擎会将第一列非空的列作为主键;如果没有的话会自动生成一列为 6 字节的主键。

那么,既然 Record Lock 是基于索引的,那如果我们的 SQL 语句中的条件导致索引失效(比如使用 or) 或者说条件根本就不涉及索引或者主键,行级锁就将退化为表锁。

Record Lock 示例

先来举个对索引字段进行查询的例子,有数据库如下,id 是主键索引:

1
2
3
4
5
sql复制代码CREATE TABLE `test` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8;

初始数据是这样的:

新建两个事务,先执行事务 T1 的前两行,也就是不要执行 commit:

image-20210801215210231

由于没有执行 commit,所以这个时候事务 T1 没有释放锁,并且锁住了 id = 1 的记录行,此时再来执行事务 2 申请 id = 2 的记录行:

image-20210801215329321

可以看见,由于锁住的是不同的记录行,所以两个记录锁并没有相互排斥,来看一下现在表中的数据,由于事务 1 还没有 commit,所以应该是只有 id = 2 的 username 被修改了:

image-20210801215624898

nice,果然。再执行下事务 1 的 commit,id = 1 的 username 也就被修改过来啦。

行锁退化为表锁示例

再来看下没有使用索引的例子:

同样的,新建两个事务,先执行事务 T1 的前两行,也就是不要执行 commit。我们试图使用 select ... for update 给 username = “user_three” 的记录行加上记录锁,但是由于 username 并非主键也并非索引,所以实际上这里事务 T1 锁住的是整张表:

image-20210801220807603

由于没有执行 commit,所以这个时候事务 T1 没有释放锁,并且锁住了整张表。此时再来执行事务 2 试图申请 id = 5 的记录锁,你会发现事务 T2 会卡住,最后超时关闭事务:

image-20210801221604790

两条不同记录拥有相同的索引,会发生锁冲突吗?

这个问题的答案应该很简单吧,上面我们强调过,行锁锁住的是索引,而不是一条记录(只不过我们平常这么说锁住了哪条记录,比较好理解罢了)。所以如果两个事务分别操作的两条不同记录拥有相同的索引,某个事务会因为行锁被另一个事务占用而发生等待。

Gap Lock 间隙锁

这里我先简单提一嘴,下文会详细解释:不同于 Record Lock 是基于唯一索引的,Gap Lock 和 Next-Key Lock 都是基于非唯一索引的。

并且,不同于 Record Lock 锁定的是某一个索引记录,Gap Lock 和 Next-Key Lock 锁定的都是一段范围内的索引记录:

1
sql复制代码select * from test where id between 1 and 10 for update;

对于上述 SQL 语句,所有在(1,10)区间内(左开右开)的记录行都会被 Gap Lock 锁住,所有 id 为 2、3、4、5、6、7、8、9 的数据行的插入会被阻塞,但是 1 和 10 两条被操作的索引记录并不会被锁住。

注意!这里指的是锁住所有的(1,10)区间内的 id,也就是说即使某个 id 目前并不在我们的表中比如 id = 6 ,如果你想插入一条 id = 6 的新纪录,那对不起,不行。

Next-Key Lock 临键锁

Next-Key Lock 是结合了 Gap Lock 和 Record Lock 的一种锁定算法,其主要目的是为了解决幻读问题。

例如一个索引有 10,11,13 和 20 这四个值,分别对这个 4 个索引进行加锁操作,那么这四个操作分别对应的 Next-Key Lock 锁住的区间是:

  • (-∞, 10]
  • (10, 11]
  • (11, 13]
  • (13, 20]
  • (20, +∞]

细心的同学应该已经注意到了,和 Gap Lock 的不同之处就在于,Next-Key Lock 锁定的区间是左开右闭的,也就是说它是包含当前被操作的索引记录的。

在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的算法就是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。

由于主键也是一种唯一索引,所以我们可以这么说:Record Lock 是基于唯一索引的,而 Next-Key Lock 是基于非唯一索引的。

需要注意的,当操作的索引为非唯一索引时,InnoDB 会先用 Record Lock 锁住对应的唯一索引,再用 Next-Key Lock 和 Gap Lock 对这个非唯一索引进行处理,而不仅仅是锁住这个非唯一索引。具体地我们举个例子来看下。

Next-Key Lock 示例

假设我们为上面 test 表中新增一个字段,并设置为非唯一索引:

1
2
3
4
5
6
7
sql复制代码CREATE TABLE `test` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`class` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `index_class` (`class`) USING BTREE COMMENT '非唯一索引'
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8;

插入一些数据:

image-20210802225225160

开启一个事务 1 执行如下的操作语句:

1
sql复制代码select * from test where class = 3 for update;

image-20210802225348249

在这种情况下,InnoDB 事实上会加上三种行锁(select * ... from update 加的是行级写锁即 X 锁):

1)给主键索引 id = 105 加上 Record Lock

2)对于非唯一索引 class = 3,其加上的是 Next-Key Lock,锁定的范围是 (1,3]

3)另外,特别需要注意的是,InnoDB 存储引擎还会对非唯一索引 class 的下一个键值加上 Gap Lock(表中 class = 3 的下个键值是 6),所以还有一个 class 索引范围为 (3,6) 的间隙锁

总结下 2)和 3),对于这条 SQL 语句,InnoDB 存储引擎锁定地 class 索引范围是 (1, 6)

下面我们用实践来验证理论,再开启一个事务 2,执行下述的语句:

image-20210802225636814

不出所料,由于在事务 1 中执行的 SQL 语句已经对主键索引中列 a=105 的记录加上了 X 锁,所以此处再去获取 这个记录的 X 锁会被阻塞住。

再用一个事务来执行下述 SQL 语句:

image-20210802230358942

主键插入 104 没有任何问题,但是插入的 class 索引值 2 在被锁定的范围 (1,6) 中,因此执行同样会被阻塞住。

经过上面的分析,大家一定能够知道下面的 SQL 语句是可以正常执行的:

image-20210802230542969

Attention

需要注意的是,Next-Key Lock 降级为 Record Lock 仅存在于操作所有的唯一索引列的情况。若唯一索引由多个列组成,而操作的仅是多个唯一索引列中的其中一个,那么 InnoDB 存储引擎依然使用 Next-Key Lock 进行锁定。

🎉 关注公众号 | 飞天小牛肉,即时获取更新

  • 博主东南大学硕士在读,携程 Java 后台开发暑期实习生,利用课余时间运营一个公众号『 飞天小牛肉 』,2020/12/29 日开通,专注分享计算机基础(数据结构 + 算法 + 计算机网络 + 数据库 + 操作系统 + Linux)、Java 技术栈等相关原创技术好文。本公众号的目的就是让大家可以快速掌握重点知识,有的放矢。关注公众号第一时间获取文章更新,成长的路上我们一起进步
  • 并推荐个人维护的开源教程类项目: CS-Wiki(Gitee 推荐项目,现已累计 1.8k+ star), 致力打造完善的后端知识体系,在技术的路上少走弯路,欢迎各位小伙伴前来交流学习 ~ 😊
  • 如果各位小伙伴春招秋招没有拿得出手的项目的话,可以参考我写的一个项目「开源社区系统 Echo」Gitee 官方推荐项目,目前已累计 900+ star,基于 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + … 并提供详细的开发文档和配套教程。公众号后台回复 Echo 可以获取配套教程,目前尚在更新中。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

⭐MybatisPlus学习笔记⭐(五)实现乐观锁机制

发表于 2021-08-05

这是我参与8月更文挑战的第5天,活动详情查看:8月更文挑战

⭐8月更文挑战第5天⭐,进行MybatisPlus的学习,欢迎小伙伴们一起学习😁

Code皮皮虾 一个沙雕而又有趣的憨憨少年,和大多数小伙伴们一样喜欢听歌、游戏,当然除此之外还有写作的兴趣,emm…,日子还很长,让我们一起加油努力叭🌈

欢迎各位小伙伴们关注我的公众号:JavaCodes,名称虽带Java但涉及范围可不止Java领域噢😁,期待您的关注❤

原文链接⭐MybatisPlus学习笔记⭐(五)实现乐观锁机制

前序

MybatisPlus专栏

⭐MybatisPlus学习笔记⭐(一)环境搭建及入门HelloWorld

⭐MybatisPlus学习笔记⭐(二)CRUD全套详解

⭐MybatisPlus学习笔记⭐(三)实现逻辑删除、分页

⭐MybatisPlus学习笔记⭐(四)条件构造器Wrapper方法详解

环境搭建请看该链接的MybatisPlus模块


1、乐观锁概述

**乐观锁( Optimistic Locking )** 相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( **Version** )记录机制实现。何谓数据版本?即为数据增加一个**版本标识**,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,**将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号等于数据库表当前版本号,则予以更新,否则认为是过期数据。**

2、主要适用场景

意图:

当要更新一条记录的时候,希望这条记录没有被别人更新

乐观锁实现方式:

  • 取出记录时,获取当前version
  • 更新时,带上这个version
  • 执行更新时, set version = newVersion where version = oldVersion
  • 如果version不对,就更新失败
1
2
3
4
5
sql复制代码–A线程
update user set name = “dong”, version = version+1 where id = 2 and version = 1

–B 线程抢先完成,这个时候version = 2,会导致A修改失败!
update user set name = “dong”, version = version+1 where id = 2 and version = 1

3、配置乐观锁

数据库新增字段==version==

在这里插入图片描述

在这里插入图片描述

==Java实体类增加对应version字段==

在这里插入图片描述

@Version说明:

  • 支持的数据类型只有:int,Integer,long,Long,Date,Timestamp,LocalDateTime
  • 整数类型下 newVersion = oldVersion + 1
  • newVersion 会回写到 entity 中
  • 仅支持 updateById(id) 与 update(entity, wrapper) 方法
  • 在 update(entity, wrapper) 方法下, wrapper 不能复用!!!

==建立配置类,配置乐观锁插件==

1
2
3
4
5
6
7
8
9
10
11
12
sql复制代码@Configuration
public class MyConfig {

//乐观锁插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}

}

4、测试乐观锁

在这里插入图片描述

正常来说最后的结果会是测试一,但结果是测试二,因为乐观锁机制,线程1和线程2最开始获取到的version值都为1,但是线程2更新完毕后version自增成为2,此时线程1来更新version不是预期值1,所以更新失败!!!

在这里插入图片描述

在这里插入图片描述


最后

我是 Code皮皮虾,一个热爱分享知识的 皮皮虾爱好者,未来的日子里会不断更新出对大家有益的博文,期待大家的关注!!!

创作不易,如果这篇博文对各位有帮助,希望各位小伙伴可以==一键三连哦!==,感谢支持,我们下次再见~


一键三连.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

java版gRPC实战之二:服务发布和调用

发表于 2021-08-05

欢迎访问我的GitHub

github.com/zq2599/blog…

内容:所有原创文章分类汇总及配套源码,涉及Java、Docker、Kubernetes、DevOPS等;

本篇概览

  • 本文是《java版gRPC实战》系列的第二篇,前文《用proto生成代码》将父工程、依赖库版本、helloworld.proto对应的java代码都准备好了,今天的任务是实战gRPC服务的开发和调用,实现的效果如下图:

在这里插入图片描述

  • 本篇的具体操作如下:
  1. 开发名为local-server的springboot应用,提供helloworld.proto中定义的gRPC服务;
  2. 开发名为local-client的springboot应用,调用local-server提供的gRPP服务;
  3. 验证gRPC服务能不能正常调用;

源码下载

  • 本篇实战中的完整源码可在GitHub下载到,地址和链接信息如下表所示(github.com/zq2599/blog…%EF%BC%9A)
名称 链接 备注
项目主页 github.com/zq2599/blog… 该项目在GitHub上的主页
git仓库地址(https) github.com/zq2599/blog… 该项目源码的仓库地址,https协议
git仓库地址(ssh) git@github.com:zq2599/blog_demos.git 该项目源码的仓库地址,ssh协议
  • 这个git项目中有多个文件夹,《java版gRPC实战》系列的源码在grpc-tutorials文件夹下,如下图红框所示:

在这里插入图片描述

  • grpc-tutorials文件夹下有多个目录,本篇文章对应的代码在local-server和local-client中,如下图红框:

在这里插入图片描述

开发gRPC服务端

  • 首先要开发的是gRPC服务端,回顾前文中helloworld.proto中定义的服务和接口,如下所示,名为Simple的服务对外提供名为SayHello接口,这就是咱们接下来的任务,创建一个springboot应用,该应用以gRPC的方式提供SayHello接口给其他应用远程调用:
1
2
3
4
5
javascript复制代码service Simple {
// 接口定义
rpc SayHello (HelloRequest) returns (HelloReply) {
}
}
  • 基于springboot框架开发一个普通的gRPC服务端应用,一共需要五个步骤,如下图所示,接下来我们按照下图序号的顺序来开发:

在这里插入图片描述

  • 首先是在父工程grpc-turtorials下面新建名为local-server的模块,其build.gradle内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
groovy复制代码// 使用springboot插件
plugins {
id 'org.springframework.boot'
}

dependencies {
implementation 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter'
// 作为gRPC服务提供方,需要用到此库
implementation 'net.devh:grpc-server-spring-boot-starter'
// 依赖自动生成源码的工程
implementation project(':grpc-lib')
}
  • 这是个springboot应用,配置文件内容如下:
1
2
3
4
5
6
7
yaml复制代码spring:
application:
name: local-server
# gRPC有关的配置,这里只需要配置服务端口号
grpc:
server:
port: 9898
  • 新建拦截类LogGrpcInterceptor.java,每当gRPC请求到来后该类会先执行,这里是将方法名字在日志中打印出来,您可以对请求响应做更详细的处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码package com.bolingcavalry.grpctutorials;

import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LogGrpcInterceptor implements ServerInterceptor {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> serverCall, Metadata metadata,
ServerCallHandler<ReqT, RespT> serverCallHandler) {
log.info(serverCall.getMethodDescriptor().getFullMethodName());
return serverCallHandler.startCall(serverCall, metadata);
}
}
  • 为了让LogGrpcInterceptor可以在gRPC请求到来时被执行,需要做相应的配置,如下所示,在普通的bean的配置中添加注解即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码package com.bolingcavalry.grpctutorials;

import io.grpc.ServerInterceptor;
import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class GlobalInterceptorConfiguration {
@GrpcGlobalServerInterceptor
ServerInterceptor logServerInterceptor() {
return new LogGrpcInterceptor();
}
}
  • 应用启动类很简单:
1
2
3
4
5
6
7
8
9
10
11
java复制代码package com.bolingcavalry.grpctutorials;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LocalServerApplication {
public static void main(String[] args) {
SpringApplication.run(LocalServerApplication.class, args);
}
}
  • 接下来是最重要的service类,gRPC服务在此处对外暴露出去,完整代码如下,有几处要注意的地方稍后提到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码package com.bolingcavalry.grpctutorials;

import com.bolingcavalry.grpctutorials.lib.HelloReply;
import com.bolingcavalry.grpctutorials.lib.SimpleGrpc;
import net.devh.boot.grpc.server.service.GrpcService;
import java.util.Date;

@GrpcService
public class GrpcServerService extends SimpleGrpc.SimpleImplBase {

@Override
public void sayHello(com.bolingcavalry.grpctutorials.lib.HelloRequest request,
io.grpc.stub.StreamObserver<com.bolingcavalry.grpctutorials.lib.HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + request.getName() + ", " + new Date()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
  • 上述GrpcServerService.java中有几处需要注意:
  1. 是使用@GrpcService注解,再继承SimpleImplBase,这样就可以借助grpc-server-spring-boot-starter库将sayHello暴露为gRPC服务;
  2. SimpleImplBase是前文中根据proto自动生成的java代码,在grpc-lib模块中;
  3. sayHello方法中处理完毕业务逻辑后,调用HelloReply.onNext方法填入返回内容;
  4. 调用HelloReply.onCompleted方法表示本次gRPC服务完成;
  • 至此,gRPC服务端编码就完成了,咱们接着开始客户端开发;

调用gRPC

  • 在父工程grpc-turtorials下面新建名为local-client的模块,其build.gradle内容如下,注意要使用spingboot插件、依赖grpc-client-spring-boot-starter库:
1
2
3
4
5
6
7
8
9
10
11
groovy复制代码plugins {
id 'org.springframework.boot'
}

dependencies {
implementation 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'net.devh:grpc-client-spring-boot-starter'
implementation project(':grpc-lib')
}
  • 应用配置文件grpc-tutorials/local-client/src/main/resources/application.yml,注意address的值就是gRPC服务端的信息,我这里local-server和local-client在同一台电脑上运行,请您根据自己情况来设置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
yml复制代码server:
port: 8080
spring:
application:
name: local-grpc-client

grpc:
client:
# gRPC配置的名字,GrpcClient注解会用到
local-grpc-server:
# gRPC服务端地址
address: 'static://127.0.0.1:9898'
enableKeepAlive: true
keepAliveWithoutCalls: true
negotiationType: plaintext

-接下来要创建下图展示的类,按序号顺序创建:

在这里插入图片描述

  • 首先是拦截类LogGrpcInterceptor,与服务端的拦截类差不多,不过实现的接口不同:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码package com.bolingcavalry.grpctutorials;

import io.grpc.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LogGrpcInterceptor implements ClientInterceptor {

private static final Logger log = LoggerFactory.getLogger(LogGrpcInterceptor.class);

@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method,
CallOptions callOptions, Channel next) {
log.info(method.getFullMethodName());
return next.newCall(method, callOptions);
}
}
  • 为了让拦截类能够正常工作,即发起gRPC请求的时候被执行,需要新增一个配置类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码package com.bolingcavalry.grpctutorials;

import io.grpc.ClientInterceptor;
import net.devh.boot.grpc.client.interceptor.GrpcGlobalClientInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;

@Order(Ordered.LOWEST_PRECEDENCE)
@Configuration(proxyBeanMethods = false)
public class GlobalClientInterceptorConfiguration {

@GrpcGlobalClientInterceptor
ClientInterceptor logClientInterceptor() {
return new LogGrpcInterceptor();
}
}
  • 启动类:
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码package com.bolingcavalry.grpctutorials;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LocalGrpcClientApplication {

public static void main(String[] args) {
SpringApplication.run(LocalGrpcClientApplication.class, args);
}
}
  • 接下来是最重要的服务类GrpcClientService,有几处要注意的地方稍后会提到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码package com.bolingcavalry.grpctutorials;

import com.bolingcavalry.grpctutorials.lib.HelloReply;
import com.bolingcavalry.grpctutorials.lib.HelloRequest;
import com.bolingcavalry.grpctutorials.lib.SimpleGrpc;
import io.grpc.StatusRuntimeException;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.springframework.stereotype.Service;

@Service
public class GrpcClientService {

@GrpcClient("local-grpc-server")
private SimpleGrpc.SimpleBlockingStub simpleStub;

public String sendMessage(final String name) {
try {
final HelloReply response = this.simpleStub.sayHello(HelloRequest.newBuilder().setName(name).build());
return response.getMessage();
} catch (final StatusRuntimeException e) {
return "FAILED with " + e.getStatus().getCode().name();
}
}
}
  • 上述GrpcClientService类有几处要注意的地方:
  1. 用@Service将GrpcClientService注册为spring的普通bean实例;
  2. 用@GrpcClient修饰SimpleBlockingStub,这样就可以通过grpc-client-spring-boot-starter库发起gRPC调用,被调用的服务端信息来自名为local-grpc-server的配置;
  3. SimpleBlockingStub来自前文中根据helloworld.proto生成的java代码;
  4. SimpleBlockingStub.sayHello方法会远程调用local-server应用的gRPC服务;
  • 为了验证gRPC服务调用能否成功,再新增个web接口,接口内部会调用GrpcClientService.sendMessage,这样咱们通过浏览器就能验证gRPC服务是否调用成功了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码package com.bolingcavalry.grpctutorials;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GrpcClientController {

@Autowired
private GrpcClientService grpcClientService;

@RequestMapping("/")
public String printMessage(@RequestParam(defaultValue = "will") String name) {
return grpcClientService.sendMessage(name);
}
}
  • 编码完成,接下来将两个服务都启动,验证gRPC服务是否正常;

验证gRPC服务

  1. local-server和local-client都是普通的springboot应用,可以在IDEA中启动,点击下图红框位置,在弹出菜单中选择Run ‘LocalServerApplication’即可启动local-server:

在这里插入图片描述

  1. local-server启动后,控制台会提示gRPC server已启动,正在监听9898端口,如下图:

在这里插入图片描述

  1. local-client后,在浏览器输入http://localhost:8080/?name=Tom,可以看到响应的内容正是来自local-server的GrpcServerService.java:

在这里插入图片描述

  1. 从web端到gRPC服务端的关键节点信息如下图:

在这里插入图片描述

  • 可以看到local-server的拦截日志:

在这里插入图片描述

  • 还有local-client的拦截日志:

在这里插入图片描述

  • 至此,最简单的java版gRPC服务发布和调用验证通过,本篇的任务也就完成了,接下来的文章,咱们会继续深入学习java版gRPC的相关技术;

你不孤单,欣宸原创一路相伴

  1. Java系列
  2. Spring系列
  3. Docker系列
  4. kubernetes系列
  5. 数据库+中间件系列
  6. DevOps系列

欢迎关注公众号:程序员欣宸

微信搜索「程序员欣宸」,我是欣宸,期待与您一同畅游Java世界…
github.com/zq2599/blog…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java中定时任务的6种实现方式,你知道几种?

发表于 2021-08-05

几乎在所有的项目中,定时任务的使用都是不可或缺的,如果使用不当甚至会造成资损。还记得多年前在做金融系统时,出款业务是通过定时任务对外打款,当时由于银行接口处理能力有限,外加定时任务使用不当,导致发出大量重复出款请求。还好在后面环节将交易卡在了系统内部,未发生资损。

所以,系统的学习一下定时任务,是非常有必要的。这篇文章就带大家整体梳理学习一下Java领域中常见的几种定时任务实现。

线程等待实现

先从最原始最简单的方式来讲解。可以先创建一个thread,然后让它在while循环里一直运行着,通过sleep方法来达到定时任务的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class Task {

public static void main(String[] args) {
// run in a second
final long timeInterval = 1000;
Runnable runnable = new Runnable() {
@Override
public void run() {
while (true) {
System.out.println("Hello !!");
try {
Thread.sleep(timeInterval);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread = new Thread(runnable);
thread.start();
}
}

这种方式简单直接,但是能够实现的功能有限,而且需要自己来实现。

JDK自带Timer实现

目前来看,JDK自带的Timer API算是最古老的定时任务实现方式了。Timer是一种定时器工具,用来在一个后台线程计划执行指定任务。它可以安排任务“执行一次”或者定期“执行多次”。

在实际的开发当中,经常需要一些周期性的操作,比如每5分钟执行某一操作等。对于这样的操作最方便、高效的实现方式就是使用java.util.Timer工具类。

核心方法

Timer类的核心方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scss复制代码// 在指定延迟时间后执行指定的任务
schedule(TimerTask task,long delay);

// 在指定时间执行指定的任务。(只执行一次)
schedule(TimerTask task, Date time);

// 延迟指定时间(delay)之后,开始以指定的间隔(period)重复执行指定的任务
schedule(TimerTask task,long delay,long period);

// 在指定的时间开始按照指定的间隔(period)重复执行指定的任务
schedule(TimerTask task, Date firstTime , long period);

// 在指定的时间开始进行重复的固定速率执行任务
scheduleAtFixedRate(TimerTask task,Date firstTime,long period);

// 在指定的延迟后开始进行重复的固定速率执行任务
scheduleAtFixedRate(TimerTask task,long delay,long period);

// 终止此计时器,丢弃所有当前已安排的任务。
cancal();

// 从此计时器的任务队列中移除所有已取消的任务。
purge();

使用示例

下面用几个示例演示一下核心方法的使用。首先定义一个通用的TimerTask类,用于定义用执行的任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
scala复制代码public class DoSomethingTimerTask extends TimerTask {

private String taskName;

public DoSomethingTimerTask(String taskName) {
this.taskName = taskName;
}

@Override
public void run() {
System.out.println(new Date() + " : 任务「" + taskName + "」被执行。");
}
}

指定延迟执行一次

在指定延迟时间后执行一次,这类是比较常见的场景,比如:当系统初始化某个组件之后,延迟几秒中,然后进行定时任务的执行。

1
2
3
4
5
6
7
typescript复制代码public class DelayOneDemo {

public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new DoSomethingTimerTask("DelayOneDemo"),1000L);
}
}

执行上述代码,延迟一秒之后执行定时任务,并打印结果。其中第二个参数单位为毫秒。

固定间隔执行

在指定的延迟时间开始执行定时任务,定时任务按照固定的间隔进行执行。比如:延迟2秒执行,固定执行间隔为1秒。

1
2
3
4
5
6
7
typescript复制代码public class PeriodDemo {

public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new DoSomethingTimerTask("PeriodDemo"),2000L,1000L);
}
}

执行程序,会发现2秒之后开始每隔1秒执行一次。

固定速率执行

在指定的延迟时间开始执行定时任务,定时任务按照固定的速率进行执行。比如:延迟2秒执行,固定速率为1秒。

1
2
3
4
5
6
7
typescript复制代码public class FixedRateDemo {

public static void main(String[] args) {
Timer timer = new Timer();
timer.scheduleAtFixedRate(new DoSomethingTimerTask("FixedRateDemo"),2000L,1000L);
}
}

执行程序,会发现2秒之后开始每隔1秒执行一次。

此时,你是否疑惑schedule与scheduleAtFixedRate效果一样,为什么提供两个方法,它们有什么区别?

schedule与scheduleAtFixedRate区别

在了解schedule与scheduleAtFixedRate方法的区别之前,先看看它们的相同点:

  • 任务执行未超时,下次执行时间 = 上次执行开始时间 + period;
  • 任务执行超时,下次执行时间 = 上次执行结束时间;

在任务执行未超时时,它们都是上次执行时间加上间隔时间,来执行下一次任务。而执行超时时,都是立马执行。

它们的不同点在于侧重点不同,schedule方法侧重保持间隔时间的稳定,而scheduleAtFixedRate方法更加侧重于保持执行频率的稳定。

schedule侧重保持间隔时间的稳定

schedule方法会因为前一个任务的延迟而导致其后面的定时任务延时。计算公式为scheduledExecutionTime(第n+1次) = realExecutionTime(第n次) + periodTime。

也就是说如果第n次执行task时,由于某种原因这次执行时间过长,执行完后的systemCurrentTime>= scheduledExecutionTime(第n+1次),则此时不做时隔等待,立即执行第n+1次task。

而接下来的第n+2次task的scheduledExecutionTime(第n+2次)就随着变成了realExecutionTime(第n+1次)+periodTime。这个方法更注重保持间隔时间的稳定。

scheduleAtFixedRate保持执行频率的稳定

scheduleAtFixedRate在反复执行一个task的计划时,每一次执行这个task的计划执行时间在最初就被定下来了,也就是scheduledExecutionTime(第n次)=firstExecuteTime +n*periodTime。

如果第n次执行task时,由于某种原因这次执行时间过长,执行完后的systemCurrentTime>= scheduledExecutionTime(第n+1次),则此时不做period间隔等待,立即执行第n+1次task。

接下来的第n+2次的task的scheduledExecutionTime(第n+2次)依然还是firstExecuteTime+(n+2)*periodTime这在第一次执行task就定下来了。说白了,这个方法更注重保持执行频率的稳定。

如果用一句话来描述任务执行超时之后schedule和scheduleAtFixedRate的区别就是:schedule的策略是错过了就错过了,后续按照新的节奏来走;scheduleAtFixedRate的策略是如果错过了,就努力追上原来的节奏(制定好的节奏)。

Timer的缺陷

Timer计时器可以定时(指定时间执行任务)、延迟(延迟5秒执行任务)、周期性地执行任务(每隔个1秒执行任务)。但是,Timer存在一些缺陷。首先Timer对调度的支持是基于绝对时间的,而不是相对时间,所以它对系统时间的改变非常敏感。

其次Timer线程是不会捕获异常的,如果TimerTask抛出的了未检查异常则会导致Timer线程终止,同时Timer也不会重新恢复线程的执行,它会错误的认为整个Timer线程都会取消。同时,已经被安排单尚未执行的TimerTask也不会再执行了,新的任务也不能被调度。故如果TimerTask抛出未检查的异常,Timer将会产生无法预料的行为。

JDK自带ScheduledExecutorService

ScheduledExecutorService是JAVA 1.5后新增的定时任务接口,它是基于线程池设计的定时任务类,每个调度任务都会分配到线程池中的一个线程去执行。也就是说,任务是并发执行,互不影响。

需要注意:只有当执行调度任务时,ScheduledExecutorService才会真正启动一个线程,其余时间ScheduledExecutorService都是出于轮询任务的状态。

ScheduledExecutorService主要有以下4个方法:

1
2
3
4
arduino复制代码ScheduledFuture<?> schedule(Runnable command,long delay, TimeUnit unit);
<V> ScheduledFuture<V> schedule(Callable<V> callable,long delay, TimeUnit unit);
ScheduledFuture<?> scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnitunit);
ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,long initialDelay,long delay,TimeUnitunit);

其中scheduleAtFixedRate和scheduleWithFixedDelay在实现定时程序时比较方便,运用的也比较多。

ScheduledExecutorService中定义的这四个接口方法和Timer中对应的方法几乎一样,只不过Timer的scheduled方法需要在外部传入一个TimerTask的抽象任务。
而ScheduledExecutorService封装的更加细致了,传Runnable或Callable内部都会做一层封装,封装一个类似TimerTask的抽象任务类(ScheduledFutureTask)。然后传入线程池,启动线程去执行该任务。

scheduleAtFixedRate方法

scheduleAtFixedRate方法,按指定频率周期执行某个任务。定义及参数说明:

1
2
3
4
arduino复制代码public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
long initialDelay,
long period,
TimeUnit unit);

参数对应含义:command为被执行的线程;initialDelay为初始化后延时执行时间;period为两次开始执行最小间隔时间;unit为计时单位。

使用实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typescript复制代码public class ScheduleAtFixedRateDemo implements Runnable{

public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.scheduleAtFixedRate(
new ScheduleAtFixedRateDemo(),
0,
1000,
TimeUnit.MILLISECONDS);
}

@Override
public void run() {
System.out.println(new Date() + " : 任务「ScheduleAtFixedRateDemo」被执行。");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

上面是scheduleAtFixedRate方法的基本使用方式,但当执行程序时会发现它并不是间隔1秒执行的,而是间隔2秒执行。

这是因为,scheduleAtFixedRate是以period为间隔来执行任务的,如果任务执行时间小于period,则上次任务执行完成后会间隔period后再去执行下一次任务;但如果任务执行时间大于period,则上次任务执行完毕后会不间隔的立即开始下次任务。

scheduleWithFixedDelay方法

scheduleWithFixedDelay方法,按指定频率间隔执行某个任务。定义及参数说明:

1
2
3
4
arduino复制代码public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
long initialDelay,
long delay,
TimeUnit unit);

参数对应含义:command为被执行的线程;initialDelay为初始化后延时执行时间;period为前一次执行结束到下一次执行开始的间隔时间(间隔执行延迟时间);unit为计时单位。

使用实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typescript复制代码public class ScheduleAtFixedRateDemo implements Runnable{

public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.scheduleWithFixedDelay(
new ScheduleAtFixedRateDemo(),
0,
1000,
TimeUnit.MILLISECONDS);
}

@Override
public void run() {
System.out.println(new Date() + " : 任务「ScheduleAtFixedRateDemo」被执行。");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

上面是scheduleWithFixedDelay方法的基本使用方式,但当执行程序时会发现它并不是间隔1秒执行的,而是间隔3秒。

这是因为scheduleWithFixedDelay是不管任务执行多久,都会等上一次任务执行完毕后再延迟delay后去执行下次任务。

Quartz框架实现

除了JDK自带的API之外,我们还可以使用开源的框架来实现,比如Quartz。

Quartz是Job scheduling(作业调度)领域的一个开源项目,Quartz既可以单独使用也可以跟spring框架整合使用,在实际开发中一般会使用后者。使用Quartz可以开发一个或者多个定时任务,每个定时任务可以单独指定执行的时间,例如每隔1小时执行一次、每个月第一天上午10点执行一次、每个月最后一天下午5点执行一次等。

Quartz通常有三部分组成:调度器(Scheduler)、任务(JobDetail)、触发器(Trigger,包括SimpleTrigger和CronTrigger)。下面以具体的实例进行说明。

Quartz集成

要使用Quartz,首先需要在项目的pom文件中引入相应的依赖:

1
2
3
4
5
6
7
8
9
10
xml复制代码<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.3.2</version>
</dependency>
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz-jobs</artifactId>
<version>2.3.2</version>
</dependency>

定义执行任务的Job,这里要实现Quartz提供的Job接口:

1
2
3
4
5
6
java复制代码public class PrintJob implements Job {
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
System.out.println(new Date() + " : 任务「PrintJob」被执行。");
}
}

创建Scheduler和Trigger,并执行定时任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scss复制代码public class MyScheduler {

public static void main(String[] args) throws SchedulerException {
// 1、创建调度器Scheduler
SchedulerFactory schedulerFactory = new StdSchedulerFactory();
Scheduler scheduler = schedulerFactory.getScheduler();
// 2、创建JobDetail实例,并与PrintJob类绑定(Job执行内容)
JobDetail jobDetail = JobBuilder.newJob(PrintJob.class)
.withIdentity("job", "group").build();
// 3、构建Trigger实例,每隔1s执行一次
Trigger trigger = TriggerBuilder.newTrigger().withIdentity("trigger", "triggerGroup")
.startNow()//立即生效
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(1)//每隔1s执行一次
.repeatForever()).build();//一直执行

//4、Scheduler绑定Job和Trigger,并执行
scheduler.scheduleJob(jobDetail, trigger);
System.out.println("--------scheduler start ! ------------");
scheduler.start();
}
}

执行程序,可以看到每1秒执行一次定时任务。

在上述代码中,其中Job为Quartz的接口,业务逻辑的实现通过实现该接口来实现。

JobDetail绑定指定的Job,每次Scheduler调度执行一个Job的时候,首先会拿到对应的Job,然后创建该Job实例,再去执行Job中的execute()的内容,任务执行结束后,关联的Job对象实例会被释放,且会被JVM GC清除。

Trigger是Quartz的触发器,用于通知Scheduler何时去执行对应Job。SimpleTrigger可以实现在一个指定时间段内执行一次作业任务或一个时间段内多次执行作业任务。

CronTrigger功能非常强大,是基于日历的作业调度,而SimpleTrigger是精准指定间隔,所以相比SimpleTrigger,CroTrigger更加常用。CroTrigger是基于Cron表达式的。

常见的Cron表达式示例如下:

cron

可以看出,基于Quartz的CronTrigger可以实现非常丰富的定时任务场景。

Spring Task

从Spring 3开始,Spring自带了一套定时任务工具Spring-Task,可以把它看成是一个轻量级的Quartz,使用起来十分简单,除Spring相关的包外不需要额外的包,支持注解和配置文件两种形式。通常情况下在Spring体系内,针对简单的定时任务,可直接使用Spring提供的功能。

基于XML配置文件的形式就不再介绍了,直接看基于注解形式的实现。使用起来非常简单,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typescript复制代码@Component("taskJob")
public class TaskJob {

@Scheduled(cron = "0 0 3 * * ?")
public void job1() {
System.out.println("通过cron定义的定时任务");
}

@Scheduled(fixedDelay = 1000L)
public void job2() {
System.out.println("通过fixedDelay定义的定时任务");
}

@Scheduled(fixedRate = 1000L)
public void job3() {
System.out.println("通过fixedRate定义的定时任务");
}
}

如果是在Spring Boot项目中,需要在启动类上添加@EnableScheduling来开启定时任务。

上述代码中,@Component用于实例化类,这个与定时任务无关。@Scheduled指定该方法是基于定时任务进行执行,具体执行的频次是由cron指定的表达式所决定。关于cron表达式上面CronTrigger所使用的表达式一致。与cron对照的,Spring还提供了fixedDelay和fixedRate两种形式的定时任务执行。

fixedDelay和fixedRate的区别

fixedDelay和fixedRate的区别于Timer中的区别很相似。

fixedRate有一个时刻表的概念,在任务启动时,T1、T2、T3就已经排好了执行的时刻,比如1分、2分、3分,当T1的执行时间大于1分钟时,就会造成T2晚点,当T1执行完时T2立即执行。

fixedDelay比较简单,表示上个任务结束,到下个任务开始的时间间隔。无论任务执行花费多少时间,两个任务间的间隔始终是一致的。

Spring Task的缺点

Spring Task 本身不支持持久化,也没有推出官方的分布式集群模式,只能靠开发者在业务应用中自己手动扩展实现,无法满足可视化,易配置的需求。

分布式任务调度

以上定时任务方案都是针对单机的,只能在单个JVM进程中使用。而现在基本上都是分布式场景,需要一套在分布式环境下高性能、高可用、可扩展的分布式任务调度框架。

Quartz分布式

首先,Quartz是可以用于分布式场景的,但需要基于数据库锁的形式。简单来说,quartz的分布式调度策略是以数据库为边界的一种异步策略。各个调度器都遵守一个基于数据库锁的操作规则从而保证了操作的唯一性,同时多个节点的异步运行保证了服务的可靠。

因此,Quartz的分布式方案只解决了任务高可用(减少单点故障)的问题,处理能力瓶颈在数据库,而且没有执行层面的任务分片,无法最大化效率,只能依靠shedulex调度层面做分片,但是调度层做并行分片难以结合实际的运行资源情况做最优的分片。

轻量级神器XXL-Job

XXL-JOB是一个轻量级分布式任务调度平台。特点是平台化,易部署,开发迅速、学习简单、轻量级、易扩展。由调度中心和执行器功能完成定时任务的执行。调度中心负责统一调度,执行器负责接收调度并执行。

针对于中小型项目,此框架运用的比较多。

其他框架

除此之外,还有Elastic-Job、Saturn、SIA-TASK等。

Elastic-Job具有高可用的特性,是一个分布式调度解决方案。

Saturn是唯品会开源的一个分布式任务调度平台,在Elastic Job的基础上进行了改造。

SIA-TASK是宜信开源的分布式任务调度平台。

小结

通过本文梳理了6种定时任务的实现,就实践场景的运用来说,目前大多数系统已经脱离了单机模式。对于并发量并不是太高的系统,xxl-job或许是一个不错的选择。

源码地址:github.com/secbr/java-…

博主简介:《SpringBoot技术内幕》技术图书作者,酷爱钻研技术,写技术干货文章。

公众号:「程序新视界」,博主的公众号,欢迎关注~

技术交流:请联系博主微信号:zhuan2quan

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…578579580…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%