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

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


  • 首页

  • 归档

  • 搜索

Spring高频面试题:如何解决循环依赖问题 循环依赖问题全

发表于 2020-11-17

循环依赖问题全景图

什么是循环依赖问题?

类与类之间的依赖关系形成了闭环,就会导致循环依赖问题的产生。

比如下图中A类依赖了B类,B类依赖了C类,而最后C类又依赖了A类,这样就形成了循环依赖问题。

循环依赖问题案例分析

  • 演示代码:
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 ClassA {
private ClassB classB;

public ClassB getClassB() {
return classB;
}

public void setClassB(ClassB classB) {
this.classB = classB;
}
}
public class ClassB {
private ClassA classA;

public ClassA getClassA() {
return classA;
}

public void setClassA(ClassA classA) {
this.classA = classA;
}
}
  • 配置文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="classA" class="ioc.cd.ClassA">
<property name="classB" ref="classB"></property>
</bean>
<bean id="classB" class="ioc.cd.ClassB">
<property name="classA" ref="classA"></property>
</bean>
</beans>
  • 测试代码:
1
2
3
4
5
6
7
8
java复制代码	@Test
public void test() throws Exception {
// 创建IoC容器,并进行初始化
String resource = "spring/spring-ioc-circular-dependency.xml";
ApplicationContext context = new ClassPathXmlApplicationContext(resource);
// 获取ClassA的实例(此时会发生循环依赖)
ClassA classA = (ClassA) context.getBean(ClassA.class);
}
  • 通过Spring IOC流程的源码分析循环依赖问题:

以上案例有几种循环依赖问题?

循环依赖问题在Spring中主要有三种情况:

  • 通过构造方法进行依赖注入时产生的循环依赖问题。
  • 通过setter方法进行依赖注入且是在多例(原型)模式下产生的循环依赖问题。
  • 通过setter方法进行依赖注入且是在单例模式下产生的循环依赖问题。

注意:在Spring中,只有【第三种方式】的循环依赖问题被解决了,其他两种方式在遇到循环依赖问题时都会产生异常。

其实也很好解释:

  • 第一种构造方法注入的情况下,在new对象的时候就会堵塞住了,其实也就是”先有鸡还是先有蛋“的历史难题。
  • 第二种setter方法&&多例的情况下,每一次getBean()时,都会产生一个新的Bean,如此反复下去就会有无穷无尽的Bean产生了,最终就会导致OOM问题的出现。

如何解决循环依赖问题?

那Spring到底是如何解决的setter方法依赖注入引起的循环依赖问题呢?请看下图(其实主要是通过两个缓存来解决的):

Spring三大缓存介绍

Spring中有三个缓存,用于存储单例的Bean实例,这三个缓存是彼此互斥的,不会针对同一个Bean的实例同时存储。

如果调用getBean,则需要从三个缓存中依次获取指定的Bean实例。
读取顺序依次是一级缓存–>二级缓存–>三级缓存

一级缓存:Map<String, Object> singletonObjects

第一级缓存的作用?

  • 用于存储单例模式下创建的Bean实例(已经创建完毕)。
  • 该缓存是对外使用的,指的就是使用Spring框架的程序员。

存储什么数据?

  • K:bean的名称
  • V:bean的实例对象(有代理对象则指的是代理对象,已经创建完毕)

第二级缓存:Map<String, Object> earlySingletonObjects

第二级缓存的作用?

  • 用于存储单例模式下创建的Bean实例(该Bean被提前暴露的引用,该Bean还在创建中)。
  • 该缓存是对内使用的,指的就是Spring框架内部逻辑使用该缓存。
  • 为了解决第一个classA引用最终如何替换为代理对象的问题(如果有代理对象)请爬楼参考演示案例

存储什么数据?

  • K:bean的名称
  • V:bean的实例对象(有代理对象则指的是代理对象,该Bean还在创建中)

第三级缓存:Map<String, ObjectFactory<?>> singletonFactories

第三级缓存的作用?

  • 通过ObjectFactory对象来存储单例模式下提前暴露的Bean实例的引用(正在创建中)。
  • 该缓存是对内使用的,指的就是Spring框架内部逻辑使用该缓存。
  • 此缓存是解决循环依赖最大的功臣

存储什么数据?

  • K:bean的名称
  • V:ObjectFactory,该对象持有提前暴露的bean的引用

为什么第三级缓存要使用ObjectFactory?需要提前产生代理对象。

什么时候将Bean的引用提前暴露给第三级缓存的ObjectFactory持有?时机就是在第一步实例化之后,第二步依赖注入之前,完成此操作。

总结

以上就是Spring解决循环依赖的关键点!总结来说,就是要搞清楚以下几点:

  • 搞清楚Spring三级缓存的作用?
  • 搞清楚第三级缓存中ObjectFactory的作用?
  • 搞清楚为什么需要第二级缓存?
  • 搞清楚什么时候使用三级缓存(添加和查询操作)?
  • 搞清楚什么时候使用二级缓存(添加和查询操作)?
  • 当目标对象产生代理对象时,Spring容器中(第一级缓存)到底存储的是谁?

本文转载自: 掘金

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

Mongodb基础,快速学会使用Nodejs操作数据库

发表于 2020-11-16

一、Mongodb 介绍

Mongodb 是一个文档数据库,以文档形式存储数据,格式类似于 JSON

与 Mysql 的特点及选型对照

Mongodb Mysql
关系类型 非关系型 关系型
存储类型 文档存储(类似于写 Word ) 表格存储 (类似于写 Excle)
操作类型 NoSQL 操作 (不需要写 sql 语句) SQL 操作
选型对照 适合存储格式比较松散的信息,如“爬虫”下来的数据 适合存储格式比较规整的信息

说明:并不是说在真实项目中,两种数据库只能选其一, 二者是可以同时使用的。对于用户信息表等规整信息可以采用 Mysql 数据库存储,而对于网页中类似博客内容等大段的富文本信息(同时包含文本、视频、图片等),放到 Mysql 数据库中的一个字段中存储就不太合适了,这段博客内容就可以采用 Mongodb 来存储。

二、Mongodb 及 Compass 安装

网上安装的资料很多,我在这里就不在重复了,大家可以根据以下参考博客进行安装

Mongodb 安装

Window 平台安装链接

Mac OS 平台安装链接 (推荐使用 brew 进行安装)

说明:在我安装后默认的运行了 Mongodb 服务器,以及连接上了 Mongodb, 可以直接通过命令行进行操作了。如果你还连接不上 Mongodb ,请参考上面安装连接中的配置。

Mongodb Compass 安装

Mongodb Compass 是官方推荐的一款可视化工具,可以方便直观的进行数据的浏览和操纵

安装链接

三、重要概念

1、和 Mysql 的差异

对于关系型数据库,使用时通常是先创建数据库,再创建表,然后对记录的增删改查

但在 Mongodb 中这些名词上有些差异,操作步骤如下:

  • 创建一个数据库 (database) – 和 Mysql 相同
  • 创建集合 (collection) – 对应 Mysql 中的表
  • 对文档 (document) 的增删改查 – 对应 Mysql 中记录的增删改查

2、BSON

  • Mongodb 的文档使用的格式是 BSON
  • BSON = Binary JSON ,是二进制类型的 JSON

四、使用 Compass 操作 Mongodb

1、打开后的界面如图所示,点击 “CONNECT” 进行连接

2、新安装的 Mongodb 默认带了三个数据库; 点击 “CREATE DATABASE” 进行创建数据库

3、创建数据库和集合

4、点击 “ADD DATA” 可以增加文档

5、文档的格式如下图 :


6、对文档的查找、修改和删除

注:查找时输入的数据也是类似于 JSON 的格式

五、使用命令行操作 Mongodb

打开 Mongodb 目录下 bin/mongo.exe

注:dbName、collName 等 请改为自己创建的数据库或集合的名字

1、查看操作

1
2
3
4
5
6
7
8
shell复制代码# 查看有哪些数据库
show dbs

# 创建或进入数据库 (当数据库不存在即创建)
use dbName

# 查看有哪些集合
show collections

2、创建集合 / 向集合中插入文档

1
2
shell复制代码# 可以直接向集合插入数据,对不存在的集合将会创建
db.collName.insert({"name":"Axton", "age":20})

3、显示集合中的文档

1
2
3
4
5
6
7
8
shell复制代码# 显示集合中全部文档
db.collName.find()

# 显示集合中特定文档
db.collName.find({ "name":"Axton" })

# 排序,按 ID 倒序排序
db.collName.find().sort({_id:-1})

4、更新文档

1
shell复制代码db.collName.update({"name":"Axton", $set:{"name": "Axton2"}})

5、删除文档

1
shell复制代码db.collName.remove({"name": "Axton"})

六、Node.js 中使用 mongodb 插件连接数据库

1、初始化环境

1
2
3
4
5
shell复制代码# 初始化 node.js 环境
npm init -y

# 安装 mongodb 模块
npm i mongodb --save

2、连接到 Mongodb

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
js复制代码const MongoClient = require('mongodb').MongoClient

const url = 'mongodb://localhost:27017' // 默认端口号为 27017 ,如修改过请自行更改
const dbName = 'mydb' // 需要连接的数据库名字

MongoClient.connect(
url,
{
// 配置 (默认写上就好)
useUnifiedTopology: true
},
(err, client) => {
if (err) {
console.error('mongodn connect error', err)
return
}

console.log('mongodb connect success')

// 切换到数据库
const db = client.db(dbName)

// 关闭连接
client.close()
}
)

3、文档(Document)的操作

查询文档

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
js复制代码const MongoClient = require('mongodb').MongoClient

const url = 'mongodb://localhost:27017'
const dbName = 'mydb'

MongoClient.connect(
url,
{
useUnifiedTopology: true
},
(err, client) => {
if (err) {
console.error('mongodn connect error', err)
return
}
console.log('mongodb connect success')
const db = client.db(dbName)

// 使用集合
const usersCollection = db.collection('students')

// ------- 文档的操作代码写在此部分,之后将不再包含连接部分代码 -------
// 查询文档
usersCollection.find({ // find() 内不写内容则为查询全部文档
"name" : "Axton"
}).toArray((err, res) => {
if (err) {
console.error(err)
return
}
console.log(res)

// find() 为异步操作,为了使查询完后在关闭数据,所以放至此位置
client.close()
})
// -------------------------------------------------------------
}
)

插入文档

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码// 插入一条数据, 插入多条可以选择 insertMany()
usersCollection.insertOne({
name: 'Jack',
sex: '男',
age: 21
}, (err, res) => {
if (err) {
console.error(err)
return
}
console.log(res)
client.close()
})

更新文档

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码// 更新一条数据, 更新多条可以选择 updateMany()
usersCollection.updateOne(
{name: 'Jack'}, // 查询条件
{$set: {info: '女装'}}, // 更新的内容
(err, res) => {
if (err) {
console.error(err)
return
}
console.log(res)
client.close()
}
)

删除文档

1
2
3
4
5
6
7
8
9
10
11
js复制代码// 删除一条数据, 删除多条可以选择 deleteMany()
usersCollection.deleteOne({
name: 'Jack'
}, (err, res) => {
if (err) {
console.error(err)
return
}
console.log(res)
client.close()
})

4、说明

mongodb 是偏底层一点的插件,此处学习使用此插件是为了让大家了解连接 mongodb 的一些原理。在日常中,为了利于项目开发,通常使用 mongoose 插件

七、Node.js 中使用 mongoose 插件连接数据库

1、说明

由于 mongodb 的数据格式过于灵活,容易导致多人开发时数据格式出错,使用 mongoose 可以更加规范的使用数据.

重要名词和操作

  • Schema 定义数据格式的规范
  • 以 Model 规范 Collection
  • 规范数据操作的 API

2、初始化环境

安装 mongoose 插件

1
shell复制代码npm i mongoose --save

目录结构

1
2
3
4
5
6
7
8
9
10
go复制代码mongoose-test
├── package-lock.json
├── package.json
├── node_modules
└── src
├── db.js
├── models
│ └── Student.js
└── test
└── student.js

3、创建连接服务

db.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
js复制代码const mongoose = require('mongoose')

const url = 'mongodb://localhost:27017'
const dbName = 'myblog'

mongoose.connect(`${url}/${dbName}`, {
useUnifiedTopology: true,
useNewUrlParser: true
})

const db = mongoose.connection // 将连接对象赋给 db

// 发送错误
db.on('error', err => {
console.error(err)
})

// 连接成功
db.on('open', () => {
console.log('success!')
})

module.exports = mongoose

4、定义 Schema 规范和创建模型

models/Students.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
js复制代码// 对应 students 集合

const mongoose = require('../db')

// 用 Schema 定义数据规范
const StudentSchema = mongoose.Schema({
name: {
type: String,
required: true,
unique: true
},
sex: String,
age: Number
}, {
timestamps: true // 时间戳, 在插入、更新数据后将会加上相应的时间数据
// 在每一条文档中加入 createdAt 和 updatedAt
})

// Model 对于 Collection
const Student = mongoose.model('student', StudentSchema)

module.exports = Student

5、文档(Document)的操作

test/students.js

查询文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码const Student = require('../models/Student')

// 自执行的异步函数
!(async () => {

// 查询文档
const res = await Student.find({
name: 'Axton'
})

// 用正则表达式模糊查询文档
const res2 = await Student.find({
name: /A/
})

// 根据 ID 查询单条文档
const res3 = await Student.findById('5fb2247e6469170600f02551')

})()

插入文档

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码const Student = require('../models/Student')

!(async () => {

// 插入文档
const res = await Student.create({
name: 'Jack',
age: 21,
sex: '男'
})

})()

更新文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码const Student = require('../models/Student')

!(async () => {

// 更新文档
const res = await Student.findOneAndUpdate(
{name: 'Jack'}, // 查询的条件
{sex: '女'},
{
new: true // 返回修改之后的内容,默认为 fasle
}
)

})()

删除文档

1
2
3
4
5
6
7
8
9
10
js复制代码const Student = require('../models/Student')

!(async () => {

// 删除文档
const res = await Student.findOneAndDelete({
name: 'Jack'
})

})()

八、写在文末

本文只是把 mongodb 和 node.js 连接 mongodb 等基础操作进行了整理和归纳,能用在常用的一些场景中。对于更高级的一些操作可以自行参考官方文档。如果发现本文有错误的地方或者写的不好的地方请在评论区指出。

本文转载自: 掘金

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

把用户访问记录优化到极致

发表于 2020-11-16

image

还记得菜菜不久之前设计的用户空间吗?没看过的同学请进传送门=》

设计高性能访客记录系统

还记得遗留的什么问题吗?菜菜来重复一下,在用户访问记录的缓存中怎么来判断是否有当前用户的记录呢?链表虽然是我们这个业务场景最主要的数据结构,但并不是当前这个问题最好的解决方案,所以我们需要一种能快速访问元素的数据结构来解决这个问题?那就是今天我们要谈一谈的 散列表

散列表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
散列表其实可以约等于我们常说的Key-Value形式。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。为什么要用数组呢?因为数组按照下标来访问元素的时间复杂度为O(1),不明白的同学可以参考菜菜以前的关于数组的文章。既然要按照数组的下标来访问元素,必然也必须考虑怎么样才能把Key转化为下标。这就是接下来要谈一谈的散列函数。

散列函数

散列函数通俗来讲就是把一个Key转化为数组下标的黑盒。散列函数在散列表中起着非常关键的作用。
散列函数,顾名思义,它是一个函数。我们可以把它定义成hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。
那一个散列函数有哪些要求呢?

  1. 散列函数计算得到的值是一个非负整数值。
  2. 如果 key1 = key2,那hash(key1) == hash(key2)
  3. 如果 key1 ≠ key2,那hash(key1) ≠ hash(key2)

简单说一下以上三点,第一点:因为散列值其实就是数组的下标,所以必须是非负整数(>=0),第二点:同一个key计算的散列值必须相同。
重点说一下第三点,其实第三点只是理论上的,我们想象着不同的Key得到的散列值应该不同,但是事实上,这一点很难做到。我们可以反证一下,如果这个公式成立,我计算无限个Key的散列值,那散列表底层的数组必须做到无限大才行。像业界比较著名的MD5、SHA等哈希算法,也无法完全避免这样的冲突。当然如果底层的数组越小,这种冲突的几率就越大。所以一个完美的散列函数其实是不存在的,即便存在,付出的时间成本,人力成本可能超乎想象。

散列冲突

既然再好的散列函数都无法避免散列冲突,那我们就必须寻找其他途径来解决这个问题。

  1. 寻址
    如果遇到冲突的时候怎么办呢?方法之一是在冲突的位置开始找数组中空余的空间,找到空余的空间然后插入。就像你去商店买东西,发现东西卖光了,怎么办呢?找下一家有东西卖的商家买呗。
    不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。

散列表的装载因子 = 填入表中的元素个数 / 散列表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降. 假设散列函数为 f=(key%1000),如下图所示
image

  1. 链地址法(拉链法)
    拉链法属于一种最常用的解决散列值冲突的方式。基本思想是数组的每个元素指向一个链表,当散列值冲突的时候,在链表的末尾增加新元素。查找的时候同理,根据散列值定位到数组位置之后,然后沿着链表查找元素。如果散列函数设计的非常糟糕的话,相同的散列值非常多的话,散列表元素的查找会退化成链表查找,时间复杂度退化成O(n)
    image
  2. 再散列法
    这种方式本质上是计算多次散列值,那就必然需要多个散列函数,在产生冲突时再使用另一个散列函数计算散列值,直到冲突不再发生,这种方法不易产生“聚集”,但增加了计算时间。
  3. 建立一个公共溢出区
    至于这种方案网络上介绍的比较少,一般应用的也比较少。可以这样理解:散列值冲突的元素放到另外的容器中,当然容器的选择有可能是数组,有可能是链表甚至队列都可以。但是无论是什么,想要保证散列表的优点还是需要慎重考虑这个容器的选择。

扩展阅读

  1. 这里需要在强调一次,散列表底层依赖的是数组按照下标访问的特性(时间复杂度为O(1)),而且一般散列表为了避免大量冲突都有装载因子的定义,这就涉及到了数组扩容的特性:需要为新数组开辟空间,并且需要把元素copy到新数组。如果我们知道数据的存储量或者数据的大概存储量,在初始化散列表的时候,可以尽量一次性分配足够大的空间。避免之后的数组扩容弊端。事实证明,在内存比较紧张的时候,优先考虑这种一次性分配的方案也要比其他方案好的多。
  2. 散列表的寻址方案中,有一种特殊情况:如果我寻找到数组的末尾仍然无空闲位置,怎么办呢?这让我想到了循环链表,数组也一样,可以组装一个循环数组。末尾如果无空位,就可以继续在数组首位继续搜索。
  3. 关于散列表元素的删除,我觉得有必要说一说。首先基于拉链方式的散列表由于元素在链表中,所有删除一个元素的时间复杂度和链表是一样的,后续的查找也没有任何问题。但是寻址方式的散列表就不同了,我们假设一下把位置N元素删除,那N之后相同散列值的元素就搜索不出来了,因为N位置已经是空位置了。散列表的搜索方式决定了空位置之后的元素就断片了….这也是为什么基于拉链方式的散列表更常用的原因之一吧。
  4. 在工业级的散列函数中,元素的散列值做到尽量平均分布是其中的要求之一,这不仅仅是为了空间的充分利用,也是为了防止大量的hashCode落在同一个位置,设想在拉链方式的极端情况下,查找一个元素的时间复杂度退化成在链表中查找元素的时间复杂度O(n),这就导致了散列表最大特性的丢失。
  5. 拉链方式实现的链表中,其实我更倾向于使用双向链表,这样在删除一个元素的时候,双向链表的优势可以同时发挥出来,这样可以把散列表删除元素的时间复杂度降低为O(1)。
  6. 在散列表中,由于元素的位置是散列函数来决定的,所有遍历一个散列表的时候,元素的顺序并非是添加元素先后的顺序,这一点需要我们在具体业务应用中要注意。

Net Core c# 代码

有几个地方菜菜需要在强调一下:

  1. 在当前项目中用的分布式框架为基于Actor模型的Orleans,所以我每个用户的访问记录不必担心多线程问题。
  2. 我没用使用hashtable这个数据容器,是因为hashtable太容易发生装箱拆箱的问题。
  3. 使用双向链表是因为查找到了当前元素,相当于也查找到了上个元素和下个元素,当前元素的删除操作时间复杂度可以为O(1)

用户访问记录的实体

1
2
3
4
5
6
7
8
9
csharp复制代码 class UserViewInfo
{
//用户ID
public int UserId { get; set; }
//访问时间,utc时间戳
public int Time { get; set; }
//用户姓名
public string UserName { get; set; }
}

用户空间添加访问记录的代码

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
csharp复制代码class UserSpace
{
//缓存的最大数量
const int CacheLimit = 1000;
//这里用双向链表来缓存用户空间的访问记录
LinkedList<UserViewInfo> cacheUserViewInfo = new LinkedList<UserViewInfo>();
//这里用哈希表的变种Dictionary来存储访问记录,实现快速访问,同时设置容量大于缓存的数量限制,减小哈希冲突
Dictionary<int, UserViewInfo> dicUserView = new Dictionary<int, UserViewInfo>(1250);

//添加用户的访问记录
public void AddUserView(UserViewInfo uv)
{
//首先查找缓存列表中是否存在,利用hashtable来实现快速查找
if (dicUserView.TryGetValue(uv.UserId, out UserViewInfo currentUserView))
{
//如果存在,则把该用户访问记录从缓存当前位置移除,添加到头位置
cacheUserViewInfo.Remove(currentUserView);
cacheUserViewInfo.AddFirst(currentUserView);
}
else
{
//如果不存在,则添加到缓存头部 并添加到哈希表中
cacheUserViewInfo.AddFirst(uv);
dicUserView.Add(uv.UserId, uv);
}
//这里每次都判断一下缓存是否超过限制
if (cacheUserViewInfo.Count > CacheLimit)
{
//移除缓存最后一个元素,并从hashtable中删除,理论上来说,dictionary的内部会两个指针指向首元素和尾元素,所以查找这两个元素的时间复杂度为O(1)
var lastItem = cacheUserViewInfo.Last.Value;
dicUserView.Remove(lastItem.UserId);
cacheUserViewInfo.RemoveLast();
}
}
}

更多精彩文章

  • 分布式大并发系列
  • 架构设计系列
  • 趣学算法和数据结构系列
  • 设计模式系列

本文转载自: 掘金

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

基于注解实现的策略模式,步骤简单,通俗易懂!

发表于 2020-11-16

背景

在项目开发的过程中,我们经常会遇到如下的一种场景:对于用户的请求需要根据不同的情况进行不同的处理。

  • 最简单粗暴的一种处理方式是使用switch…case或者if…else。但是这样处理方式只适用于处理逻辑简单或者情况分类较少的情况,如学校发放校服,男同学发放男士衣服,女同学发放女士衣服。
  • 但是,如果处理逻辑毕竟复杂,或者情况分类较多,甚至未来有可能增加情况分类,上一种处理方式就会显得力不从心。此时使用策略模式将会是一种更优的处理方式。

基础配置&步骤

以下的方案是基于注解实现的策略模式。基础步骤&配置如下:

  • 定义策略名称:该项使用枚举实现
  • 定义策略名称注解:使用注解进行定义
  • 定义策略行为接口:该接口定义了策略行为
  • 定义策略处理器:包含策略名称的注解,并实现了策略行为接口
  • 定义策略容器:此处使用map作为策略容器,key为策略名称注解,value为策略处理器的实例
  • 初始化策略:容器初始化时候,从容器中读取包含策略名称注解的实例,并将其放入到策略容器中。

代码实现

在以下的例子中,会针对用户请求的Msg进行解析,msgType有两种:一种是聊天消息ChatMsg,还有一种是系统消息SysMsg。实现方式如下所示:

定义策略名称

1
2
3
4
5
arduino复制代码public enum MsgType {

CHAT_MSG,
SYS_MSG;
}

定义策略名称注解

1
2
3
4
5
6
7
less复制代码@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface Type {
MsgType value();
}

定义策略行为接口

1
2
3
arduino复制代码public interface BaseMsgHandler {
void handleMsg(String content);
}

定义策略处理器

1
2
3
4
5
6
7
8
9
less复制代码@Component
@Type(value = MsgType.CHAT_MSG)
public class ChatMsgHandler implements BaseMsgHandler{

@Override
public void handleMsg(String msg) {
System.out.println("This is chatMsg. Detail msg information is :" + msg);
}
}
1
2
3
4
5
6
7
8
9
less复制代码@Component
@Type(value = MsgType.SYS_MSG)
public class SysMsgHandler implements BaseMsgHandler{

@Override
public void handleMsg(String msg) {
System.out.println("This is sysMsg. Detail msg information is :" + msg);
}
}

定义策略容器

1
swift复制代码public static final Map<Type, BaseMsgHandler> msgStrategyMap = new HashMap<>();

初始化策略

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码@Component
public class MsgConfig implements ApplicationContextAware {

public static final Map<Type, BaseMsgHandler> msgStrategyMap = new HashMap<>();

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
applicationContext.getBeansWithAnnotation(Type.class).entrySet().iterator().forEachRemaining(entrySet ->{
msgStrategyMap.put(entrySet.getKey().getClass().getAnnotation(Type.class),
(BaseMsgHandler) entrySet.getValue());
});
}
}

上述准备动作完成后,就可以编写调用代码了:

1
2
3
4
5
6
7
kotlin复制代码import lombok.Data;

@Data
public class Msg{
private String content;
private MsgType msgType;
}
1
2
3
4
5
6
7
8
9
10
less复制代码@RestController
@RequestMapping("/")
public class MsgController {

@RequestMapping("msg")
public void handleMsg(@RequestBody Msg msg){
BaseMsgHandler handler = MsgConfig.msgStrategyMap.get(msg.getMsgType());
handler.handleMsg(msg.getContent());
}
}

最后

欢迎关注公众号:前程有光,领取一线大厂Java面试题总结+各知识点学习思维导+一份300页pdf文档的Java核心知识点总结!

本文转载自: 掘金

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

InnoDB架构,一幅图秒懂!

发表于 2020-11-16

网上写 MySQL 架构的文章比较多,写 InnoDB 架构的文章比较少,今天简单说说 InnoDB 架构。

_画外音:_一分钟,一幅图,秒懂。

MySQL 简要架构是怎么样的?

MySQL 整体分为三层:

(1)客户端,是各种编程语言的 connector;

(2)MySQL 服务,内部包含各种组件,实现各种功能;

(3)文件系统,数据存储与日志;

其中,MySQL 服务内,可以以插件的形式,实现各种存储引擎。

在 InnoDB,MyISAM,Memory… 等各种存储引擎中,InnoDB 是使用范围最广的。

_画外音:_事务,行锁,聚集索引,MVCC… 众多特性让 InnoDB 备受青睐。

InnoDB 简要架构是怎么样的?

InnoDB 整体也分为三层:

(1)内存结构 (In-Memory Structure),这一层在 MySQL 服务进程内;

(2)OS Cache,这一层属于内核态内存;

(3)磁盘结构 (On-Disk Structure),这一层在文件系统上;

这三层的交互有两类:

(1)通过 OS Cache 落地数据(上图中,两个短箭头);

(2)直接 O_Direct 落地数据(长途中,长箭头);

_画外音:_这是一个性能与一致性折衷的设计。

InnoDB 内存结构包含哪些核心组件?

InnoDB 内存结构包含四大核心组件,分别是:

(1)缓冲池 (Buffer Pool);

(2)写缓冲 (Change Buffer);

(3)自适应哈希索引 (Adaptive Hash Index);

(4)日志缓冲 (Log Buffer);

恰好,这四大核心组件,今年都详细的写过。

缓冲池 (Buffer Pool)

目的是提升 InnoDB 性能,加速读请求,避免每次数据访问都进行磁盘 IO。

_画外音:_和系统架构设计中缓存的功能有点像,避免每次访问数据库。

这里面涉及的技术点包括:预读,局部性原理,LRU,预读失败 + 缓冲池污染,新生代老生代双链 LRU… 细节参见《缓冲池 (buffer pool),彻底懂了!》。

写缓冲 (Change Buffer)

目的是提升 InnoDB 性能,加速写请求,避免每次写入都进行磁盘 IO。

_画外音:_我 C,这个牛逼,写入居然都可以不进行磁盘 IO?

细节参见《写缓冲 (change buffer),彻底懂了!》,这篇文章的细节原理,特别有意思。

自适应哈希索引 (Adaptive Hash Index)

目的是提升 InnoDB 性能,加速读请求,减少索引查询的寻路路径。

这里面涉及的技术点包括:聚集索引,普通索引,哈希索引… 细节参见《InnoDB 到底支不支持哈希索引》。

日志缓冲 (Log Buffer)

目的是提升 InnoDB 性能,极大优化 redo 日志性能,并提供了高并发与强一致性的折衷方案。

这里面涉及的技术点包括:redo log 作用,流程,三层架构,随机写优化为顺序写,次次写优化为批量写… 细节参见《事务已提交,数据却丢了,赶紧检查下 LogBuffer》。

InnoDB 磁盘结构包含哪些核心组件?

主要包括日志与表空间,其结构与原理比 InnoDB 内存结构更加复杂,如果大家感兴趣,未来再撰文详述。

一分钟系列,希望大家对 InnoDB 架构,以及 InnoDB 内存结构的四大组件有了更系统性的了解。

知其然,知其所以然,希望大家有收获。

相关文章:

《缓冲池 (buffer pool),彻底懂了!》

《写缓冲 (change buffer),彻底懂了!》

《InnoDB 到底支不支持哈希索引?》

《事务已提交,数据却丢了,赶紧检查下 LogBuffer》

末了,昨天有个朋友留言 “谢谢”,说面试过程中,用从“架构师之路” 里学到的知识,完美回答了面试官提出的一个 MySQL 底层细节问题,顺利拿到 offer,涨薪 20%。后来他和面试官一对,原来面试官也是看了架构师之路才学会的这个 MySQL 知识点,才问了相关的问题。

为啥说这个事?

我希望大家从 “架构师之路” 学到东西,职业生涯往上走,这是我的初衷。如果大家把学到的新知识,拿来出题为难面试者,这不是我想看到的,这样对没有订阅 “架构师之路” 的朋友不公平。

我承诺,不把从架构师之路中学到的新知识,作为面试候选人的面试题!

谢谢大家的支持!

本文转载自: 掘金

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

大厂都是怎么SQL调优的?

发表于 2020-11-16

有情怀,有干货,微信搜索【三太子敖丙】关注这个不一样的程序员。

本文 GitHub github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

前言

这天我正在午休呢,公司DBA就把我喊醒了,说某库出现大量慢SQL,很快啊,很快,我还没反应过来,库就挂了,我心想现在的用户不讲武德啊,怎么在我睡觉的时候大量请求呢。

这是很常见的一个场景哈,因为很多业务开始数据量级不大,所以写sql的时候就没注意性能,等量级上去,很多业务就需要做调优了,在电商公司工作的这几年我也总结了不少,下面就分享给大家吧。

在代码开发过程中,我们都会遵循一些SQL开发规范去编写高质量SQL,来提高接口的Response Time(RT),对一些核心接口要求RT在100ms以内甚至更低。

由于业务前期数据量比较小,基本都能满足这个要求,但随着业务量的增长,数据量也随之增加,对应接口的SQL耗时也在变长,直接影响了用户的体验,这时候就需要对SQL进行优化。

优化点主要包括SQL规范性检查,表结构索引检查,SQL优化案例分析,下面从这三方面结合实际案例聊聊如何优化SQL。

SQL规范性检查

每个公司都有自己的MySQL开发规范,基本上大同小异,这里罗列一些比较重要的,我工作期间经常接触的给大家。

select检查

UDF用户自定义函数

SQL语句的select后面使用了自定义函数UDF,SQL返回多少行,那么UDF函数就会被调用多少次,这是非常影响性能的。

1
2
mysql复制代码#getOrderNo是用户自定义一个函数用户来根据order_sn来获取订单编号
select id, payment_id, order_sn, getOrderNo(order_sn) from payment_transaction where status = 1 and create_time between '2020-10-01 10:00:00' and '2020-10-02 10:00:00';

text类型检查

如果select出现text类型的字段,就会消耗大量的网络和IO带宽,由于返回的内容过大超过max_allowed_packet设置会导致程序报错,需要评估谨慎使用。

1
2
mysql复制代码#表request_log的中content是text类型。
select user_id, content, status, url, type from request_log where user_id = 32121;

group_concat谨慎使用

gorup_concat是一个字符串聚合函数,会影响SQL的响应时间,如果返回的值过大超过了max_allowed_packet设置会导致程序报错。

1
mysql复制代码select batch_id, group_concat(name) from buffer_batch where status = 0 and create_time between '2020-10-01 10:00:00' and '2020-10-02 10:00:00';

内联子查询

在select后面有子查询的情况称为内联子查询,SQL返回多少行,子查询就需要执行过多少次,严重影响SQL性能。

1
mysql复制代码select id,(select rule_name from member_rule limit 1) as rule_name, member_id, member_type, member_name, status  from member_info m where status = 1 and create_time between '2020-09-02 10:00:00' and '2020-10-01 10:00:00';

from检查

表的链接方式

在MySQL中不建议使用Left Join,即使ON过滤条件列索引,一些情况也不会走索引,导致大量的数据行被扫描,SQL性能变得很差,同时要清楚ON和Where的区别。

1
2
mysql复制代码SELECT a.member_id,a.create_time,b.active_time FROM operation_log a LEFT JOIN member_info b ON a.member_id = b.member_id where  b.`status` = 1
and a.create_time between '2020-10-01 00:00:00' and '2020-10-30 00:00:00' limit 100, 0;

子查询

由于MySQL的基于成本的优化器CBO对子查询的处理能力比较弱,不建议使用子查询,可以改写成Inner Join。

1
2
mysql复制代码select b.member_id,b.member_type, a.create_time,a.device_model from member_operation_log a inner join (select member_id,member_type from member_base_info where `status` = 1
and create_time between '2020-10-01 00:00:00' and '2020-10-30 00:00:00') as b on a.member_id = b.member_id;

where检查

索引列被运算

当一个字段被索引,同时出现where条件后面,是不能进行任何运算,会导致索引失效。

1
2
3
4
mysql复制代码#device_no列上有索引,由于使用了ltrim函数导致索引失效
select id, name , phone, address, device_no from users where ltrim(device_no) = 'Hfs1212121';
#balance列有索引,由于做了运算导致索引失效
select account_no, balance from accounts where balance + 100 = 10000 and status = 1;

类型转换

对于Int类型的字段,传varchar类型的值是可以走索引,MySQL内部自动做了隐式类型转换;相反对于varchar类型字段传入Int值是无法走索引的,应该做到对应的字段类型传对应的值总是对的。

1
2
3
4
mysql复制代码#user_id是bigint类型,传入varchar值发生了隐式类型转换,可以走索引。
select id, name , phone, address, device_no from users where user_id = '23126';
#card_no是varchar(20),传入int值是无法走索引
select id, name , phone, address, device_no from users where card_no = 2312612121;

列字符集

从MySQL 5.6开始建议所有对象字符集应该使用用utf8mb4,包括MySQL实例字符集,数据库字符集,表字符集,列字符集。避免在关联查询Join时字段字符集不匹配导致索引失效,同时目前只有utf8mb4支持emoji表情存储。

1
2
3
4
mysql复制代码character_set_server  =  utf8mb4    #数据库实例字符集
character_set_connection = utf8mb4 #连接字符集
character_set_database = utf8mb4 #数据库字符集
character_set_results = utf8mb4 #结果集字符集

group by检查

前缀索引

group by后面的列有索引,索引可以消除排序带来的CPU开销,如果是前缀索引,是不能消除排序的。

1
2
3
4
mysql复制代码#device_no字段类型varchar(200),创建了前缀索引。
mysql> alter table users add index idx_device_no(device_no(64));

mysql> select device_no, count(*) from users where create_time between '2020-10-01 00:00:00' and '2020-10-30 00:00:00' group by device_no;

函数运算

假设需要统计某月每天的新增用户量,参考如下SQL语句,虽然可以走create_time的索引,但是不能消除排序,可以考虑冗余一个字段stats_date date类型来解决这种问题。

1
mysql复制代码select DATE_FORMAT(create_time, '%Y-%m-%d'), count(*) from users where create_time between '2020-09-01 00:00:00' and '2020-09-30 23:59:59' group by DATE_FORMAT(create_time, '%Y-%m-%d');

order by检查

前缀索引

order by后面的列有索引,索引可以消除排序带来的CPU开销,如果是前缀索引,是不能消除排序的。

字段顺序

排序字段顺序,asc/desc升降要跟索引保持一致,充分利用索引的有序性来消除排序带来的CPU开销。

limit检查

limit m,n要慎重

对于limit m, n分页查询,越往后面翻页即m越大的情况下SQL的耗时会越来越长,对于这种应该先取出主键id,然后通过主键id跟原表进行Join关联查询。

表结构检查

表&列名关键字

在数据库设计建模阶段,对表名及字段名设置要合理,不能使用MySQL的关键字,如desc, order, status, group等。同时建议设置lower_case_table_names = 1表名不区分大小写。

表存储引擎

对于OLTP业务系统,建议使用InnoDB引擎获取更好的性能,可以通过参数default_storage_engine控制。

AUTO_INCREMENT属性

建表的时候主键id带有AUTO_INCREMENT属性,而且AUTO_INCREMENT=1,在InnoDB内部是通过一个系统全局变量dict_sys.row_id来计数,row_id是一个8字节的bigint unsigned,InnoDB在设计时只给row_id保留了6个字节的长度,这样row_id取值范围就是0到2^48 - 1,如果id的值达到了最大值,下一个值就从0开始继续循环递增,在代码中禁止指定主键id值插入。

1
2
3
4
5
mysql复制代码#新插入的id值会从10001开始,这是不对的,应该从1开始。
create table booking( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id',......) engine = InnoDB auto_increment = 10000;

#指定了id值插入,后续自增就会从该值开始+1,索引禁止指定id值插入。
insert into booking(id, book_sn) values(1234551121, 'N12121');

NOT NULL属性

根据业务含义,尽量将字段都添加上NOT NULL DEFAULT VALUE属性,如果列值存储了大量的NULL,会影响索引的稳定性。

DEFAULT属性

在创建表的时候,建议每个字段尽量都有默认值,禁止DEFAULT NULL,而是对字段类型填充响应的默认值。

COMMENT属性

字段的备注要能明确该字段的作用,尤其是某些表示状态的字段,要显式的写出该字段所有可能的状态数值以及该数值的含义。

TEXT类型

不建议使用Text数据类型,一方面由于传输大量的数据包可能会超过max_allowed_packet设置导致程序报错,另一方面表上的DML操作都会变的很慢,建议采用es或者对象存储OSS来存储和检索。

索引检查

索引属性

索引基数指的是被索引的列唯一值的个数,唯一值越多接近表的count(*)说明索引的选择率越高,通过索引扫描的行数就越少,性能就越高,例如主键id的选择率是100%,在MySQL中尽量所有的update都使用主键id去更新,因为id是聚集索引存储着整行数据,不需要回表,性能是最高的。

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
mysql复制代码mysql> select count(*) from member_info;
+----------+
| count(*) |
+----------+
| 148416 |
+----------+
1 row in set (0.35 sec)

mysql> show index from member_base_info;
+------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
+------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| member_info | 0 | PRIMARY | 1 | id | A | 131088 | NULL | NULL | | BTREE | | |
| member_info | 0 | uk_member_id | 1 | member_id | A | 131824 | NULL | NULL | | BTREE | | |
| member_info | 1 | idx_create_time | 1 | create_time | A | 6770 | NULL | NULL | | BTREE | | |
+------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
#Table: 表名
#Non_unique :是否为unique index,0-是,1-否。
#Key_name:索引名称
#Seq_in_index:索引中的顺序号,单列索引-都是1;复合索引-根据索引列的顺序从1开始递增。
#Column_name:索引的列名
#Collation:排序顺序,如果没有指定asc/desc,默认都是升序ASC。
#Cardinality:索引基数-索引列唯一值的个数。
#sub_part:前缀索引的长度;例如index (member_name(10),长度就是10。
#Packed:索引的组织方式,默认是NULL。
#Null:YES:索引列包含Null值;'':索引不包含Null值。
#Index_type:默认是BTREE,其他的值FULLTEXT,HASH,RTREE。
#Comment:在索引列中没有被描述的信息,例如索引被禁用。
#Index_comment:创建索引时的备注。

前缀索引

对于变长字符串类型varchar(m),为了减少key_len,可以考虑创建前缀索引,但是前缀索引不能消除group by, order by带来排序开销。如果字段的实际最大值比m小很多,建议缩小字段长度。

1
mysql复制代码alter table member_info add index idx_member_name_part(member_name(10));

复合索引顺序

有很多人喜欢在创建复合索引的时候,总以为前导列一定是唯一值多的列,例如索引index idx_create_time_status(create_time, status),这个索引往往是无法命中,因为扫描的IO次数太多,总体的cost的比全表扫描还大,CBO最终的选择是走full table scan。

MySQL遵循的是索引最左匹配原则,对于复合索引,从左到右依次扫描索引列,到遇到第一个范围查询(>=, >,<, <=, between ….. and ….)就停止扫描,索引正确的索引顺序应该是index idx_status_create_time(status, create_time)。

1
mysql复制代码select account_no, balance from accounts where status = 1 and create_time between '2020-09-01 00:00:00' and '2020-09-30 23:59:59';

时间列索引

对于默认字段created_at(create_time)、updated_at(update_time)这种默认就应该创建索引,这一般来说是默认的规则。

SQL优化案例

通过对慢查询的监控告警,经常发现一些SQL语句where过滤字段都有索引,但是由于SQL写法的问题导致索引失效,下面二个案例告诉大家如何通过SQL改写来查询。可以通过以下SQL来捞取最近5分钟的慢查询进行告警。

1
mysql复制代码select CONCAT( '# Time: ', DATE_FORMAT(start_time, '%y%m%d %H%i%s'), '\n', '# User@Host: ', user_host, '\n', '# Query_time: ', TIME_TO_SEC(query_time),  '  Lock_time: ', TIME_TO_SEC(lock_time), '  Rows_sent: ', rows_sent, '  Rows_examined: ', rows_examined, '\n', sql_text, ';' ) FROM mysql.slow_log where start_time between current_timestamp and date_add(CURRENT_TIMESTAMP,INTERVAL -5 MINUTE);

慢查询SQL

1
mysql复制代码| 2020-10-02 19:17:23 | w_mini_user[w_mini_user] @  [10.200.20.11] | 00:00:02   | 00:00:00  |         9 |        443117 | mini_user |              0 |         0 | 168387936 | select id,club_id,reason,status,type,created_time,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and app_id is not null) or (invite_id=12395 or applicant_id=12395) order by created_time desc limit 0,10; | 1219921665 |

从慢查询slow_log可以看到,执行时间2s,扫描了443117行,只返回了9行,这是不合理的。

SQL分析

1
2
3
4
5
6
7
8
9
10
mysql复制代码#原始SQL,频繁访问的接口,目前执行时间2s。
select id,team_id,reason,status,type,created_time,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and app_id is not null) or (invite_id=12395 or app_id=12395) order by created_time desc limit 0,10;

#执行计划
+----+-------------+--------------+-------+---------------------------------+------------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------------+-------+---------------------------------+------------+---------+------+------+-------------+
| 1 | SIMPLE | t_user_msg | index | invite_id,app_id,team_id | created_time | 5 | NULL | 10 | Using where |
+----+-------------+--------------+-------+---------------------------------+------------+---------+------+------+-------------+
1 row in set (0.00 sec)

从执行计划可以看到,表上有单列索引invite_id,app_id,team_id,created_time,走的是create_time的索引,而且type=index索引全扫描,因为create_time没有出现在where条件后,只出现在order by后面,只能是type=index,这也预示着表数据量越大该SQL越慢,我们期望是走三个单列索引invite_id,app_id,team_id,然后type=index_merge操作。

按照常规思路,对于OR条件拆分两部分,分别进行分析。

1
sql复制代码select id, ……. from t_user_msg where 1 and  **(team_id in (3212) and app_id is not null)** order by created_time desc limit 0,10;

从执行计划看走的是team_id的索引,没有问题。

1
2
3
mysql复制代码| id | select_type | table        | type | possible_keys        | key     | key_len | ref   | rows | Extra                       |
+----+-------------+--------------+------+----------------------+---------+---------+-------+------+-----------------------------+
| 1 | SIMPLE | t_user_msg | ref | app_id,team_id | team_id | 8 | const | 30 | Using where; Using filesort |

再看另外一个sql语句:

1
sql复制代码select id, ……. from t_user_msg where 1 and  **(invite_id=12395 or app_id=12395)** order by created_time desc limit 0,10;

从执行计划上看,分别走的是invite_id,app_id的单列索引,同时做了index_merge合并操作,也没有问题。

1
2
3
mysql复制代码| id | select_type | table        | type        | possible_keys           | key                     | key_len | ref  | rows | Extra                                                             |
+----+-------------+--------------+-------------+-------------------------+-------------------------+---------+------+------+-------------------------------------------------------------------+
| 1 | SIMPLE | t_user_msg | index_merge | invite_id,app_id | invite_id,app_id | 9,9 | NULL | 2 | Using union(invite_id,app_id); Using where; Using filesort |

通过上面的分析,第一部分SQL走的执行计划走team_id索引没问题,第二部分SQL分别走invite_id,app_id索引并且index_merge也没问题,为什么两部分SQL进行OR关联之后走create_time的单列索引呢,不应该是三个单列索引的index_merge吗?

index_merge默认是在优化器选项是开启的,主要是将多个范围扫描的结果集合并成一个,可以通过变量查看。

1
2
mysql复制代码mysql >select @@optimizer_switch;
| index_merge=on,index_merge_union=on,index_merge_sort_union=on,index_merge_intersection=on,

其他三个字段都传入的是具体的值,而且都走了相应的索引,只能怀疑app_id is not null这个条件影响了CBO对最终执行计划的选择,去掉这个条件来看执行计划,竟然走了三个单列索引且type=index_merge,那下面只要搞定app_id is not null这个条件就OK了吧。

1
2
3
mysql复制代码| id | select_type | table        | type        | possible_keys                   | key                             | key_len | ref  | rows | Extra                                                                     |
+----+-------------+--------------+-------------+---------------------------------+---------------------------------+---------+------+------+---------------------------------------------------------------------------+
| 1 | SIMPLE | t_user_msg | index_merge | invite_id,app_id,teadm_id | team_id,invite_id,app_id | 8,9,9 | NULL | 32 | Using union(team_id,invite_id,app_id); Using where; Using filesort |

SQL改写

通过上面分析得知,条件app_id is not null影响了CBO的选择,下面进行改造。

改写优化1

根据SQL开发规范改写,将OR改写成Union All方式即可,最终的SQL如下:

1
2
3
4
5
sql复制代码select id, ……. from (
select id, ……. from t_user_msg where **1 and (club_id in (5821) and applicant_id is not null)**
**union all** select id, ……. from t_user_msg where **1 and invitee_id='146737'**
**union all** select id, ……. from t_user_msg where **1 and app_id='146737'**
) as a order by created_time desc limit 0,10;

一般情况下,Java代码和SQL是分开的,SQL是配置在xml文件中,根据业务需求,除了team_id是必填,其他两个都是可选的,所以这种改写虽然能提高SQL执行效率,但不适合这种业务场景。

改写优化2

app_id is not null 改写为IFNULL(app_id, 0) >0),最终的SQL为:

1
sql复制代码select id,team_id,reason,status,type,created_time,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and **IFNULL(app_id, 0) >0)**) or (invite_id=12395 or app_id=12395) order by created_time desc limit 0,10;

改写优化3

将字段app_id bigint(20) DEFAULT NULL,变更为app_id bigint(20) NOT NULL DEFAULT 0,同时更新将app_id is null的时候全部更新成0,就可以将条件app_id is not null 转换为app_id > 0,最终的SQL为:

1
sql复制代码select id,team_id,reason,status,type,created_at,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and **app_id > 0)**) or (invite_id=12395 or app_id=12395) order by created_time desc limit 0,10;

从执行计划看,两种改写优化方式都走三个单列索引,执行时间从2s降低至10ms,线上采用的是优化1的方式,如果一开始能遵循MySQL开发规范就就会避免问题的发生。

总结

上面介绍了SQL规范性检查,表结构检查,索引检查以及通过SQL改写来优化查询,在编写代码的过程,如果能提前做这些规范性检查,评估出自己认为理想的执行计划,然后通过explain解析出MySQL CBO的执行计划,两者做对比分析差异,弄清楚自己的选择和CBO的不同,不但能够编写高质量的SQL,同时也能清楚CBO的工作原理。

文章持续更新,可以微信搜一搜「 三太子敖丙 」第一时间阅读,回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHub github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。

本文转载自: 掘金

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

走进 Python 类的内部 属性的存储 继承与属性查找 函

发表于 2020-11-15

本文首发于 at7h 的个人博客

这篇文章和大家一起聊一聊 Python 3.8 中类和对象背后的一些概念和实现原理,主要尝试解释 Python 类和对象属性的存储,函数和方法,描述器,对象内存占用的优化支持,以及继承与属性查找等相关问题。

让我们从一个简单的例子开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
python复制代码class Employee:

outsource = False

def __init__(self, department, name):
self.department = department
self.name = name

@property
def inservice(self):
return self.department is not None

def __repr__(self):
return f"<Employee: {self.department}-{self.name}>"

employee = Employee('IT', 'bobo')

employee 对象是 Employee 类的一个实例,它有两个属性 department 和 name,其值属于该实例。outsource 是类属性,所有者是类,该类的所有实例对象共享此属性值,这跟其他面向对象语言一致。

更改类变量会影响到该类的所有实例对象:

1
2
3
4
5
6
7
python复制代码>>> e1 = Employee('IT', 'bobo')
>>> e2 = Employee('HR', 'cici')
>>> e1.outsource, e2.outsource
(False, False)
>>> Employee.outsource = True
>>> e1.outsource, e2.outsource
>>> (True, True)

这仅限于从类更改,当我们从实例更改类变量时:

1
2
3
4
5
6
7
python复制代码>>> e1 = Employee('IT', 'bobo')
>>> e2 = Employee('HR', 'cici')
>>> e1.outsource, e2.outsource
(False, False)
>>> e1.outsource = True
>>> e1.outsource, e2.outsource
(True, False)

是的,当你试图从实例对象修改类变量时,Python 不会更改该类的类变量值,而是创建一个同名的实例属性,这是非常正确且安全的。在搜索属性值时,实例变量会优先于类变量,这将在继承与属性查找一节中详细解释。

值得特别注意的是,当类变量的类型是可变类型时,你是从实例对象中更改的它们的:

1
2
3
4
5
6
7
8
9
python复制代码>>> class S:
... L = [1, 2]
...
>>> s1, s2 = S(), S()
>>> s1.L, s2.L
([1, 2], [1, 2])
>>> t1.L.append(3)
>>> t1.L, s2.L
([1, 2, 3], [1, 2, 3])

好的实践方式是应当尽量的避免这样的设计。

属性的存储

本小节我们一起来看看 Python 中的类属性、方法及实例属性是如何关联存储的。

实例属性

在 Python 中,所有实例属性都存储在 __dict__ 字典中,这就是一个常规的 dict,对于实例属性的维护即是从该字典中获取和修改,它对开发者是完全开放的。

1
2
3
4
5
6
7
8
9
10
python复制代码>>> e = Employee('IT', 'bobo')
>>> e.__dict__
{'department': 'IT', 'name': 'bobo'}
>>> type(e.__dict__)
dict
>>> e.name is e.__dict__['name']
True
>>> e.__dict__['department'] = 'HR'
>>> e.department
'HR'

正因为实例属性是采用字典来存储,所以任何时候我们都可以方便的给对象添加或删除字段:

1
2
3
4
5
6
7
8
py复制代码>>> e.age = 30 # 并没有定义 age 属性
>>> e.age
30
>>> e.__dict__
{'department': 'IT', 'name': 'bobo', 'age': 30}
>>> del e.age
>>> e.__dict__
{'department': 'IT', 'name': 'd'}

我们也可以从字典中实例化一个对象,或者通过保存实例的 __dict__ 来恢复实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
python复制代码>>> def new_employee_from(d):
... instance = object.__new__(Employee)
... instance.__dict__.update(d)
... return instance
...
>>> e1 = new_employee_from({'department': 'IT', 'name': 'bobo'})
>>> e1
<Employee: IT-bobo>
>>> state = e1.__dict__.copy()
>>> del e1
>>> e2 = new_employee_from(state)
>>> e2
>>> <Employee: IT-bobo>

因为 __dict__ 的完全开放,所以我们可以向其中添加任何 immutable 类型的 key,比如数字:

1
2
3
python复制代码>>> e.__dict__[1] = 1
>>> e.__dict__
{'department': 'IT', 'name': 'bobo', 1: 1}

这些非字符串的字段是我们无法通过实例对象访问的,为了确保不会出现这样的情况,除非必要的情况下,一般最好不要直接对 __dict__ 进行写操作,甚至不要直接操作 __dict__。

所以有一种说法是 Python is a “consenting adults language”。

这种动态的实现使得我们的代码非常灵活,很多时候非常的便利,但这也付出了存储和性能上的开销。所以 Python 也提供了另外一种机制(__slots__)来放弃使用 __dict__,以节约内存,提高性能,详见 __slots__ 一节。

类属性

同样的,类属性也在存储在类的 __dict__ 字典中:

1
2
3
4
5
6
7
8
9
10
11
12
13
python复制代码>>> Employee.__dict__
mappingproxy({'__module__': '__main__',
'outsource': True,
'__init__': <function __main__.Employee.__init__(self, department, name)>,
'inservice': <property at 0x108419ea0>,
'__repr__': <function __main__.Employee.__repr__(self)>,
'__str__': <function __main__.Employee.__str__(self)>,
'__dict__': <attribute '__dict__' of 'Employee' objects>,
'__weakref__': <attribute '__weakref__' of 'Employee' objects>,
'__doc__': None}

>>> type(Employee.__dict__)
mappingproxy

与实例字典的『开放』不同,类属性使用的字典是一个 MappingProxyType 对象,它是一个不能 setattr 的字典。这意味着它对开发者是只读的,其目的正是为了保证类属性的键都是字符串,以简化和加快新型类属性的查找和 __mro__ 的搜索逻辑。

1
2
python复制代码>>> Employee.__dict__['outsource'] = False
TypeError: 'mappingproxy' object does not support item assignment

因为所有的方法都归属于一个类,所以它们也存储在类的字典中,从上面的例子中可以看到已有的 __init__ 和 __repr__ 方法。我们可以再添加几个来验证:

1
2
3
4
5
6
7
8
9
10
11
12
python复制代码class Employee:
# ...
@staticmethod
def soo():
pass

@classmethod
def coo(cls):
pass

def foo(self):
pass
1
2
3
4
5
6
7
8
9
10
11
12
python复制代码>>> Employee.__dict__
mappingproxy({'__module__': '__main__',
'outsource': False,
'__init__': <function __main__.Employee.__init__(self, department, name)>,
'__repr__': <function __main__.Employee.__repr__(self)>,
'inservice': <property at 0x108419ea0>,
'soo': <staticmethod at 0x1066ce588>,
'coo': <classmethod at 0x1066ce828>,
'foo': <function __main__.Employee.foo(self)>,
'__dict__': <attribute '__dict__' of 'Employee' objects>,
'__weakref__': <attribute '__weakref__' of 'Employee' objects>,
'__doc__': None})

继承与属性查找

目前为止,我们已经知道,所有的属性和方法都存储在两个 __dict__ 字典中,现在我们来看看 Python 是如何进行属性查找的。

Python 3 中,所有类都隐式的继承自 object,所以总会有一个继承关系,而且 Python 是支持多继承的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python复制代码>>> class A:
... pass
...
>>> class B:
... pass
...
>>> class C(B):
... pass
...
>>> class D(A, C):
... pass
...
>>> D.mro()
[<class '__main__.D'>, <class '__main__.A'>, <class '__main__.C'>, <class '__main__.B'>, <class 'object'>]

mro() 是一个特殊的方法,它返回类的线性解析顺序。

属性访问的默认行为是从对象的字典中获取、设置或删除属性,例如对于 e.f 的查找简单描述是:

e.f 的查找顺序会从 e.__dict__['f'] 开始,然后是 type(e).__dict__['f'],接下来依次查找 type(e) 的基类(__mro__ 顺序,不包括元类)。 如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重载默认行为并转而发起调用描述器方法。这具体发生在优先级链的哪个环节则要根据所定义的描述器方法及其被调用的方式来决定。

所以,要理解查找的顺序,你必须要先了解描述器协议。

简单总结,有两种描述器类型:数据描述器和和非数据描述器。

如果一个对象除了定义 __get__() 之外还定义了 __set__() 或 __delete__(),则它会被视为数据描述器。仅定义了 __get__() 的描述器称为非数据描述器(它们通常被用于方法,但也可以有其他用途)

由于函数只实现 __get__,所以它们是非数据描述器。

Python 的对象属性查找顺序如下:

  1. 类和父类字典的数据描述器
  2. 实例字典
  3. 类和父类字典中的非数据描述器

请记住,无论你的类有多少个继承级别,该类对象的实例字典总是存储了所有的实例变量,这也是 super 的意义之一。

下面我们尝试用伪代码来描述查找顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
python复制代码def get_attribute(obj, name):
class_definition = obj.__class__

descriptor = None
for cls in class_definition.mro():
if name in cls.__dict__:
descriptor = cls.__dict__[name]
break

if hasattr(descriptor, '__set__'):
return descriptor, 'data descriptor'

if name in obj.__dict__:
return obj.__dict__[name], 'instance attribute'

if descriptor is not None:
return descriptor, 'non-data descriptor'
else:
raise AttributeError
1
2
3
4
5
6
7
8
9
10
11
12
python复制代码>>> e = Employee('IT', 'bobo')
>>> get_attribute(e, 'outsource')
(False, 'non-data descriptor')
>>> e.outsource = True
>>> get_attribute(e, 'outsource')
(True, 'instance attribute')
>>> get_attribute(e, 'name')
('bobo', 'instance attribute')
>>> get_attribute(e, 'inservice')
(<property at 0x10c966d10>, 'data descriptor')
>>> get_attribute(e, 'foo')
(<function __main__.Employee.foo(self)>, 'non-data descriptor')

由于这样的优先级顺序,所以实例是不能重载类的数据描述器属性的,比如 property 属性:

1
2
3
4
5
6
7
python复制代码>>> class Manager(Employee):
... def __init__(self, *arg):
... self.inservice = True
... super().__init__(*arg)
...
>>> m = Manager("HR", "cici")
AttributeError: can't set attribute

发起描述器调用

上面讲到,在查找属性时,如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重载默认行为并转而发起描述器方法调用。

描述器的作用就是绑定对象属性,我们假设 a 是一个实现了描述器协议的对象,对 e.a 发起描述器调用有以下几种情况:

  • 直接调用:用户级的代码直接调用e.__get__(a),不常用
  • 实例绑定:绑定到一个实例,e.a 会被转换为调用: type(e).__dict__['a'].__get__(e, type(e))
  • 类绑定:绑定到一个类,E.a 会被转换为调用: E.__dict__['a'].__get__(None, E)

在继承关系中进行绑定时,会根据以上情况和 __mro__ 顺序来发起链式调用。

函数与方法

我们知道方法是属于特定类的函数,唯一的不同(如果可以算是不同的话)是方法的第一个参数往往是为类或实例对象保留的,在 Python 中,我们约定为 cls 或 self, 当然你也可以取任何名字如 this(只是最好不要这样做)。

上一节我们知道,函数实现了 __get__() 方法的对象,所以它们是非数据描述器。在 Python 访问(调用)方法支持中正是通过调用 __get__() 将调用的函数绑定成方法的。

在纯 Python 中,它的工作方式如下(示例来自描述器使用指南):

1
2
3
4
5
python复制代码class Function:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return types.MethodType(self, obj) # 将函数绑定为方法

在 Python 2 中,有两种方法: unbound method 和 bound method,在 Python 3 中只有后者。

bound method 与它们绑定的类或实例数据相关联:

1
2
3
4
5
6
7
python复制代码>>> Employee.coo
<bound method Employee.coo of <class '__main__.Employee'>>
>>> Employee.foo
<function __main__.Employee.foo(self)>
>>> e = Employee('IT', 'bobo')
>>> e.foo
<bound method Employee.foo of <Employee: IT-bobo>>

我们可以从方法来访问实例与类:

1
2
3
4
python复制代码>>> e.foo.__self__
<Employee: IT-bobo>
>>> e.foo.__self__.__class__
__main__.Employee

借助描述符协议,我们可以在类的外部作用域手动绑定一个函数到方法,以访问类或实例中的数据,我将以这个示例来解释当你的对象访问(调用)类字典中存储的函数时将其绑定成方法(执行)的过程:

现有以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
python复制代码>>> def f1(self):
... if isinstance(self, type):
... return self.outsource
... return self.name
...
>>> bound_f1 = f1.__get__(e, Employee) # or bound_f1 = f1.__get__(e)
>>> bound_f1
<bound method f1 of <Employee: IT-bobo>>
>>> bound_f1.__self__
<Employee: IT-bobo>
>>> bound_f1()
'bobo'

总结一下:当我们调用 e.foo() 时,首先从 Employee.__dict__['foo'] 中得到 foo 函数,在调用该函数的 foo 方法 foo.__get__(e) 将其转换成方法,然后执行 foo() 获得结果。这就完成了 e.foo() -> f(e) 的过程。

如果你对我的解释感到疑惑,我建议你可以阅读官方的描述器使用指南以进一步了解描述器协议,在该文的函数和方法和静态方法和类方法一节中详细了解函数绑定为方法的过程。同时在 Python 类一文的方法对象一节中也有相关的解释。

__slots__

Python 的对象属性值都是采用字典存储的,当我们处理数成千上万甚至更多的实例时,内存消耗可能是一个问题,因为字典哈希表的实现,总是为每个实例创建了大量的内存。所以 Python 提供了一种 __slots__ 的方式来禁用实例使用 __dict__,以优化此问题。

通过 __slots__ 来指定属性后,会将属性的存储从实例的 __dict__ 改为类的 __dict__ 中:

1
2
3
4
5
6
python复制代码class Test:
__slots__ = ('a', 'b')

def __init__(self, a, b):
self.a = a
self.b = b
1
2
3
4
5
6
7
8
9
10
python复制代码>>> t = Test(1, 2)
>>> t.__dict__
AttributeError: 'Test' object has no attribute '__dict__'
>>> Test.__dict__
mappingproxy({'__module__': '__main__',
'__slots__': ('a', 'b'),
'__init__': <function __main__.Test.__init__(self, a, b)>,
'a': <member 'a' of 'Test' objects>,
'b': <member 'b' of 'Test' objects>,
'__doc__': None})

关于 __slots__ 我之前专门写过一篇文章分享过,感兴趣的同学请移步理解 Python 类属性 __slots__ 一文。

补充

__getattribute__ 和 __getattr__

也许你还有疑问,那函数的 __get__ 方法是怎么被调用的呢,这中间过程是什么样的?

在 Python 中 一切皆对象,所有对象都有一个默认的方法 __getattribute__(self, name)。

该方法会在我们使用 . 访问 obj 的属性时会自动调用,为了防止递归调用,它总是实现为从基类 object 中获取 object.__getattribute__(self, name), 该方法大部分情况下会默认从 self 的 __dict__ 字典中查找 name(除了特殊方法的查找)。

话外:如果该类还实现了 __getattr__,则只有 __getattribute__ 显式地调用或是引发了 AttributeError 异常后才会被调用。__getattr__ 由开发者自己实现,应当返回属性值或引发 AttributeError 异常。

而描述器正是由 __getattribute__() 方法调用,其大致逻辑为:

1
2
3
4
5
python复制代码def __getattribute__(self, key):
v = object.__getattribute__(self, key)
if hasattr(v, '__get__'):
return v.__get__(self)
return v

请注意:重写 __getattribute__() 会阻止描述器的自动调用。

函数属性

函数也是 Python function 对象,所以一样,它也具有任意属性,这有时候是有用的,比如实现一个简单的函数调用跟踪装饰器:

1
2
3
4
5
6
7
8
9
10
11
python复制代码def calltracker(func):
@wraps(func)
def wrapper(*args, **kwargs):
wrapper.calls += 1
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper

@calltracker
def f():
return 'f called'
1
2
3
4
5
6
python复制代码>>> f.calls
0
>>> f()
'f called'
>>> f.calls
1

参考

  • HowFunctionsToMethods
  • Descriptor HowTo Guide
  • Python Data model
  • Understanding internals of Python classes

同名公众号:

本文转载自: 掘金

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

限流10万QPS、跨域、过滤器、令牌桶算法-网关Gatewa

发表于 2020-11-15

一、微服务网关Spring Cloud Gateway

1.1 导引

文中内容包含:微服务网关限流10万QPS、跨域、过滤器、令牌桶算法。

在构建微服务系统中,必不可少的技术就是网关了,从早期的Zuul,到现在的Spring Cloud Gateway,网关我们用的不可少。

今天我就将沉淀下来的所有与网关相关的知识,用一篇文章总结清楚,希望对爱学习的小伙伴们有所帮助。

本篇文章主要介绍网关跨域配置,网关过滤器编写,网关的令牌桶算法限流【每秒10万QPS】

首先我们来看什么是网关

1.2 什么是微服务网关Gateway?

This project provides a library for building an API Gateway on top of Spring WebFlux.

gateway官网:spring.io/projects/sp…

实现微服务网关的技术有很多,

  • nginx Nginx (engine x) 是一个高性能的HTTP和反向代理web服务器,同时也提供了IMAP/POP3/SMTP服务
  • zuul ,Zuul 是 Netflix 出品的一个基于 JVM 路由和服务端的负载均衡器。
  • spring-cloud-gateway, 是spring 出品的 基于spring 的网关项目,集成断路器,路径重写,性能比Zuul好。

我们使用gateway这个网关技术,无缝衔接到基于spring cloud的微服务开发中来。

1.3 微服务为什么要使用网关呢?

不同的微服务一般会有不同的网络地址,而外部客户端可能需要调用多个服务的接口才能完成一个业务需求,如果让客户端直接与各个微服务通信,会有以下的问题:

  1. 客户端会多次请求不同的微服务,增加了客户端的复杂性
  2. 存在跨域请求,在一定场景下处理相对复杂
  3. 认证复杂,每个服务都需要独立认证
  4. 难以重构,随着项目的迭代,可能需要重新划分微服务。例如,可能将多个服务合并成一个或者将一个服务拆分成多个。如果客户端直接与微服务通信,那么重构将会很难实施
  5. 某些微服务可能使用了防火墙 / 浏览器不友好的协议,直接访问会有一定的困难

以上这些问题可以借助网关解决。

网关是介于客户端和服务器端之间的中间层,所有的外部请求都会先经过 网关这一层。也就是说,API 的实现方面更多的考虑业务逻辑,而安全、性能、监控可以交由 网关来做,这样既提高业务灵活性又不缺安全性,典型的架构图如图所示:

1.4 微服务网关优点

  • 安全 ,只有网关系统对外进行暴露,微服务可以隐藏在内网,通过防火墙保护。
  • 易于监控。可以在网关收集监控数据并将其推送到外部系统进行分析。
  • 易于认证。可以在网关上进行认证,然后再将请求转发到后端的微服务,而无须在每个微服务中进行认证。
  • 减少了客户端与各个微服务之间的交互次数
  • 易于统一授权。

1.5 总结

微服务网关就是一个系统,通过暴露该微服务网关系统,方便我们进行相关的鉴权,安全控制,日志统一处理,易于监控的相关功能。

二、微服务网关搭建及配置

2.1 微服务网关微服务搭建

​ 由于我们开发的系统 有包括前台系统和后台系统,后台的系统给管理员使用。那么也需要调用各种微服务,所以我们针对管理后台搭建一个网关微服务。分析如下:

搭建步骤:

(1)依赖坐标pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
xml复制代码<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

(2)启动引导类:GatewayApplication

1
2
3
4
5
6
7
java复制代码@SpringBootApplication
@EnableEurekaClient
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

(3)在resources下创建application.yml

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
yaml复制代码spring:
application:
name: apigateway
cloud:
gateway:
routes:
- id: open
uri: lb://open
predicates:
- Path=/open/**
filters:
- StripPrefix= 1
- id: system
uri: lb://system
predicates:
- Path=/system/**
filters:
- StripPrefix= 1
server:
port: 9999
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
prefer-ip-address: true

参考官方手册:

cloud.spring.io/spring-clou…

2.2 微服务网关跨域

在启动类GatewayApplication中,加入跨域配置代码如下

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Bean
public CorsWebFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedMethod("*");//支持所有方法
config.addAllowedOrigin("*");//跨域处理 允许所有的域
config.addAllowedHeader("*");//支持所有请求头

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
source.registerCorsConfiguration("/**", config);//匹配所有请求

return new CorsWebFilter(source);
}

三、微服务网关过滤器

我们可以通过网关过滤器,实现一些逻辑的处理,比如ip黑白名单拦截、特定地址的拦截等。下面的代码中做了两个过滤器,并且设定的先后顺序。

(1)在网关微服务中创建IpFilter,无需配置其他,注册到Spring容器即可生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Component
public class IpFilter implements GlobalFilter, Ordered {

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
InetSocketAddress remoteAddress = request.getRemoteAddress();
//TODO 设置ip白名单
System.out.println("ip:"+remoteAddress.getHostName());
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 1;
}
}

(2)在网关微服务中创建UrlFilter,无需配置其他,注册到Spring容器即可生效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Component
public class UrlFilter implements GlobalFilter, Ordered {

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String url = request.getURI().getPath();
//TODO 拦截特定URL地址
System.out.println("url:"+url);
return chain.filter(exchange);
}

@Override
public int getOrder() {
return 2;
}
}

四、网关限流每秒10万请求

​ 我们之前说过,网关可以做很多的事情,比如,限流,当我们的系统 被频繁的请求的时候,就有可能 将系统压垮,所以 为了解决这个问题,需要在每一个微服务中做限流操作,但是如果有了网关,那么就可以在网关系统做限流,因为所有的请求都需要先通过网关系统才能路由到微服务中。

4.1 限流实现思路分析

看图就完了,非常简单!

4.2 令牌桶算法 介绍

令牌桶算法是比较常见的限流算法之一,大概描述如下:

1)所有的请求在处理之前都需要拿到一个可用的令牌才会被处理;
2)根据限流大小,设置按照一定的速率往桶里添加令牌;
3)桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃或者拒绝;
4)请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除;
5)令牌桶有最低限额,当桶中的令牌达到最低限额的时候,请求处理完之后将不会删除令牌,以此保证足够的限流

如下图:

这个算法的实现,有很多技术,Guava(读音: 瓜哇)是其中之一,redis客户端也有其实现。

4.3 网关限流代码实现

需求:每个ip地址1秒内只能发送10万请求,多出来的请求返回429错误。

代码实现:

(1)spring cloud gateway 默认使用redis的RateLimter限流算法来实现。所以我们要使用首先需要引入redis的依赖
1
2
3
4
5
6
xml复制代码<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
(2)定义KeyResolver

在GatewayApplicatioin引导类中添加如下代码,KeyResolver用于计算某一个类型的限流的KEY也就是说,可以通过KeyResolver来指定限流的Key。

1
2
3
4
5
6
7
8
9
10
java复制代码    //定义一个KeyResolver
@Bean
public KeyResolver ipKeyResolver() {
return new KeyResolver() {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
return Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
}
};
}
(3)修改application.yml中配置项,指定限制流量的配置以及REDIS的配置,修改后最终配置如下:
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
yaml复制代码spring:
application:
name: apigateway
cloud:
gateway:
routes:
- id: open
uri: lb://open
predicates:
- Path=/open/**
filters:
- StripPrefix= 1
- name: RequestRateLimiter #请求数限流 名字不能随便写
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 1
- id: system
uri: lb://system
predicates:
- Path=/system/**
filters:
- StripPrefix= 1
# 配置Redis 127.0.0.1可以省略配置
redis:
host: 101.57.2.128
port: 6379
server:
port: 9999
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:100/eureka
instance:
prefer-ip-address: true

解释:

  • burstCapacity:令牌桶总容量。
  • replenishRate:令牌桶每秒填充平均速率。
  • key-resolver:用于限流的键的解析器的 Bean 对象的名字。它使用 SpEL 表达式根据#{@beanName}从 Spring 容器中获取 Bean 对象。

通过在replenishRate和中设置相同的值来实现稳定的速率burstCapacity。设置burstCapacity高于时,可以允许临时突发replenishRate。在这种情况下,需要在突发之间允许速率限制器一段时间(根据replenishRate),因为2次连续突发将导致请求被丢弃(HTTP 429 - Too Many Requests)

key-resolver: “#{@userKeyResolver}” 用于通过SPEL表达式来指定使用哪一个KeyResolver.

如上配置:

表示 一秒内,允许 一个请求通过,令牌桶的填充速率也是一秒钟添加一个令牌。

最大突发状况 也只允许 一秒内有一次请求,可以根据业务来调整 。

(4)测试时需要注意服务启动顺序,这里需要依赖于Redis,所以首先要启动redis
  • 启动redis
  • 启动注册中心
  • 启动商品微服务
  • 启动gateway网关
  • 打开浏览器 http://localhost:9999/open
  • 快速刷新,当1秒内发送超过10万次请求,就会返回429错误。

那么问题来了:怎么发送10万次请求呢? 知道的同学,请在留言区评论。不知道下次我写篇压测来个百万QPS!

本文转载自: 掘金

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

自定义注解的魅力你到底懂不懂

发表于 2020-11-15

前言

你知道自定义注解的魅力所在吗?

你知道自定义注解该怎么使用吗?

本文一开始的这两个问题,需要您仔细思考下,然后结合这两个问题来阅读下面的内容;如果您在阅读完文章后对这两个问题有了比较清晰的,请动动您发财的小手,点赞留言呀!

本文主线:

  • 注解是什么;
  • 实现一个自定义注解;
  • 自定义注解的实战应用场景;

注意:本文在介绍自定义注解实战应用场景时,需要结合拦截器、AOP进行使用,所以本文也会简单聊下AOP相关知识点,如果对于AOP的相关内容不太清楚的可以参考此 细说Spring——AOP详解 文章进行了解。

注解

注解是什么?

①、引用自维基百科的内容:

Java注解又称Java标注,是JDK5.0版本开始支持加入源代码的特殊语法 元数据 。

Java语言中的类、方法、变量、参数和包等都可以被标注。和Javadoc不同,Java标注可以通过反射获取标注内容。在编译器生成类文件时,标注可以被嵌入到字节码中。Java虚拟机可以保留标注内容,在运行时可以获取到标注内容。 当然它也支持自定义Java标注。

②、引用自网络的内容:

Java 注解是在 JDK5 时引入的新特性,注解(也被称为 元数据 )为我们在代码中添加信息提供了一种形式化的方法,使我们可以在稍后某个时刻非常方便地使用这些数据。

元注解是什么?

元注解 的作用就是负责注解其他注解。Java5.0定义了4个标准的meta-annotation(元注解)类型,它们被用来提供对其它 annotation类型作说明。

标准的元注解:
  • @Target
  • @Retention
  • @Documented
  • @Inherited

在详细说这四个元数据的含义之前,先来看一个在工作中会经常使用到的 @Autowired 注解,进入这个注解里面瞧瞧: 此注解中使用到了@Target、@Retention、@Documented 这三个元注解 。

1
2
3
4
5
6
java复制代码@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Autowired {
boolean required() default true;
}
@Target元注解:

@Target注解,是专门用来限定某个自定义注解能够被应用在哪些Java元素上面的,标明作用范围;取值在java.lang.annotation.ElementType 进行定义的。

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
java复制代码public enum ElementType {
/** 类,接口(包括注解类型)或枚举的声明 */
TYPE,

/** 属性的声明 */
FIELD,

/** 方法的声明 */
METHOD,

/** 方法形式参数声明 */
PARAMETER,

/** 构造方法的声明 */
CONSTRUCTOR,

/** 局部变量声明 */
LOCAL_VARIABLE,

/** 注解类型声明 */
ANNOTATION_TYPE,

/** 包的声明 */
PACKAGE
}

根据此处可以知道 @Autowired 注解的作用范围:

1
2
java复制代码// 可以作用在 构造方法、方法、方法形参、属性、注解类型 上
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention元注解:

@Retention注解,翻译为持久力、保持力。即用来修饰自定义注解的生命周期。

注解的生命周期有三个阶段:

  • Java源文件阶段;
  • 编译到class文件阶段;
  • 运行期阶段;

同样使用了RetentionPolicy 枚举类型对这三个阶段进行了定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public enum RetentionPolicy {
/**
* Annotations are to be discarded by the compiler.
* (注解将被编译器忽略掉)
*/
SOURCE,

/**
* Annotations are to be recorded in the class file by the compiler
* but need not be retained by the VM at run time. This is the default
* behavior.
* (注解将被编译器记录在class文件中,但在运行时不会被虚拟机保留,这是一个默认的行为)
*/
CLASS,

/**
* Annotations are to be recorded in the class file by the compiler and
* retained by the VM at run time, so they may be read reflectively.
* (注解将被编译器记录在class文件中,而且在运行时会被虚拟机保留,因此它们能通过反射被读取到)
* @see java.lang.reflect.AnnotatedElement
*/
RUNTIME
}

再详细描述下这三个阶段:

①、如果被定义为 RetentionPolicy.SOURCE,则它将被限定在Java源文件中,那么这个注解即不会参与编译也不会在运行期起任何作用,这个注解就和一个注释是一样的效果,只能被阅读Java文件的人看到;

②、如果被定义为 RetentionPolicy.CLASS,则它将被编译到Class文件中,那么编译器可以在编译时根据注解做一些处理动作,但是运行时JVM(Java虚拟机)会忽略它,并且在运行期也不能读取到;

③、如果被定义为 RetentionPolicy.RUNTIME,那么这个注解可以在运行期的加载阶段被加载到Class对象中。那么在程序运行阶段,可以通过反射得到这个注解,并通过判断是否有这个注解或这个注解中属性的值,从而执行不同的程序代码段。

注意:实际开发中的自定义注解几乎都是使用的 RetentionPolicy.RUNTIME 。

@Documented元注解:

@Documented注解,是被用来指定自定义注解是否能随着被定义的java文件生成到JavaDoc文档当中。

@Inherited元注解:

@Inherited注解,是指定某个自定义注解如果写在了父类的声明部分,那么子类的声明部分也能自动拥有该注解。

@Inherited注解只对那些@Target被定义为 ElementType.TYPE 的自定义注解起作用。

自定义注解实现:

在了解了上面的内容后,我们来尝试实现一个自定义注解:

根据上面自定义注解中使用到的元注解得知:

①、此注解的作用范围,可以使用在类(接口、枚举)、方法上;

②、此注解的生命周期,被编译器保存在class文件中,而且在运行时会被JVM保留,可以通过反射读取;

自定义注解的简单使用:

上面已经创建了一个自定义的注解,那该怎么使用呢?下面首先描述下它简单的用法,后面将会使用其结合拦截器和AOP切面编程进行实战应用;

应用场景实现

在了解了上面注解的知识后,我们乘胜追击,看看它的实际应用场景是肿么样的,以此加深下我们的理解;

实现的 Demo 项目是以 SpringBoot 实现的,项目工程结构图如下:

场景一:自定义注解 + 拦截器 = 实现接口响应的包装

使用自定义注解 结合 拦截器 优雅的实现对API接口响应的包装。

在介绍自定义实现的方式之前,先简单介绍下普遍的实现方式,通过两者的对比,才能更加明显的发现谁最优雅。

普通的接口响应包装方式:

现在项目绝大部分都采用的前后端分离方式,所以需要前端和后端通过接口进行交互;目前在接口交互中使用最多的数据格式是 json,然后后端返回给前端的最为常见的响应格式如下:

1
2
3
4
5
6
7
8
json复制代码{
#返回状态码
code:integer,
#返回信息描述
message:string,
#返回数据值
data:object
}

项目中经常使用枚举类定义状态码和消息,代码如下:

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
java复制代码/**
* @author 【 木子雷 】 公众号
* @Title: ResponseCode
* @Description: 使用枚举类封装好的响应状态码及对应的响应消息
* @date: 2019年8月23日 下午7:12:50
*/
public enum ResponseCode {

SUCCESS(1200, "请求成功"),

ERROR(1400, "请求失败");


private Integer code;

private String message;

private ResponseCode(Integer code, String message) {
this.code = code;
this.message = message;
}

public Integer code() {
return this.code;
}

public String message() {
return this.message;
}

}

同时项目中也会设计一个返回响应包装类,代码如下:

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
java复制代码import com.alibaba.fastjson.JSONObject;
import java.io.Serializable;

/**
* @author 【 木子雷 】 公众号
* @Title: Response
* @Description: 封装的统一的响应返回类
* @date: 2019年8月23日 下午7:07:13
*/
@SuppressWarnings("serial")
public class Response<T> implements Serializable {

/**
* 响应数据
*/
private T date;

/**
* 响应状态码
*/
private Integer code;

/**
* 响应描述信息
*/
private String message;

public Response(T date, Integer code, String message) {
super();
this.date = date;
this.code = code;
this.message = message;
}


public T getDate() {
return date;
}

public void setDate(T date) {
this.date = date;
}

public Integer getCode() {
return code;
}

public void setCode(Integer code) {
this.code = code;
}

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}


@Override
public String toString() {
return JSONObject.toJSONString(this);
}
}

最后就是使用响应包装类和状态码枚举类 来实现返回响应的包装了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@GetMapping("/user/findAllUser")
public Response<List<User>> findAllUser() {
logger.info("开始查询所有数据...");

List<User> findAllUser = new ArrayList<>();
findAllUser.add(new User("木子雷", 26));
findAllUser.add(new User("公众号", 28));

// 返回响应进行包装
Response response = new Response(findAllUser, ResponseCode.SUCCESS.code(), ResponseCode.SUCCESS.message());

logger.info("response: {} \n", response.toString());
return response;
}

在浏览器中输入网址: http://127.0.0.1:8080/v1/api/user/findAllUser 然后点击回车,得到如下数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
json复制代码{
"code": 1200,
"date": [
{
"age": 26,
"name": "木子雷"
},
{
"age": 28,
"name": "公众号"
}
],
"message": "请求成功"
}

通过看这中实现响应包装的方式,我们能发现什么问题吗?

答:代码很冗余,需要在每个接口方法中都进行响应的包装;使得接口方法包含了很多非业务逻辑代码;

有没有一些方法进行优化下呢? en en 思考中。。。。。 啊,自定义注解 + 拦截器可以实现呀!

自定义注解实现接口响应包装:

①、首先创建一个进行响应包装的自定义注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码/**
* @author 【 木子雷 】 公众号
* @PACKAGE_NAME: com.lyl.annotation
* @ClassName: ResponseResult
* @Description: 标记方法返回值需要进行包装的 自定义注解
* @Date: 2020-11-10 10:38
**/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ResponseResult {

}

②、创建一个拦截器,实现对请求的拦截,看看请求的方法或类上是否使用了自定义的注解:

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
java复制代码/**
* @author 【 木子雷 】 公众号
* @PACKAGE_NAME: com.lyl.interceptor
* @ClassName: ResponseResultInterceptor
* @Description: 拦截器:拦截请求,判断请求的方法或类上是否使用了自定义的@ResponseResult注解,
* 并在请求内设置是否使用了自定义注解的标志位属性;
* @Date: 2020-11-10 10:50
**/
@Component
public class ResponseResultInterceptor implements HandlerInterceptor {

/**
* 标记位,标记请求的controller类或方法上使用了到了自定义注解,返回数据需要被包装
*/
public static final String RESPONSE_ANNOTATION = "RESPONSE_ANNOTATION";

/**
* 请求预处理,判断是否使用了自定义注解
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// 请求的接口方法
if (handler instanceof HandlerMethod) {
final HandlerMethod handlerMethod = (HandlerMethod) handler;
final Class<?> clazz = handlerMethod.getBeanType();
final Method method = handlerMethod.getMethod();
// 判断是否在类对象上加了注解
if (clazz.isAnnotationPresent(ResponseResult.class)) {
// 在请求中设置需要进行响应包装的属性标志,在下面的ResponseBodyAdvice增强中进行处理
request.setAttribute(RESPONSE_ANNOTATION, clazz.getAnnotation(ResponseResult.class));
} else if (method.isAnnotationPresent(ResponseResult.class)) {
// 在请求中设置需要进行响应包装的属性标志,在下面的ResponseBodyAdvice增强中进行处理
request.setAttribute(RESPONSE_ANNOTATION, method.getAnnotation(ResponseResult.class));
}
}
return true;
}
}

③、创建一个增强Controller,实现对返回响应进行包装的增强处理:

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
java复制代码/**
* @author 【 木子雷 】 公众号
* @PACKAGE_NAME: com.lyl.interceptor
* @ClassName: ResponseResultHandler
* @Description: 对 返回响应 进行包装 的增强处理
* @Date: 2020-11-10 13:49
**/
@ControllerAdvice
public class ResponseResultHandler implements ResponseBodyAdvice<Object> {

private final Logger logger = LoggerFactory.getLogger(this.getClass());

/**
* 标记位,标记请求的controller类或方法上使用了到了自定义注解,返回数据需要被包装
*/
public static final String RESPONSE_ANNOTATION = "RESPONSE_ANNOTATION";

/**
* 请求中是否包含了 响应需要被包装的标记,如果没有,则直接返回,不需要重写返回体
*
* @param methodParameter
* @param aClass
* @return
*/
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
ServletRequestAttributes ra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest sr = (HttpServletRequest) ra.getRequest();
// 查询是否需要进行响应包装的标志
ResponseResult responseResult = (ResponseResult) sr.getAttribute(RESPONSE_ANNOTATION);
return responseResult == null ? false : true;
}


/**
* 对 响应体 进行包装; 除此之外还可以对响应体进行统一的加密、签名等
*
* @param responseBody 请求的接口方法执行后得到返回值(返回响应)
*/
@Override
public Object beforeBodyWrite(Object responseBody, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
logger.info("返回响应 包装进行中。。。");
Response response;
// boolean类型时判断一些数据库新增、更新、删除的操作是否成功
if (responseBody instanceof Boolean) {
if ((Boolean) responseBody) {
response = new Response(responseBody, ResponseCode.SUCCESS.code(), ResponseCode.SUCCESS.message());
} else {
response = new Response(responseBody, ResponseCode.ERROR.code(), ResponseCode.ERROR.message());
}
} else {
// 判断像查询一些返回数据的情况,查询不到数据返回 null;
if (null != responseBody) {
response = new Response(responseBody, ResponseCode.SUCCESS.code(), ResponseCode.SUCCESS.message());
} else {
response = new Response(responseBody, ResponseCode.ERROR.code(), ResponseCode.ERROR.message());
}
}
return response;
}
}

④、最后在 Controller 中使用上我们的自定义注解;在 Controller 类上或者 方法上使用@ResponseResult自定义注解即可; 在浏览器中输入网址: http://127.0.0.1:8080/v1/api/user/findAllUserByAnnotation 进行查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码// 自定义注解用在了方法上
@ResponseResult
@GetMapping("/user/findAllUserByAnnotation")
public List<User> findAllUserByAnnotation() {
logger.info("开始查询所有数据...");

List<User> findAllUser = new ArrayList<>();
findAllUser.add(new User("木子雷", 26));
findAllUser.add(new User("公众号", 28));

logger.info("使用 @ResponseResult 自定义注解进行响应的包装,使controller代码更加简介");
return findAllUser;
}

至此我们的接口返回响应包装自定义注解实现设计完成,看看代码是不是又简洁,又优雅呢。

总结:本文针对此方案只是进行了简单的实现,如果有兴趣的朋友可以进行更好的优化。

场景二:自定义注解 + AOP = 实现优雅的使用分布式锁

分布式锁的最常见的使用流程:

先看看最为常见的分布式锁使用方式的实现,然后再聊聊自定义注解怎么优雅的实现分布式锁的使用。

普通的分布式锁使用方式:

通过上面的代码可以得到一个信息:如果有很多方法中需要使用分布式锁,那么每个方法中都必须有获取分布式锁和释放分布式锁的代码,这样一来就会出现代码冗余;

那有什么好的解决方案吗? 自定义注解使代码变得更加简洁、优雅;

自定义注解优雅的使用分布式锁:

①、首先实现一个标记分布式锁使用的自定义注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码/**
* @author 【 木子雷 】 公众号
* @PACKAGE_NAME: com.lyl.annotation
* @ClassName: GetDistributedLock
* @Description: 获取redis分布式锁 注解
* @Date: 2020-11-10 16:24
**/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface GetDistributedLock {

// 分布式锁 key
String lockKey();

// 分布式锁 value,默认为 lockValue
String lockValue() default "lockValue";

// 过期时间,默认为 300秒
int expireTime() default 300;

}

②、定义一个切面,在切面中对使用了 @GetDistributedLock 自定义注解的方法进行环绕增强通知:

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
java复制代码/**
* @author: 【 木子雷 】 公众号
* @PACKAGE_NAME: com.lyl.aop
* @ClassName: DistributedLockAspect
* @Description: 自定义注解结合AOP切面编程优雅的使用分布式锁
* @Date: 2020-11-10 16:52
**/
@Component
@Aspect
public class DistributedLockAspect {

private final Logger logger = LoggerFactory.getLogger(this.getClass());

@Autowired
RedisService redisService;


/**
* Around 环绕增强通知
*
* @param joinPoint 连接点,所有方法都属于连接点;但是当某些方法上使用了@GetDistributedLock自定义注解时,
* 则其将连接点变为了切点;然后在切点上织入额外的增强处理;切点和其相应的增强处理构成了切面Aspect 。
*/
@Around(value = "@annotation(com.lyl.annotation.GetDistributedLock)")
public Boolean handlerDistributedLock(ProceedingJoinPoint joinPoint) {
// 通过反射获取自定义注解对象
GetDistributedLock getDistributedLock = ((MethodSignature) joinPoint.getSignature())
.getMethod().getAnnotation(GetDistributedLock.class);

// 获取自定义注解对象中的属性值
String lockKey = getDistributedLock.lockKey();
String LockValue = getDistributedLock.lockValue();
int expireTime = getDistributedLock.expireTime();

if (redisService.tryGetDistributedLock(lockKey, LockValue, expireTime)) {
// 获取分布式锁成功后,继续执行业务逻辑
try {
return (boolean) joinPoint.proceed();
} catch (Throwable throwable) {
logger.error("业务逻辑执行失败。", throwable);
} finally {
// 最终保证分布式锁的释放
redisService.releaseDistributedLock(lockKey, LockValue);
}
}
return false;
}

}

③、最后,在 Controller 中的方法上使用 @GetDistributedLock 自定义注解即可;当某个方法上使用了 自定义注解,那么这个方法就相当于一个切点,那么就会对这个方法做环绕(方法执行前和方法执行后)增强处理;

在浏览器中输入网址: http://127.0.0.1:8080/v1/api/user/getDistributedLock 回车后触发方法执行:

1
2
3
4
5
6
7
8
9
java复制代码// 自定义注解的使用
@GetDistributedLock(lockKey = "userLock")
@GetMapping("/user/getDistributedLock")
public boolean getUserDistributedLock() {
logger.info("获取分布式锁...");
// 写具体的业务逻辑

return true;
}

通过自定义注解的方式,可以看到代码变得更加简洁、优雅。

场景三:自定义注解 + AOP = 实现日志的打印

先看看最为常见的日志打印的方式,然后再聊聊自定义注解怎么优雅的实现日志的打印。

普通日志的打印方式:

通过看上面的代码可以知道,如果每个方法都需要打印下日志,那将会存在大量的冗余代码;

自定义注解实现日志打印:

①、首先创建一个标记日志打印的自定义注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码/**
* @Author: 【 木子雷 】 公众号
* @PACKAGE_NAME: com.lyl.annotation
* @ClassName: PrintLog
* @Description: 自定义注解实现日志打印
* @Date: 2020-11-10 18:05
**/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PrintLog {

}

②、定义一个切面,在切面中对使用了 @PrintLog 自定义注解的方法进行环绕增强通知:

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
java复制代码/**
* @author: 【 木子雷 】 公众号
* @PACKAGE_NAME: com.lyl.aop
* @ClassName: PrintLogAspect
* @Description: 自定义注解结合AOP切面编程优雅的实现日志打印
* @Date: 2020-11-10 18:11
**/
@Component
@Aspect
public class PrintLogAspect {

private final Logger logger = LoggerFactory.getLogger(this.getClass());

/**
* Around 环绕增强通知
*
* @param joinPoint 连接点,所有方法都属于连接点;但是当某些方法上使用了@PrintLog自定义注解时,
* 则其将连接点变为了切点;然后在切点上织入额外的增强处理;切点和其相应的增强处理构成了切面Aspect 。
*/
@Around(value = "@annotation(com.lyl.annotation.PrintLog)")
public Object handlerPrintLog(ProceedingJoinPoint joinPoint) {
// 获取方法的名称
String methodName = joinPoint.getSignature().getName();
// 获取方法入参
Object[] param = joinPoint.getArgs();

StringBuilder sb = new StringBuilder();
for (Object o : param) {
sb.append(o + "; ");
}
logger.info("进入《{}》方法, 参数为: {}", methodName, sb.toString());

Object object = null;
// 继续执行方法
try {
object = joinPoint.proceed();

} catch (Throwable throwable) {
logger.error("打印日志处理error。。", throwable);
}
logger.info("{} 方法执行结束。。", methodName);
return object;
}

}

③、最后,在 Controller 中的方法上使用 @PrintLog 自定义注解即可;当某个方法上使用了 自定义注解,那么这个方法就相当于一个切点,那么就会对这个方法做环绕(方法执行前和方法执行后)增强处理;

1
2
3
4
5
6
7
java复制代码@PrintLog
@GetMapping(value = "/user/findUserNameById/{id}", produces = "application/json;charset=utf-8")
public String findUserNameById(@PathVariable("id") int id) {
// 模拟根据id查询用户名
String userName = "木子雷 公众号";
return userName;
}

④、在浏览器中输入网址: http://127.0.0.1:8080/v1/api/user/findUserNameById/66 回车后触发方法执行,发现控制台打印了日志:

1
2
java复制代码进入《findUserNameById》方法, 参数为: 66; 
findUserNameById 方法执行结束。。

使用自定义注解实现是多优雅,代码看起来简介干净,越瞅越喜欢;赶快去你的项目中使用吧, 嘿嘿。。。

end 。。。 自定义注解介绍到这本文也就结束了,期待我们的下次见面。

最后,想问下文章开头的那两个问题大家心里是不是已经有了答案呢!嘿嘿。。

♡ 关注 + 点赞 + 收藏 + 评论 哟

如果本文对您有帮助的话,请挥动下您爱发财的小手点下赞呀,您的支持就是我不断创作的动力;谢谢!

如果想要 Demo 源码的话,请您 VX搜索【木子雷】公众号,回复 “ 注解 ” 获取; 再次感谢您阅读本文!

参考资料

①、自定义注解详细介绍

②、Java 自定义注解及使用场景

③、想自己写框架?不会写Java注解可不行

④、看看人家那后端 API 接口写得,那叫一个优雅!

本文转载自: 掘金

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

支付系统的核心,账务系统都需要有哪些功能 账务系统的学习总结

发表于 2020-11-14

账务系统的学习总结

在支付公司,总是涉及到钱,不管是出金还是入金,出金在产品形态上是代付的概念,具体的场景是用户提现余额到银行卡,或者薪资代发等.入金在产品形态上就是快捷支付,协议扣款,具体的场景比如付打车款.这是针对的具体的业务层面,而对支付公司来讲,如何把这些出入的资金管理起来,保证备付金账户的钱出入无误,商户的钱结算准确,就需要一整套系统来管理维护,账户系统就是来实现资金管理的重要部分.

记账的理论

账户系统的核心就是记账(记账就是业务发生后,将其记录在账户中,为了更好的掌握资金的来源与去向),既然是记账,那必然设计到复式记账(有借有贷,借贷相等)还有单式记账(单边账),两种方式都是可以实现记账,在系统实现时两种方式各有利弊

先说一下两种记账方式的方法

单式记账法:

  1. 记账手续简便
  2. 只反映经历业务的一个侧面,不能全面系统的反映经济业务.

复试记账法:

  1. 对经历业务进行相互联系的双重登记,能够反映经济活动全貌.
  2. 能够根据会计等式的平衡关系,检查账户记录的正确性.

复试记账是更加科学的一种记账方式.

复式记账法账户系统的基本原理:借贷平衡,有进有出。

账户系统恒等式:

  1. 资产类账户余额 = 负债类账户余额 + 公共类账户余额。
  2. 资产类账户余额 = 信用账户 + 借款账户(用户欠款合计) = 应收账款合计
  3. 负债类账户余额 = 现金类账户余额 = 预收 + 应付账款合计
  4. 公共类账户 = 渠道账户 + 收益类账户 + 优惠券账户等。(一般不和用户相关都设置成公共账户,余额可为负,非公共账户余额不可为负)。

系统设计和实现

系统分为三个模块,分别是

  1. 账户核心,用于实时的记账操作,记账的操作由业务驱动账务完成业务记账.
  2. 对账模块,内部对账,这是后续账账核对和账实核对的基础,也是对外对账的基础。
  3. 日终核对,进行平衡检查,保证账务系统数据的一致与准确,生成总账。

在系统设计的时候,为了系统的性能会进行系统模块的拆分,拆分为 支付记账和会计记账.

支付记账

支付记账是在支付流程中完成的,目的是让用户完成购买后,能够立即看到支付结果和账户余额。为了提升性能,支付记账一般采用单边账的形式,即将会计分录登记在用户侧或者商户侧。

会计记账

会计记账采用复式记账,不同业务记账方式也不一样。

表设计

账户核心设计(日间)

image-20201114162351748

账户信息表

记录账户的余额.

分为:用户账户,商户账户,内部分户,贷款分户.

重点字段:

账户号 OID_ACCTNO NUMBER(15)
记账类型 TYPE_ACCTLOG VARCHAR2(1)
资金余额 AMT_BALCUR NUMBER(20)
可用余额 AMT_BALAVAL NUMBER(20)
冻结金额 AMT_BALFRZ NUMBER(20)
上期资金余额 AMT_LASTBAL NUMBER(20)
上期可用余额 AMT_LASTAVAL NUMBER(20)
上期冻结余额 AMT_LASTFRZ NUMBER(20)
开户时间 DATE_OPEN DATE
余额变动账务日期 DATE_ACCT_LAST VARCHAR2(8)
客户ID USER_NO VARCHAR2(32)

账户明细表

记录每个账户对应余额的变化

重点字段:

表名 TA_ACCT_BAL_BILL
字段中文名 字段名 字段类型
资金流水号 JNO_ACCT VARCHAR2(20)
账务日期 DATE_ACCT VARCHAR2(8)
记账类型 TYPE_ACCTLOG VARCHAR2(1)
交易状态 STAT_TRANS VARCHAR2(1)
账户号 OID_ACCTNO VARCHAR2(15)
交易单号 OID_BILLNO VARCHAR2(16)
交易单日期时间 DT_BILLTRANS DATE
交易单类型 TYPE_BILL VARCHAR2(2)
外部订单号 JNO_CLI VARCHAR2(64)
渠道代码 OID_CHNL VARCHAR2(2)
业务类型 OID_BIZ VARCHAR2(6)
流水创建时间 DT_SYS TIMESTAMP
金额 AMOUNT NUMBER(20)
交易后余额 AMT_BAL NUMBER(20)
出帐帐号 DEBIT_ACCT VARCHAR2(15)

账户冻结解冻单据表

为了记录账户冻结余额与订单的关联关系,根据业务场景来确定是否需要.

支付公司里主要针对需要提前预冻结的交易,比如:代付成功前,先把用户账户的钱冻结,成功后在解冻出账.

退款流水表

根据业务场景来确定是否需要本记录表,非必须.

日终核对

image-20201114163553111

账户日终余额表

总账 科目总账,分为日总账和周期总账,日总账每日生成,周期总账月末、季末、半年末、年末生成,记录每个科目的期末余额和本期借、贷发生额。

主要记录登记每日的账户日终余额,用于后续的核对记录.

重点字段:

账期,机构码,科目号

上期借方余额,上期贷方余额

本期借方发生额,本期贷方发生额

期末借方余额,期末贷方余额

待结转金额表

非必须为了实现某些业务场景,比如账户分为在途账户和结算账户(可直接提现).

系统实现功能

日间功能(账户核心实时模块)

业务驱动账务完成业务记账,账务系统根据不同的业务场景,划分不同的交易码,设置分录规则,拆分录、改余额。实现功能

  1. 出入账功能.

入账,出账,出入账.
2. 账户管理.

创建,修改,查询
3. 冻结功能

冻结,入账冻结,出账入账冻结,解冻,解冻出账

内部对账:

是后续账账核对和账实核对的基础,也是对外对账的基础。

  1. 核实账户系统中的账务与支付记录的一致性;
  2. 核实会计系统中的账务与支付记录的一致性。

两个对账任务一般是在后台定时运行(5分钟运行一次),除非系统有bug或者发生故障,内部对账一般不会出现差错。如有差错,也需要人工处理。

日终模块

日终,进行平衡检查,保证账务系统数据的一致与准确,生成总账。日终主要进行如下处理

  1. 生成总账.
  2. 总账平衡检查.

恒等式在系统中的应用

  1. 定时检查账户系统余额是否恒等,检查是否存在单边入账,人工调整余额等不正常现象发生。
  2. 正常业务的异常情况的预警
  3. 内部风控的检查

举例:

一条交易记录 收付款方,只有收款方的余额账户增加了,但是付款方余额账户没有减少,付款方就可与多提现。

内部有权限的的员工更改数据库额余额账户金额,偷偷提现

对账

对账是在结转会计期间的账簿记录前,对账簿和账户所记录的有关数据加以检查和核对,保证账簿记录真实可靠。

在会计上,对账分三个部分:账证核对、账账核对和账实核对,做到账证相符、账账相符和账实相符。

账证核对

是将账簿记录与记账凭证进行核对,这里是记账凭证是指日常经济活动的书面证明。如果还是用记账 APP 来类别的话,我们超市购物的小票、购买商品的发票、银行卡的收款转账记录,都是记账凭证。

对应实际的业务场景:

电商平台: 电商平台自身的交易订单,支付渠道提供对账单。

支付公司: 支付公司的支付订单,与支付渠道(银联,网联)对账单.

账账核对

是把有相互关系的多个账簿记录进行核对。有相互关系的账簿记录,包括总分类账簿间核对,明细账簿间核对等多种类型。例如:我们使用记账 APP 时,整个家庭的日常开销和购物、教育、交通等分类开销的核对,是总分类账簿间核对;核对银行卡取现记录和日常现金支出记录,是明细账簿间核对。

完成渠道对账后,需要进行日结和试算平衡:

  1. 总分类账各账户本期借方发生额合计与贷方余额合计是否相等;
  2. 总分类账各账户借方余额合计与贷方发生额合计是否相符;
  3. 核对各种明细账及现金、银行存款日记账的本期发生额及期末余额同总分类账中有关账户的余额是否相等。

从科目维度,计算:

  • 科目期初余额+科目当日发生额=科目期末余额
  • 下级科目余额总和=上级科目余额(科目总分检查)

账实核对

是各项资产物资的记录数值与实际真实数额间的核对。例如:我们使用记账 APP 记录购买了一台手机,我发现自己手里确实有一台手机,这就是账实核对。

对应到实际的业务场景:

对电商平台来说: 交易数据与支付渠道收款金额、已发货商品数量进行核对,结算数据和付款进行进行核对。

对支付公司来说: 核对支付订单与支付渠道的收款金额是否到账到备付金账户,从网银明细确定金额是否已明确.

验证银行存款的变化和实际资金流向是一致的.如果涉及到账户资金的结转(在途转成现金,允许账户资金可提现),的前提也是实际资金与账户余额一致.

参考文档

  1. 单式记账法和复式记账法的区别
  2. 账务系统-复式记账法总结
  3. 基础向:详细解析「财务对账」的秘密
  4. 支付清结算之账户和账务处理
  5. 互联网支付行业中,清结算相关复式记账的会计科目该如何设计,如何记账?有人做过或者了解相关的设计方案吗?
    如果觉得文章对您有用,可以关注我的公众号 程序和猫 , 更加方便的交流.

本文转载自: 掘金

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

1…766767768…956

开发者博客

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