产品:大哥,你这列表查询有问题啊!
前言
👳♂️产品大哥(怒气冲冲跑过来): “大哥你这查询列表有问题啊,每次点一下查询,返回的数据不一样呢”
👦我:“FKY 之前不是说好的吗,加了排序查询很卡,就取消了”
🧔技术经理:“卡主要是因为分页查询加了排序之后,mybatisPlus
生成的 count
也会有Order by
就 很慢,自己实现一个count
就行了”
👦我:“分页插件在执行统计操作的时候,一般都会对Sql 简单的优化,会去掉排序的”
今天就来看看分页插件处理 count 的时候的优化逻辑,是否能去除order by;
同时 简单阐述一下 order by、limit 的运行原理
往期好文:最近发现一些同事的代码问题
mybatisPlus分页插件count 运行原理
分页插件都是基于MyBatis 的拦截器接口Interceptor
实现,这个就不用多说了。下面看一下分页插件的处理count
的代码,以及优化的逻辑。
详细代码见:
com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor.class
count sql 从获取到执行的主要流程
1.确认count sql
MappedStatement
对象:
先查询Page
对象中 是否有countId
(countId 为mapper sql id),有的话就用自定义的count sql
,没有的话就自己通过查询语句构建一个count MappedStatement
2.优化count sql
:
得到countMs
构建成功之后对count SQL
进行优化,最后 执行count SQL,将结果 set 到page对象中。
public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
IPage<?> page = ParameterUtils.findPage(parameter).orElse(null);
if (page == null || page.getSize() < 0 || !page.searchCount()) {
return true;
}
BoundSql countSql;
// -------------------------------- 根据“countId”获取自定义的count MappedStatement
MappedStatement countMs = buildCountMappedStatement(ms, page.countId());
if (countMs != null) {
countSql = countMs.getBoundSql(parameter);
} else {
//-------------------------------------------根据查询ms 构建统计SQL的MS
countMs = buildAutoCountMappedStatement(ms);
//-------------------------------------------优化count SQL
String countSqlStr = autoCountSql(page, boundSql.getSql());
PluginUtils.MPBoundSql mpBoundSql = PluginUtils.mpBoundSql(boundSql);
countSql = new BoundSql(countMs.getConfiguration(), countSqlStr, mpBoundSql.parameterMappings(), parameter);
PluginUtils.setAdditionalParameter(countSql, mpBoundSql.additionalParameters());
}
CacheKey cacheKey = executor.createCacheKey(countMs, parameter, rowBounds, countSql);
//----------------------------------------------- 统计SQL
List<Object> result = executor.query(countMs, parameter, rowBounds, resultHandler, cacheKey, countSql);
long total = 0;
if (CollectionUtils.isNotEmpty(result)) {
// 个别数据库 count 没数据不会返回 0
Object o = result.get(0);
if (o != null) {
total = Long.parseLong(o.toString());
}
}
// ---------------------------------------set count ret
page.setTotal(total);
return continuePage(page);
}
count SQL 优化逻辑
主要优化的是以下两点:
- 去除 SQl 中的order by
- 去除 left join
哪些情况count 优化限制:
- SQL 中 有 这些集合操作的 INTERSECT,EXCEPT,MINUS,UNION 直接不优化count
- 包含groupBy 不去除orderBy
- order by 里带参数,不去除order by
- 查看select 字段中是否动态条件,如果有条件字段,则不会优化 Count SQL
- 包含 distinct、groupBy不优化
- 如果 left join 是子查询,并且子查询里包含 ?(代表有入参) 或者 where 条件里包含使用 join 的表的字段作条件,就不移除 join
- 如果 where 条件里包含使用 join 的表的字段作条件,就不移除 join
- 如果 join 里包含 ?(代表有入参) 就不移除 join
详情可阅读一下代码:
/**
* 获取自动优化的 countSql
*
* @param page 参数
* @param sql sql
* @return countSql
*/
protected String autoCountSql(IPage<?> page, String sql) {
if (!page.optimizeCountSql()) {
return lowLevelCountSql(sql);
}
try {
Select select = (Select) CCJSqlParserUtil.parse(sql);
SelectBody selectBody = select.getSelectBody();
// https://github.com/baomidou/mybatis-plus/issues/3920 分页增加union语法支持
//----------- SQL 中 有 这些集合操作的 INTERSECT,EXCEPT,MINUS,UNION 直接不优化count
if (selectBody instanceof SetOperationList) {
// ----lowLevelCountSql 具体实现: String.format("SELECT COUNT(*) FROM (%s) TOTAL", originalSql)
return lowLevelCountSql(sql);
}
....................省略.....................
if (CollectionUtils.isNotEmpty(orderBy)) {
boolean canClean = true;
if (groupBy != null) {
// 包含groupBy 不去除orderBy
canClean = false;
}
if (canClean) {
for (OrderByElement order : orderBy) {
//-------------- order by 里带参数,不去除order by
Expression expression = order.getExpression();
if (!(expression instanceof Column) && expression.toString().contains(StringPool.QUESTION_MARK)) {
canClean = false;
break;
}
}
}
//-------- 清除order by
if (canClean) {
plainSelect.setOrderByElements(null);
}
}
//#95 Github, selectItems contains #{} ${}, which will be translated to ?, and it may be in a function: power(#{myInt},2)
// ----- 查看select 字段中是否动态条件,如果有条件字段,则不会优化 Count SQL
for (SelectItem item : plainSelect.getSelectItems()) {
if (item.toString().contains(StringPool.QUESTION_MARK)) {
return lowLevelCountSql(select.toString());
}
}
// ---------------包含 distinct、groupBy不优化
if (distinct != null || null != groupBy) {
return lowLevelCountSql(select.toString());
}
// ------------包含 join 连表,进行判断是否移除 join 连表
if (optimizeJoin && page.optimizeJoinOfCountSql()) {
List<Join> joins = plainSelect.getJoins();
if (CollectionUtils.isNotEmpty(joins)) {
boolean canRemoveJoin = true;
String whereS = Optional.ofNullable(plainSelect.getWhere()).map(Expression::toString).orElse(StringPool.EMPTY);
// 不区分大小写
whereS = whereS.toLowerCase();
for (Join join : joins) {
if (!join.isLeft()) {
canRemoveJoin = false;
break;
}
.........................省略..............
} else if (rightItem instanceof SubSelect) {
SubSelect subSelect = (SubSelect) rightItem;
/* ---------如果 left join 是子查询,并且子查询里包含 ?(代表有入参) 或者 where 条件里包含使用 join 的表的字段作条件,就不移除 join */
if (subSelect.toString().contains(StringPool.QUESTION_MARK)) {
canRemoveJoin = false;
break;
}
str = subSelect.getAlias().getName() + StringPool.DOT;
}
// 不区分大小写
str = str.toLowerCase();
if (whereS.contains(str)) {
/*--------------- 如果 where 条件里包含使用 join 的表的字段作条件,就不移除 join */
canRemoveJoin = false;
break;
}
for (Expression expression : join.getOnExpressions()) {
if (expression.toString().contains(StringPool.QUESTION_MARK)) {
/* 如果 join 里包含 ?(代表有入参) 就不移除 join */
canRemoveJoin = false;
break;
}
}
}
// ------------------ 移除join
if (canRemoveJoin) {
plainSelect.setJoins(null);
}
}
}
// 优化 SQL-------------
plainSelect.setSelectItems(COUNT_SELECT_ITEM);
return select.toString();
} catch (JSQLParserException e) {
..............
}
return lowLevelCountSql(sql);
}
order by 运行原理
order by 排序,具体怎么排取决于优化器的选择,如果优化器认为走索引更快,那么就会用索引排序,否则,就会使用filesort (执行计划中extra中提示:using filesort),但是能走索引排序的情况并不多,并且确定性也没有那么强,很多时候,还是走的filesort
索引排序
索引排序,效率是最高的,就算order by
后面的字段是 索引列,也不一定就是通过索引排序。这个过程是否一定用索引,完全取决于优化器的选择。
filesort 排序
如果不能走索引排序, MySQL 会执行filesort
操作以读取表中的行并对它们进行排序。
在进行排序时,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer,它的大小是由sort_buffer_size控制的。
sort_buffer_size的大小不同,会在不同的地方进行排序操作:
- 如果要排序的数据量小于 sort_buffer_size,那么排序就在内存中完成。
- 如果排序数据量大于sort_buffer_size,则需要利用磁盘临时文件辅助排序。
采用多路归并排序的方式将磁盘上的多个有序子文件合并成一个有序的结果集
filesort 排序 具体实现方式
FileSort是MySQL中用于对数据进行排序的一种机制,主要有以下几种实现方式:
全字段排序
- 原理:将查询所需的所有字段,包括用于排序的字段以及其他
SELECT
列表中的字段,都读取到排序缓冲区中进行排序。这样可以在排序的同时获取到完整的行数据,减少访问原表数据的次数。 - 适用场景:当排序字段和查询返回字段较少,并且排序缓冲区能够容纳这些数据时,全字段排序效率较高。
行指针排序
- 原理:只将排序字段和行指针(指向原表中数据行的指针)读取到排序缓冲区中进行排序。排序完成后,再根据行指针回表读取所需的其他字段数据。
- 适用场景:当查询返回的
字段较多
,而排序缓冲区
无法容纳全字段数据时,行指针排序可以减少排序缓冲区的占用,提高排序效率。但由于需要回表
操作,可能会增加一定的I/O开销。
多趟排序
- 原理:如果数据量非常大,即使采用行指针排序,排序缓冲区也无法一次容纳所有数据,MySQL会将数据分成多个较小的部分,分别在排序缓冲区中进行排序,生成多个有序的临时文件。然后再将这些临时文件进行多路归并,最终得到完整的有序结果。
- 适用场景:适用于处理超大数据量的排序操作,能够在有限的内存资源下完成排序任务,但会产生较多的磁盘I/O操作,
性能相对较低
。
优先队列排序
- 原理:结合优先队列数据结构进行排序。对于带有
LIMIT
子句的查询,MySQL会创建一个大小为LIMIT
值的优先队列。在读取数据时,将数据放入优先队列中,根据排序条件进行比较和调整。当读取完所有数据或达到一定条件后,优先队列中的数据就是满足LIMIT
条件的有序结果。 - 适用场景:特别适用于需要获取少量排序后数据的情况,如查询排名前几的数据。可以避免对大量数据进行全量排序,提高查询效率。
❗所以减少查询字段 ,以及 减少 返回的行数,对于排序SQL 的优化也是非常重要
❗以及order by 后面尽量使用索引字段,以及行数限制
limit 运行原理
limit执行过程
对于 SQL 查询中 LIMIT 的使用,像 LIMIT 10000, 100 这种形式,MySQL 的执行顺序大致如下:
- 从数据表中读取所有符合条件的数据(包括排序和过滤)。
- 将数据按照 ORDER BY 排序。
- 根据 LIMIT 参数选择返回的记录:
- 跳过前 10000 行数据(这个过程是通过丢弃数据来实现的)。
- 然后返回接下来的 100 行数据。
所以,LIMIT 是先检索所有符合条件的数据,然后丢弃掉前面的行,再返回指定的行数。这解释了为什么如果数据集很大,LIMIT 会带来性能上的一些问题,尤其是在有很大的偏移量(比如 LIMIT 10000, 100)时。
总结
本篇文章分析,mybatisPlus 分页插件处理count sql
的逻辑,以及优化过程,同时也简单分析order by
和 limit
执行原理。
希望这篇文章能够让你对SQL优化 有不一样的认知,最后感谢各位老铁一键三连!
ps: 云服务器找我返点;面试宝典私;收徒ING;
来源:juejin.cn/post/7457934738356338739
字节2面:为了性能,你会违反数据库三范式吗?
大家好,我是猿java。
数据库的三大范式,它是数据库设计中最基本的三个规范,那么,三大范式是什么?在实际开发中,我们一定要严格遵守三大范式吗?这篇文章,我们一起来聊一聊。
1. 三大范式
1. 第一范式(1NF,确保每列保持原子性)
第一范式要求数据库中的每个表格的每个字段(列)都具有原子性,即字段中的值不可再分割。换句话说,每个字段只能存储一个单一的值,不能包含集合、数组或重复的组。
如下示例: 假设有一个学生表 Student
,结构如下:
学生ID | 姓名 | 电话号码 |
---|---|---|
1 | 张三 | 123456789, 987654321 |
2 | 李四 | 555555555 |
在这个表中,电话号码
字段包含多个号码,违反了1NF的原子性要求。为了满足1NF,需要将电话号码拆分为单独的记录或创建一个新的表。
满足 1NF后的设计:
学生表 Student
学生ID | 姓名 |
---|---|
1 | 张三 |
2 | 李四 |
电话表 Phone
电话ID | 学生ID | 电话号码 |
---|---|---|
1 | 1 | 123456789 |
2 | 1 | 987654321 |
3 | 2 | 555555555 |
1.2 第二范式(2NF,确保表中的每列都和主键相关)
第二范式要求满足第一范式,并且消除表中的部分依赖,即非主键字段必须完全依赖于主键,而不是仅依赖于主键的一部分。这主要适用于复合主键的情况。
如下示例:假设有一个订单详情表 OrderDetail
,结构如下:
订单ID | 商品ID | 商品名称 | 数量 | 单价 |
---|---|---|---|---|
1001 | A01 | 苹果 | 10 | 2.5 |
1001 | A02 | 橙子 | 5 | 3.0 |
1002 | A01 | 苹果 | 7 | 2.5 |
在上述表中,主键是复合主键 (订单ID, 商品ID)
。商品名称
和单价
只依赖于复合主键中的商品ID
,而不是整个主键,存在部分依赖,违反了2NF。
满足 2NF后的设计:
订单详情表 OrderDetail
订单ID | 商品ID | 数量 |
---|---|---|
1001 | A01 | 10 |
1001 | A02 | 5 |
1002 | A01 | 7 |
商品表 Product
商品ID | 商品名称 | 单价 |
---|---|---|
A01 | 苹果 | 2.5 |
A02 | 橙子 | 3.0 |
1.3 第三范式(3NF,确保每列都和主键列直接相关,而不是间接相关)
第三范式要求满足第二范式,并且消除表中的传递依赖,即非主键字段不应依赖于其他非主键字段。换句话说,所有非主键字段必须直接依赖于主键,而不是通过其他非主键字段间接依赖。
如下示例:假设有一个员工表 Employee
,结构如下:
员工ID | 员工姓名 | 部门ID | 部门名称 |
---|---|---|---|
E01 | 王五 | D01 | 销售部 |
E02 | 赵六 | D02 | 技术部 |
E03 | 孙七 | D01 | 销售部 |
在这个表中,部门名称
依赖于部门ID
,而部门ID
依赖于主键员工ID
,形成了传递依赖,违反了3NF。
满足3NF后的设计:
员工表 Employee
员工ID | 员工姓名 | 部门ID |
---|---|---|
E01 | 王五 | D01 |
E02 | 赵六 | D02 |
E03 | 孙七 | D01 |
部门表 Department
部门ID | 部门名称 |
---|---|
D01 | 销售部 |
D02 | 技术部 |
通过将部门信息移到单独的表中,消除了传递依赖,使得数据库结构符合第三范式。
最后,我们总结一下数据库设计的三大范式:
- 第一范式(1NF): 确保每个字段的值都是原子性的,不可再分。
- 第二范式(2NF): 在满足 1NF的基础上,消除部分依赖,确保非主键字段完全依赖于主键。
- 第三范式(3NF): 在满足 2NF的基础上,消除传递依赖,确保非主键字段直接依赖于主键。
2. 破坏三范式
在实际工作中,尽管遵循数据库的三大范式(1NF、2NF、3NF)有助于提高数据的一致性和减少冗余,但在某些情况下,为了满足性能、简化设计或特定业务需求,我们可能需要违反这些范式。
下面列举了一些常见的破坏三范式的原因及对应的示例。
2.1 性能优化
在高并发、大数据量的应用场景中,严格遵循三范式可能导致频繁的联表查询,增加查询时间和系统负载。为了提高查询性能,设计者可能会通过冗余数据来减少联表操作。
假设有一个电商系统,包含订单表 Orders
和用户表 Users
。在严格 3NF设计中,订单表只存储 用户ID
,需要通过联表查询获取用户的详细信息。
但是,为了查询性能,我们通常会在订单表中冗余存储 用户姓名
和 用户地址
等信息,因此,查询订单信息时无需联表查询 Users
表,从而提升查询速度。
破坏 3NF后的设计:
订单ID | 用户ID | 用户姓名 | 用户地址 | 订单日期 | 总金额 |
---|---|---|---|---|---|
1001 | U01 | 张三 | 北京市 | 2023-10-01 | 500元 |
1002 | U02 | 李四 | 上海市 | 2023-10-02 | 300元 |
2.2 简化查询和开发
严格规范化可能导致数据库结构过于复杂,增加开发和维护的难度,为了简化查询逻辑和减少开发复杂度,我们也可能会选择适当的冗余。
比如,在内容管理系统(CMS)中,文章表 Articles
和分类表 Categories
通常是独立的,如果频繁需要显示文章所属的分类名称,联表查询可能增加复杂性。因此,通过在 Articles
表中直接存储 分类名称
,可以简化前端展示逻辑,减少开发工作量。
破坏 3NF后的设计:
文章ID | 标题 | 内容 | 分类ID | 分类名称 |
---|---|---|---|---|
A01 | 文章一 | … | C01 | 技术 |
A02 | 文章二 | … | C02 | 生活 |
2.3 报表和数据仓库
在数据仓库和报表系统中,通常需要快速读取和聚合大量数据。为了优化查询性能和数据分析,可能会采用冗余的数据结构,甚至使用星型或雪花型模式,这些模式并不完全符合三范式。
在销售数据仓库中,为了快速生成销售报表,可能会创建一个包含维度信息的事实表。
破坏 3NF后的设计:
销售ID | 产品ID | 产品名称 | 类别 | 销售数量 | 销售金额 | 销售日期 |
---|---|---|---|---|---|---|
S01 | P01 | 手机 | 电子 | 100 | 50000元 | 2023-10-01 |
S02 | P02 | 书籍 | 教育 | 200 | 20000元 | 2023-10-02 |
在事实表中直接存储 产品名称
和 类别
,避免了需要联表查询维度表,提高了报表生成的效率。
2.4 特殊业务需求
在某些业务场景下,可能需要快速响应特定的查询或操作,这时通过适当的冗余设计可以满足业务需求。
比如,在实时交易系统中,为了快速计算用户的账户余额,可能会在用户表中直接存储当前余额,而不是每次交易时都计算。
破坏 3NF后的设计:
用户ID | 用户名 | 当前余额 |
---|---|---|
U01 | 王五 | 10000元 |
U02 | 赵六 | 5000元 |
在交易记录表中存储每笔交易的增减,但直接在用户表中维护 当前余额
,避免了每次查询时的复杂计算。
2.5 兼顾读写性能
在某些应用中,读操作远多于写操作。为了优化读性能,可能会通过数据冗余来提升查询速度,而接受在数据写入时需要额外的维护工作。
社交媒体平台中,用户的好友数常被展示在用户主页上。如果每次请求都计算好友数量,效率低下。可以在用户表中维护一个 好友数
字段。
破坏3NF后的设计:
用户ID | 用户名 | 好友数 |
---|---|---|
U01 | Alice | 150 |
U02 | Bob | 200 |
通过在 Users
表中冗余存储 好友数
,可以快速展示,无需实时计算。
2.6 快速迭代和灵活性
在快速发展的产品或初创企业中,数据库设计可能需要频繁调整。过度规范化可能导致设计不够灵活,影响迭代速度。适当的冗余设计可以提高开发的灵活性和速度。
一个初创电商平台在初期快速上线,数据库设计时为了简化开发,可能会将用户的收货地址直接存储在订单表中,而不是单独创建地址表。
破坏3NF后的设计:
订单ID | 用户ID | 用户名 | 收货地址 | 订单日期 | 总金额 |
---|---|---|---|---|---|
O1001 | U01 | 李雷 | 北京市海淀区… | 2023-10-01 | 800元 |
O1002 | U02 | 韩梅梅 | 上海市浦东新区… | 2023-10-02 | 1200元 |
这样设计可以快速上线,后续根据需求再进行规范化和优化。
2.7 降低复杂性和提高可理解性
有时,过度规范化可能使数据库结构变得复杂,难以理解和维护。适度的冗余可以降低设计的复杂性,提高团队对数据库结构的理解和沟通效率。
在一个学校管理系统中,如果将学生的班级信息独立为多个表,可能增加理解难度。为了简化设计,可以在学生表中直接存储班级名称。
破坏3NF后的设计:
学生ID | 姓名 | 班级ID | 班级名称 | 班主任 |
---|---|---|---|---|
S01 | 张三 | C01 | 三年级一班 | 李老师 |
S02 | 李四 | C02 | 三年级二班 | 王老师 |
通过在学生表中直接存储 班级名称
和 班主任
,减少了表的数量,简化了设计。
3. 总结
本文,我们分析了数据库的三范式以及对应的示例,它是数据库设计的基本规范。但是,在实际工作中,为了满足性能、简化设计、快速迭代或特定业务需求,我们很多时候并不会严格地遵守三范式。
所以说,架构很多时候都是业务需求、数据一致性、系统性能、开发效率等各种因素权衡的结果,我们需要根据具体应用场景做出合理的设计选择。
4. 学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7455635421529145359
Redis - 全局ID生成器 RedisIdWorker
概述
定义
:一种分布式系统下用来生成全局唯一 ID 的工具特点
- 唯一性,满足优惠券需要唯一的 ID 标识用于核销
- 高可用,随时能够生成正确的 ID
- 高性能,生成 ID 的速度很快
- 递增性,生成的 ID 是逐渐变大的,有利于数据库形成索引
- 安全性,生成的 ID 无明显规律,可以避免间接泄露信息
- 生成量大,可满足优惠券订单数据量大的需求
ID 组成部分
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
代码实现
目标
:手动实现一个简单的全局 ID 生成器实现流程
- 创建生成器:在 utils 包下创建 RedisIdWorker 类,作为 ID 生成器
- 创建时间戳:创建一个时间戳,即 RedisId 的高32位
- 获取当前日期:创建当前日期对象 date,用于自增 id 的生成
- count:设置 Id 格式,保证 Id 严格自增长
- 拼接 Id 并将其返回
代码实现
@Component
public class RedisIdWorker {
// 开始时间戳
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 序列号的位数
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 获取下一个自动生成的 id
public long nextId(String keyPrefix){
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 3.获取当前日期
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 4.获取自增长值:生成一个递增计数值。每次调用 increment 方法时,它会在这个key之前的自增值的基础上+1(第一次为0)
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 5.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
测试
一、CountDownLatch 工具类
定义
:一个同步工具类,用于协调多个线程的等待与唤醒功能
:
- 控制多个线程的执行顺序和同步
- 确保主线程在所有子线程完成后才继续执行
- 防止主线程过早结束导致子线程执行状态丢失
常用方法
:
- await:用于主线程的阻塞方法,使其阻塞等待直到计数器归零
- countDown:用于子线程的计数方法,使计数器递减
二、ExecutorService & Executors
定义
:Java 提供的线程池管理接口功能
:
- 简化异步任务的执行管理
- 提供有关 “线程池” 和 “任务执行” 的标准 API
常用方法
方法 说明 Executors.newFixedThreadPool(xxxThreads) Executors 提供的工厂方法,用于创建 ExecutorService 实例 execute(functionName) 调用线程执行 functionName 任务,无返回值 ⭐ submit(functionName) 调用线程执行 functionName 任务,返回一个 Future 类 invokeAny(functionName) 调用线程执行一组 functionName 任务,返回首成功执行的任务的结果 invokeAll(functionName) 调用线程执行一组 functionName 任务,返回所有任务执行的结果 ⭐ shutdown() 停止接受新任务,并在所有正在运行的线程完成当前工作后关闭 ⭐ awaitTermination() 停止接受新任务,在指定时间内等待所有任务完成
- 参考资料:一文秒懂 Java ExecutorService
- 代码实现
- 目标:测试 redisIdWorker 在高并发场景下的表现(共生成 30000 个 id)
private ExecutorService es = Executors.newFixedThreadPool(500); // 创建一个含有 500 个线程的线程池
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300); // 定义一个工具类,统计线程执行300次task的进度
// 创建函数,供线程执行
Runnable task = () -> {
for(int i = 0; i < 100; i ++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
}
long begin = System.currentTimeMillis();
for( int i = 0; i < 300 ; i ++) {
es.submit(task);
}
latch.await(); // 主线程等待,直到 CountDownLatch 的计数归
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin)); // 打印任务执行的总耗时
}
超卖问题
- 目标:通过数据库的 SQL 语句直接实现库存扣减(存在超卖问题)
一、乐观锁
定义
:一种并发控制机制,不使用数据库锁,而是在更新时通过版本号或条件判断来确保数据一致性优点
:并发性能高,不会产生死锁,适合读多写少的场景实现方式
:CAS (Compare and Swap) - 比较并交换操作实现示例
(基于版本号的乐观锁)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1, version = version + 1")
.eq("voucher_id", voucherId)
.eq("version", version)
.gt("stock", 0)
.update();
分布式环境的局限性
- **原子性问题:**多个线程同时检查库存并更新时,可能导致超卖。这是因为检查和更新操作不是原子的
- **事务隔离:**在默认的"读已提交"隔离级别下,分布式环境中的多个节点可能读取到不一致的数据状态
- **分布式一致性:**在分布式环境中,不同的应用服务器可能同时操作数据库,而数据库层本身并不能感知跨服务器的事务一致性
二、悲观锁
定义
:一种并发控制机制,通过添加同步锁强制线程串行执行优点
:实现简单,可以确保数据一致性缺点
:由于串行执行导致性能较低,不适合高并发场景事务隔离级别
:读已提交及以上实现方法
:使用 SQL 的 forUpdate() 子句,可以在查询时锁定选中的数据行。被锁定的行在当前事务提交或回滚前,其他事务无法对其进行修改或读取
三、事务隔离级别
- 定义:数据库事务并发执行时的隔离程度,用于解决并发事务可能带来的问题
- 优点:可以防止脏读、不可重复读和幻读等并发问题
- 缺点:隔离级别越高,并发性能越低
- 实现方法:
- 读未提交(Read Uncommitted):允许读取未提交的数据
- 读已提交(Read Committed):只允许读取已提交的数据
- 可重复读(Repeatable Read):在同一事务中多次读取同样数据的结果是一致的
- 串行化(Serializable):最高隔离级别,完全串行化执行
一人一单问题
一、单服务器系统解决方案
需求
:每个人只能抢购一张大额优惠券,避免同一用户购买多张优惠券重点
- 事务:库存扣减操作必须在事务中执行
- 粒度:事务粒度必须够小,避免影响性能
- 锁:事务开启时必须确保拿到当前下单用户的订单,并依据用户 Id 加锁
- 找到事务的代理对象,避免 Spring 事务注解失效 (需要给启动类加
@EnableAspectJAutoProxy(exposeProxy = true)
注解)
实现逻辑
- 获取优惠券 id、当前登录用户 id
- 查询数据库的优惠券表(voucher_order)
- 如果存在优惠券 id 和当前登录用户 id 都匹配的 order 则拒绝创建订单,返回 fail()
- 如果不存在则创建订单 voucherOrder 并保存至 voucher_order 表中,返回 ok()
二、分布式系统解决方案 (通过 Lua 脚本保证原子性)
一、优惠券下单逻辑
二、代码实现 (Lua脚本)
--1. 参数列表
--1.1. 优惠券id
local voucherId = ARGV[1]
--1.2. 用户id
local userId = ARGV[2]
--1.3. 订单id
local orderId = ARGV[3]
--2. 数据key
--2.1. 库存key
local stockKey = 'seckill:stock:' .. voucherId
--2.2. 订单key
local orderKey = 'seckill:order' .. voucherId
--3. 脚本业务
--3.1. 判断库存是否充足 get stockKey
if( tonumber( redis.call('get', stockKey) ) <= 0 ) then
return 1
end
--3.2. 判断用户是否下单 SISMEMBER orderKey userId
if( redis.call( 'sismember', orderKey, userId ) == 1 ) then
return 2
end
--3.4 扣库存: stockKey 的库存 -1
redis.call( 'incrby', stockKey, -1 )
--3.5 下单(保存用户): orderKey 集合中添加 userId
redis.call( 'sadd', orderKey, userId )
-- 3.6. 发送消息到队列中
redis.call( 'xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId )
三、加载 Lua 脚本
RedisScript 接口
:用于绑定一个具体的 Lua 脚本DefaultRedisScript 实现类
- 定义:RedisScript 接口的实现类
- 功能:提前加载 Lua 脚本
- 示例
// 创建Lua脚本对象
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
// Lua脚本初始化 (通过静态代码块)
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("/path/to/lua_script.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
四、执行 Lua 脚本
调用Lua脚本 API
:StringRedisTemplate.execute( RedisScript script, List keys, Object… args )示例
- 执行 ”下单脚本” (此时不需要 key,因为下单时只需要用 userId 和 voucherId 查询是否有锁)
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT, // 要执行的脚本
Collections.emptyList(), // KEY
voucherId.toString(), userId.toString(), String.valueOf(orderId) // VALUES
);
- 执行 “unlock脚本”
- 执行 ”下单脚本” (此时不需要 key,因为下单时只需要用 userId 和 voucherId 查询是否有锁)
实战:添加优惠券 & 单服务器创建订单
添加优惠券
目标
:商家在主页上添加一个优惠券的抢购链接,可以点击抢购按钮抢购优惠券
一、普通优惠券
定义
:日常可获取的资源代码实现
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
二、限量优惠券
定义
:限制数量,需要设置时间限制、面对高并发请求的资源下单流程
- 查询优惠券:通过 voucherId 查询优惠券
- 时间判断:判断是否在抢购优惠券的固定时间范围内
- 库存判断:判断优惠券库存是否 ≥ 1
- 扣减库存
- 创建订单:创建订单 VoucherOrder 对象,指定订单号,指定全局唯一 voucherId,指定用户 id
- 保存订单:保存订单到数据库
- 返回结果:Result.ok(orderId)
代码实现
- VoucherController
@PostMapping("seckill")
public Result addSeckillVoucher( @RequestBody Voucher voucher ){
voucherService.addSeckillVoucher(voucher);
return Result.o(voucher.getId());
}
- VoucherServiceImpl
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券到数据库
save(voucher);
// 保存优惠券信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存优惠券到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
- VoucherController
(缺陷) 优惠券下单功能
一、功能说明
目标
:用户抢购代金券,保证用户成功获得优惠券,保证效率并且避免超卖工作流程
- 提交优惠券 ID
- 查询优惠券信息 (下单时间是否合法,下单时库存是否充足)
- 扣减库存,创建订单
- 返回订单 ID
四、代码实现
- VoucherOrderServiceImpl (下述代码在分布式环境下仍然存在超卖问题)
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService{
@Resource
private ISeckillVoucherService seckillVoucherService;
@Override
public Result seckillVoucher(Long voucherId) {
// 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 优惠券抢购时间判断
if(voucher.getBeginTime().isAfter(LocalDateTime.now) || voucher.getEndTime().isBefore(LocalDateTime.now()){
return Result.fail("当前不在抢购时间!");
}
// 库存判断
if(voucher.getStock() < 1){
return Result.fail("库存不足!");
}
// !!! 实现一人一单功能 !!!
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long userId) {
Long userId = UserHolder.getUser().getId();
// 查询当前用户是否已经购买过优惠券
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if( count > 0 ) {
return Result.fail("当前用户不可重复购买!");
// !!! 实现乐观锁 !!!
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1;
.eq("voucher_id", voucherId).gt("stock", 0) // where voucher_id = voucherId and stock > 0;
.update();
if(!success) {
return Result.fail("库存不足!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisIdWorker.nextId("order"));
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 返回订单id
return Result.ok(orderId);
}
来源:juejin.cn/post/7448119568567189530
虾皮开的很高,还有签字费。
大家好,我是二哥呀。
虾皮在去年之前,还是很多大厂人外逃的首选项,因为总部在新加坡,比较有外企范,但去年就突然急转直下,队伍收紧了不少。
作为东南亚电商市场的领头羊,市场覆盖了新加坡、马来西亚、泰国、菲律宾、印尼、越南等地,目前也开始进军巴西和墨西哥等新兴市场。
我从 offershow 上也统计了一波 25 届虾皮目前开出来的薪资状况,方便大家做个参考。
- 本科 985,后端岗,给了 32k,还有 5 万签字费,自己硬 A 出来的,15 天年假,base 上海,早 9.30 晚 7 点
- 硕士双一流,后端给了 40 万年包,但已经签了其他的三方,拒了,11 月 31 日下午开的
- 硕士 985,后端开发,给到了 23k,白菜价,主要面试的时候表现太差了
- 硕士海归,后端开发给了 26.5k,还有三万签字费,咩别的高,就释放了
- 硕士211,测试岗,只给了 21k,还有 3 万年终奖,但拒了
从目前统计到的情况来看,虾皮其实还蛮舍得给钱的,似乎有点超出了外界对他的期待。但很多同学因为去年的情况,虾皮只能拿来做备胎,不太敢去。
从虾皮母公司 Sea 发布的2024 年第三季度财报来看,电子商务(主要是 Shopee)收入增长了 42.6%,达到了 31.8 亿美元,均超预期。
总之,希望能尽快扭转颓势吧,这样学 Java 的小伙伴也可以有更多的选择。
那接下来,我们就以 Java 面试指南中收录的虾皮面经同学 13 一面为例,来看看下面的面试难度,自己是否有一战之力。
虾皮面经同学 13 一面
tcp为什么是可靠的
TCP 首先通过三次握手和四次挥手来保证连接的可靠性,然后通过校验和、序列号、确认应答、超时重传、滑动窗口等机制来保证数据的可靠传输。
①、校验和:TCP 报文段包括一个校验和字段,用于检测报文段在传输过程中的变化。如果接收方检测到校验和错误,就会丢弃这个报文段。
推荐阅读:TCP 校验和计算方法
②、序列号/确认机制:TCP 将数据分成多个小段,每段数据都有唯一的序列号,以确保数据包的顺序传输和完整性。同时,发送方如果没有收到接收方的确认应答,会重传数据。
③、流量控制:接收方会发送窗口大小告诉发送方它的接收能力。发送方会根据窗口大小调整发送速度,避免网络拥塞。
④、超时重传:如果发送方发送的数据包超过了最大生存时间,接收方还没有收到,发送方会重传数据包以保证丢失数据重新传输。
⑤、拥塞控制:TCP 会采用慢启动的策略,一开始发的少,然后逐步增加,当检测到网络拥塞时,会降低发送速率。在网络拥塞缓解后,传输速率也会自动恢复。
http的get和post区别
GET 请求主要用于获取数据,参数附加在 URL 中,存在长度限制,且容易被浏览器缓存,有安全风险;而 POST 请求用于提交数据,参数放在请求体中,适合提交大量或敏感的数据。
另外,GET 请求是幂等的,多次请求不会改变服务器状态;而 POST 请求不是幂等的,可能对服务器数据有影响。
https使用过吗 怎么保证安全
HTTP 是明文传输的,存在数据窃听、数据篡改和身份伪造等问题。而 HTTPS 通过引入 SSL/TLS,解决了这些问题。
SSL/TLS 在加密过程中涉及到了两种类型的加密方法:
- 非对称加密:服务器向客户端发送公钥,然后客户端用公钥加密自己的随机密钥,也就是会话密钥,发送给服务器,服务器用私钥解密,得到会话密钥。
- 对称加密:双方用会话密钥加密通信内容。
客户端会通过数字证书来验证服务器的身份,数字证书由 CA 签发,包含了服务器的公钥、证书的颁发机构、证书的有效期等。
https能不能抓包
可以,HTTPS 可以抓包,但因为通信内容是加密的,需要解密后才能查看。
其原理是通过一个中间人,伪造服务器证书,并取得客户端的信任,然后将客户端的请求转发给服务器,将服务器的响应转发给客户端,完成中间人攻击。
常用的抓包工具有 Wireshark、Fiddler、Charles 等。
threadlocal 原理 怎么避免垃圾回收?
ThreadLocal 的实现原理就是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。
1、当需要存线程隔离的对象时,通过 ThreadLocal 的 set 方法将对象存入 Map 中。
2、当需要取线程隔离的对象时,通过 ThreadLocal 的 get 方法从 Map 中取出对象。
3、Map 的大小由 ThreadLocal 对象的多少决定。
通常情况下,随着线程 Thread 的结束,其内部的 ThreadLocalMap 也会被回收,从而避免了内存泄漏。
但如果一个线程一直在运行,并且其 ThreadLocalMap
中的 Entry.value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。当 Entry 非常多时,可能就会引发更严重的内存溢出问题。
使用完 ThreadLocal 后,及时调用 remove()
方法释放内存空间。remove()
方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。
mysql慢查询
慢 SQL 也就是执行时间较长的 SQL 语句,MySQL 中 long_query_time 默认值是 10 秒,也就是执行时间超过 10 秒的 SQL 语句会被记录到慢查询日志中。
可通过 show variables like 'long_query_time';
查看当前的 long_query_time 值。
不过,生产环境中,10 秒太久了,超过 1 秒的都可以认为是慢 SQL 了。
mysql事务隔离级别
事务的隔离级别定了一个事务可能受其他事务影响的程度,MySQL 支持的四种隔离级别分别是:读未提交、读已提交、可重复读和串行化。
遇到过mysql死锁或者数据不安全吗
有,一次典型的场景是在技术派项目中,两个事务分别更新两张表,但是更新顺序不一致,导致了死锁。
-- 创建表/插入数据
CREATE TABLE account (
id INT AUTO_INCREMENT PRIMARY KEY,
balance INT NOT NULL
);
INSERT INTO account (balance) VALUES (100), (200);
-- 事务 1
START TRANSACTION;
-- 锁住 id=1 的行
UPDATE account SET balance = balance - 10 WHERE id = 1;
-- 等待锁住 id=2 的行(事务 2 已锁住)
UPDATE account SET balance = balance + 10 WHERE id = 2;
-- 事务 2
START TRANSACTION;
-- 锁住 id=2 的行
UPDATE account SET balance = balance - 10 WHERE id = 2;
-- 等待锁住 id=1 的行(事务 1 已锁住)
UPDATE account SET balance = balance + 10 WHERE id = 1;
两个事务访问相同的资源,但是访问顺序不同,导致了死锁。
解决方法:
第一步,使用 SHOW ENGINE INNODB STATUS\G;
查看死锁信息。
第二步,调整事务的资源访问顺序,保持一致。
怎么解决依赖冲突的
比如在一个项目中,Spring Boot 和其他库对 Jackson 的版本有不同要求,导致序列化和反序列化功能出错。
这时候,可以先使用 mvn dependency:tree分析依赖树,找到冲突;然后在 dependencyManagement 中强制统一 Jackson 版本,或者在传递依赖中使用 exclusion 排除不需要的版本。
spring事务
在 Spring 中,事务管理可以分为两大类:声明式事务管理和编程式事务管理。
编程式事务可以使用 TransactionTemplate 和 PlatformTransactionManager 来实现,需要显式执行事务。允许我们在代码中直接控制事务的边界,通过编程方式明确指定事务的开始、提交和回滚。
声明式事务是建立在 AOP 之上的。其本质是通过 AOP 功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前启动一个事务,在目标方法执行完之后根据执行情况提交或者回滚事务。
相比较编程式事务,优点是不需要在业务逻辑代码中掺杂事务管理的代码,Spring 推荐通过 @Transactional 注解的方式来实现声明式事务管理,也是日常开发中最常用的。
常见的linux命令
我自己常用的 Linux 命令有 top 查看系统资源、ps 查看进程、netstat 查看网络连接、ping 测试网络连通性、find 查找文件、chmod 修改文件权限、kill 终止进程、df 查看磁盘空间、free 查看内存使用、service 启动服务、mkdir 创建目录、rm 删除文件、rmdir 删除目录、cp 复制文件、mv 移动文件、zip 压缩文件、unzip 解压文件等等这些。
git命令
git clone <repository-url>
:克隆远程仓库。git status
:查看工作区和暂存区的状态。git add <file>
:将文件添加到暂存区。git commit -m "message"
:提交暂存区的文件到本地仓库。git log
:查看提交历史。git merge <branch-name>
:合并指定分支到当前分支。git checkout <branch-name>
:切换分支。git pull
:拉取远程仓库的更新。
内容来源
三分恶的面渣逆袭:javabetter.cn/sidebar/san…
二哥的 Java 进阶之路(GitHub 已有 13000+star):github.com/itwanger/to…
最后,把二哥的座右铭送给大家:没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟。共勉 💪。
来源:juejin.cn/post/7451638008409554994
AI赋能剪纸艺术,剪映助力多地文旅点亮新春
近日,一场别开生面的文化盛宴在社交媒体拉开帷幕。多地文旅纷纷在官方账号发布剪纸风格的视频,以独特的视角展现当地丰富的文旅资源,将传统非遗文化与春节的喜庆氛围完美融合,这一创新形式收获网友大量点赞。
在这些令人眼前一亮的视频中,各地的标志性景点和特色风土人情以剪纸艺术的形式生动呈现。细腻的线条勾勒出西安大雁塔的宏伟庄严,鲜艳的色彩展现出塞上江南的瑰丽,精致的图案描绘出江南水乡的温婉秀丽。每一幅剪纸都仿佛在诉说着一个地方的故事,让大众在感受剪纸艺术魅力的同时,领略到祖国大地的壮美多姿。
图片来源:陕西文旅、威海文旅、内蒙古文旅的官方社交媒体账号
记者注意到,本次剪纸效果采用了剪映提供的“中式剪纸”模板功能。作为字节跳动旗下的视频创作工具产品,剪映团队发挥技术优势,将AI新技术与传统剪纸艺术深度融合,为创作者提供了便捷且强大的创作工具。通过AI算法,用户只需上传照片素材,就能快速生成效果精细的剪纸风格视频,大大降低了创作门槛,让更多人参与到创作中来。
除了风景类的剪纸视频模板,剪映在春节期间还推出了丰富多样的其他模板,如人物剪纸模板。用户可以通过这些模板,将自己或身边人的形象创作为剪纸风格的人物,为视频增添更多趣味性和个性化元素。无论是阖家团圆的场景,还是展现个人风采的画面,都能通过这些模板以独特的剪纸艺术形式呈现。
剪映相关负责人表示,新春将至,希望通过AI技术的应用让剪纸艺术突破地域和传统展示形式的限制,激发更多人对家乡的热爱,鼓励大家用这种新颖的方式秀出自己家乡的风景,共同分享美好。(作者:刘洪)
收起阅读 »synchronized就该这么学
先赞后看,Java进阶一大半
早期sychonrized重量级锁开销大,于是JDK1.5引入了ReentrantLock,包含现在很多偏见都是认为ReentrantLock性能要优于sychonrized。但JDK1.6引入的锁升级,不断迭代,怕是性能往往还优于ReentrantLock。
我是南哥,相信对你通关面试、拿下Offer有所帮助。
敲黑板:本文总结了多线程相关的synchronized、volatile常见的面试题!
⭐⭐⭐收录在《Java学习/进阶/面试指南》:https://github/JavaSouth
精彩文章推荐
1. synchronized
1.1 可重入锁
面试官:知道可重入锁有哪些吗?
可重入意味着获取锁的粒度是线程而不是调用,如果大家知道这个概念,会更容易理解可重入锁的作用。
既然获取锁的粒度是线程,意味着线程自己是可以获取自己的内部锁的,而如果获取锁的粒度是调用则每次经过同步代码块都需要重新获取锁。
举个例子。线程A获取了某个对象锁,但在线程代码的流程中仍需再次获取该对象锁,此时线程A可以继续执行不需要重新再获取该对象锁。另外线程如果要使用父类的同步方法,由于可重入锁也无需再次获取锁。
在Java中,可重入锁主要有ReentrantLock、synchronized。
1.2 synchronized实现原理
面试官:你先说说synchronized的实现原理?
synchronized的实现是基于monitor的。任何对象都有一个monitor与之关联,当monitor被持有后,对象就会处于锁定状态。而在同步代码块的开始位置,在编译期间会被插入monitorenter指令。
当线程执行到monitorenter指令时,就会尝试获取monitor的所有权,如果获取得到则代表获得锁资源。
1.3 synchronized缺点
面试官:那synchronized有什么缺点?
在Java SE 1.6还没有对synchronized进行了各种优化前,很多人都会称synchronized为重量级锁,因为它对资源消耗是比较大的。
- synchronized需要频繁的获得锁、释放锁,这会带来了不少性能消耗。
- 另外没有获得锁的线程会被操作系统进行挂起阻塞、唤醒。而唤醒操作需要保存当前线程状态,切换到下一个线程,也就是进行上下文切换。上下文切换是很耗费资源的一种操作。
1.4 保存线程状态
面试官:为什么上下文切换要保存当前线程状态?
这就跟读英文课文时查字典一样,我们要先记住课文里的页数,查完字典好根据页数翻到英文课文原来的位置。
同理,CPU要保证可以切换到上一个线程的状态,就需要保存当前线程的状态。
1.5 锁升级
面试官:可以怎么解决synchronized资源消耗吗?
上文我有提到Java SE 1.6对synchronized进行了各种优化,具体的实现是给synchronized引入了锁升级的概念。synchronized锁一共有四种状态,级别从低到高依次是无锁、偏向锁、轻量级锁、重量级锁。
大家思考下,其实多线程环境有着各种不同的场景,同一个锁状态并不能够适应所有的业务场景。而这四种锁状态就是为了适应各种不同场景来使得线程并发的效率最高。
- 没有任何线程访问同步代码块,此时synchronized是无锁状态。
- 只有一个线程访问同步代码块的场景的话,会进入偏向锁状态。偏向锁顾名思义会偏向访问它的线程,使其加锁、解锁不需要额外的消耗。
- 有少量线程竞争的场景的话,偏向锁会升级为轻量级锁。而轻量级采用CAS操作来获得锁,CAS操作不需要获得锁、释放锁,减少了像synchronized重量级锁带来的上下文切换资源消耗。
- 轻量级锁通过CAS自旋来获得锁,如果自旋10次失败,为了减少CPU的消耗则锁会膨胀为重量级锁。此时synchronized重量级锁就回归到了悲观锁的状态,其他获取不到锁的都会进入阻塞状态。
1.6 锁升级优缺点
面试官:它们都有什么优缺点呢?
由于每个锁状态都有其不同的优缺点,也意味着有其不同的适应场景。
- 偏向锁的优点是加锁和解锁操作不需要额外的消耗;缺点是如果线程之间存在锁竞争,偏向锁会撤销,这也带来额外的撤销消耗;所以偏向锁适用的是只有一个线程的业务场景。
- 轻量级锁状态下,优点是线程不会阻塞,提高了程序执行效率;但如果始终获取不到锁的线程会进行自旋,而自旋动作是需要消耗CPU的;所以轻量级锁适用的是追求响应时间、同时同步代码块执行速度快的业务场景。
- 重量级锁的优点是不需要自旋消耗CPU;但缺点很明显,线程会阻塞、响应时间也慢;重量级锁更适用在同步代码块执行速度较长的业务场景。
2. volatile
2.1 指令重排序
面试官:重排序知道吧?
指令重排序字面上听起来很高级,但只要理解了并不难掌握。我们先来看看指令重排序究竟有什么作用。
指令重排序的主要作用是可以优化编译器和处理器的执行效率,提高程序性能。例如多条执行顺序不同的指令,可以重排序让轻耗时的指令先执行,从而让出CPU流水线资源供其他指令使用。
但如果指令之间存在着数据依赖关系,则编译器和处理器不会对相关操作进行指令重排序,避免程序执行结果改变。这个规则也称为as-if-serial语义
。例如以下代码。
String book = "JavaGetOffer"; // A
String avator = "思考的陈"; // B
String msg = book + abator; // C
对于A、B,它们之间并没有依赖关系,谁先执行对程序的结果没有任何影响。但C却依赖于A、B,不能出现类似C -> A -> B或C -> B -> A或A -> C -> B或B -> C -> A之类的指令重排,否则程序执行结果将改变。
2.2 重排序的问题
面试官:那重排序不会有什么问题吗?
在单线程环境下,有as-if-serial语义
的保护,我们无需担心程序执行结果被改变。但在多线程环境下,指令重排序会出现数据不一致的问题。举个多线程的例子方便大家理解。
int number = 0;
boolean flag = false;
public void method1() {
number = 6; // A
flag = true; // B
}
public void method2() {
if (flag) { // C
int i = number * 6; // D
}
}
假如现在有两个线程,线程1执行method1
、线程2执行method2
。因为method1
其中的A、B之间没有数据依赖关系,可能出现B -> A的指令重排序,大家注意这个指令重排序会影响到线程2执行的结果。
当B指令执行后A指令还没有执行number = 6
,此时如果线程2执行method2
同时给i赋值为0 * 6
。很明显程序运行结果和我们预期的并不一致。
2.3 volatile特性
面试官:有什么办法可以解决?
关于上文的重排序问题,可以使用volatile关键字来解决。volatile一共有以下特性:
- 可见性。volatile修饰的变量每次被修改后的值,对于任何线程都是可见的,即任何线程会读取到最后写入的变量值。
- 原子性。volatile变量的读写具有原子性。
- 禁止代码重排序。对于volatile变量操作的相关代码不允许重排序。
int number = 0;
volatile boolean flag = false;
public void method1() {
number = 6; // A
flag = true; // B
}
public void method2() {
if (flag) { // C
int i = number * 6; // D
}
}
由于volatile具有禁止代码重排序的特性,所以不会出现上文的B -> A的指令重排序。另外volatile具有可见性,falg的修改对线程2来说是可见的,线程会立刻感知到flag = ture
从而执行对i的赋值。以上问题可以通过volatile解决,和使用synchronized加锁是一样的效果。
另外大家注意一点,volatile的原子性指的是对volatile的读、写操作的原子性,但类似于volatile++
这种复合操作是没有原子性的。
2.5 可见性原理
面试官:那volatile可见性的原理是什么?
内存一共分为两种,线程的本地内存和线程外的主内存。对于一个volatile修饰的变量,任何线程对该变量的修改都会同步到主内存。而当读一个volatile修饰的变量时,JMM(Java Memory Model)会把该线程对应的本地内存置为无效,从而线程读取变量时读取的是主内存。
线程每次读操作都是读取主内存中最新的数据,所以volatile能够实现可见性的特性。
2.3 volatile局限性
面试官:volatile有什么缺点吗?
企业生产上还是比较少用到volatile的,对于加锁操作会使用的更多些。
- synchronized加锁操作虽然开销比volatile大,但却适合复杂的业务场景。而volatile只适用于状态独立的场景,例如上文对flag变量的读写。
- volatile编写的代码是比较难以理解的,不清楚整个流程和原理很难维护代码。
- 类似于
volatile++
这种复合操作,volatile不能确保原子性。
⭐⭐⭐本文收录在《Java学习/进阶/面试指南》:https://github/JavaSouth
我是南哥,南就南在Get到你的点赞点赞点赞。
创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️
来源:juejin.cn/post/7435894119103430665
如何进行千万级别数据跑批优化
最近观看公司前辈文档,看到对大数据量跑批的优化方案,参照自己的理解和之前相关经验整理了一份优化方案~
Background
定义: 跑批通常指代的是我们应用程序在固定日期针对某一批大量数据定时进行特定的处理,在金融业务中一般跑批的场景有分户日结、账务计提、账单逾期、不良资产处理等等,它具有高连贯性特点,通常我们执行完跑批后还要对跑批数据进行进一步处理,比如发 MQ 给下游消费,数仓拉取分析等。。。
跑批最怕的就是上来就干,从不考虑涉及到第三方接口时的响应时间、大事务等问题。
Problem
针对大数据量跑批会有很多的问题,比如我们要在指定日期指定时间的大数据量去处理,还要保证处理期间尽可能的高效,在出现错误时也要进行相应的补偿措施,避免影响到其它业务等 ~
- OOM : 查询跑批数据,未进行分片处理,随着业务纵向发展数据膨胀一旦上来,就容易导致 OOM 悲剧;
- 未对数据进行批量处理: 针对业务中间的处理未采用批量处理的思维,造成花费大量的时间,另外频繁的 IO 也是问题之一;
- 避免大事务: 直接用 @Transaction 去覆盖所有的业务是不可取的,问题定位困难不说,方法处理时间变久了;
- 下游接口的承受能力: 下游的承载能力也要在我们的考虑范围之内,比如大数量分批一直发,你是爽了,下游没有足够的能力消费就会造成灾难性的问题;
- 任务时间上的隔离: 通常大数据量跑批后面还有一些业务上的处理,对于时间和健壮性上要严格控制;
- 失败任务补偿: 分布式任务调度创建跑批任务,然后拆分子任务并发到消息队列,线程池执行任务调用远程接口,这中间的任何步骤都有可能会出问题导致任务失败;
Analyze
通过以上问题的总结,我们可以得出要完整的进行大数据量跑批任务我们的代码设计需要具备以下的几点素质:
- 健壮性: 跑批任务是要通过定时的去处理这些数据,不能因为其中一条数据出现异常从而导致整批数据无法继续进行操作,所以它必须是健壮的;
- 可靠性: 针对于异常数据我们后续可进行补偿处理,所以它必须是可靠的;
- 隔离性: 避免干扰任何其他应用程序的正常运行;
- 高性能: 通常跑批任务要处理的数据量较大,我们不能让它处理的时间过于久,这样会挤压后续的其它连贯性业务处理时间,所以我们必须考虑其性能处理;
Solution
大数据量的数据是很庞大的,如果一次性都加载到内存里面将会是灾难性的后果,因此我们要对大数据量数据进行分割处理,这是防止 OOM 必要的一环!此外,监控、异常等方法措施也要实施到位,到问题出现再补救就晚了~
1、数据库问题
使用数据库扫表问题:
遍历数据对数据库的压力是很大的,越往后速度越慢;
解决:
遍历数据库越往后查压力越大,可以设置在每次查询的时候携带上一次的极值,让你分页查找的offect永远控制在0;
2、分片广播
分片: 在生产环境中,都是采用集群部署,如果一个跑批任务只跑在一个机器上,那效率肯定很低,我们可以利用 xxl-job「分片广播」 和 「动态分片」 功能;
分布式调度幂等: 分布式任务调度只能保证准时调到一个节点上,而且通常都有失败重试的功能。所以任务幂等都是要的,一般都是通过分布式锁来实现,这里遵循简单原则使用数据库就可以了,可以通过在任务表里 insert 一条唯一的任务记录,通过唯一键来防止重复调度。
除了用唯一键,还可以在记录中增加一个状态字段,使用乐观锁来更新状态。比如开始是初始化状态,更新成正在运行的状态,更新失败说明别的节点已经在跑这个任务。当然分布式锁的实现方案有很多,比如 redis、zk 等等。
集群分布式任务调度 xxl-job: 执行器集群部署时,“分片广播” 以执行器为维度进行分片,任务路由策略选择”分片广播”情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;
- 分片任务场景:10个执行器的集群来处理10w条数据,每台机器只需要处理1w条数据,耗时降低10倍;
- 广播任务场景:广播执行器机器运行shell脚本、广播集群节点进行缓存更新等
// Index 是属于 Total 第几个序列(从0开始)
int shardIndex = XxlJobHelper.getShardIndex();
// Total 是总的执行器数量
int shardTotal = XxlJobHelper.getShardTotal();
3、分批获取
- 设置步长: 分派到一个 Pod 负责的数据也是庞大的,一下查出来耗时太久容易导致超时,通常我们会引入步长的概念,比如分派给 Pod 1w条数据,我们可以将它划分 10 次查出,一次查出 1k 数据,进而避免了数据库查询数据耗时太久 ~
- 空间换时间: 跑批可能会涉及到数据准备的过程,边循环跑批数据边去查找所需的数据,涉及多个 for 嵌套的循环处理时,可以采用空间换时间的思想,将数据加载到内存中进行筛选查找,但是要做好 OOM 防范措施,比如用包装类接查找出来的数据等等,毕竟内存不是无限大的!
- 深分页: 分批查询时 limit 的偏移量越大,执行时间越长。比如 limit a, b 会查询前 a + b 条数据,然后丢弃前 a 条数据,select * 会查询所有的列,也会有回表操作。我们可以使用 子查询 优化 SQL ,先查出 id 后分页,尽量用覆盖索引 来优化;
4、事务控制
- 这些操作自身是无法回滚的,这就会导致数据的不一致。可能 RPC 调用成功了,但是本地事务回滚了,可是 PRC 调用无法回滚了;
- 在事务中有远程调用,就会拉长整个事务导致本事务的数据库连接一直被占用,从而导致数据库连接池耗尽或者单个链接超时,因此要熟悉调用链路,将事务粒度控制在最小范围内;
5、充分利用服务器资源
需要充分利用服务器资源,采用多线程,MySQL的CPU在罚息期间也是低于 50%、IOPS 使用率低于 50%;
其实跑数据是 io 密集型的,不需要非得压榨服务器资源 ~
6、MQ 消费任务并行
MQ 消费消息队列的消息时要在每个节点上同时跑多个子任务才能资源利用最大化。那么就使用到线程池了,如果选择的是Kafka或者 RocketMQ,他们的客户端本来就是线程池消费的,只需要合理调整客户端参数就可以了。如果使用的是 Redis,那就需要自己创建一个线程池,然后让一个 EventLoop 线程从 Redis 队列中取任务。放入线程池中运行,因为我们已经使用 Redis 队列做缓冲,所以线程池的队列长度设为0,这里直接使用JDK提供的 SynchronousQueue。(这里以java为例)
7、动态调整并发度
跑批任务中能动态调整速度是很重要的,有 2 个地方可以进行操作:
- 任务中调用远程接口,这个速度控制其实用 Thread.sleep() 就好了。
- 控制任务并发度,就是有多少个线程同时运行任务。这个控制可以通过调整线程池的线程数来实现,但是线程池动态调整线程数比较麻烦。动态调整可以通过开源的限流组件来实现,比如 Guava 的 RateLimiter。可以在每次调用远程接口前调用限流组件来控制并发速度。
8、失败任务如何继续
一般分布式调度路径:
- 分布式 任务调度创建跑批任务;
- 拆分子任务 多线程 并发的发送到 消息队列 ;
- 线程池 执行任务调用远程接口;
在这个链条中,可能导致任务失败或者中止的原因无非下面几个。
- 服务器 Pod 因为其它业务影响重启导致任务中止;
- 任务消费过程中失败,达到最大的重试次数;
- 业务逻辑不合理或者数据膨胀导致 OOM ;
- 消费时调用远程接口超时(这个很多人专注自己的业务逻辑从而忽略第三方接口的调用)
其实解决起来也简单,因为其它因素导致失败,你需要记录下任务的进度,然后在失败的点去再次重试 ~
- 记录进度: 我们需要知道这个任务执行到哪里了,同时也要记录更新的时间,这样才知道补偿哪里,比如进行跑批捞取时,要记录我们捞取的数据区间 ~
- 任务重试: 编写一个补偿式的任务(比如FixJob),定时的去扫面处在中间态的任务,如果扫到就触发补偿机制,将这个任务改成待执行状态投入消息队列;
9、下游接口时间
跑批最怕的就是上来就干,从不考虑涉及到第三方接口时的响应时间,如果不考虑第三方接口调用时间,那么在测试时候你会发现频繁的 YGC,这是很致命的问题,属于你设计之外的事件,但也是你必须要考虑的~
解决起来也简单,在业务可以容忍的情况下,我们可以将调用接口的业务逻辑设计一个中间态,然后挂起我们的这个业务,随后用定时任务去查询我们的业务结果,在收到信息后继续我们的业务逻辑,避免它一直在内存中堆积 ~
10、线程安全
在进行跑批时,一般会采用多线程的方式进行处理,因此要考虑线程安全的问题,比如使用线程安全的容器,使用JUC包下的工具类。
11、异常 & 监控
- 异常: 要保证程序的健壮性,做好异常处理,不能因为一处报错,导致整个任务执行失败,对于异常的数据可以跳过,不影响其他数据的正常执行;
- 监控: 一般大数据量跑批是业务核心中的核心,一次异常就是很大的灾难,对业务的损伤不可预估,因此要配置相应的监控措施,在发送异常前及时察觉,进而做补偿措施;
Reference
来源:juejin.cn/post/7433315676051406888
别再混淆了!一文带你搞懂@Valid和@Validated的区别
上篇文章我们简单介绍和使用了一下Springboot的参数校验,同时也用到了 @Valid 注解和 @Validated 注解,那它们之间有什么不同呢?
区别
先总结一下它们的区别:
- 来源
- @Validated :是Spring框架特有的注解,属于Spring的一部分,也是JSR 303的一个变种。它提供了一些 @Valid 所没有的额外功能,比如分组验证。
- @Valid:Java EE提供的标准注解,它是JSR 303规范的一部分,主要用于Hibernate Validation等场景。
- 注解位置
- @Validated : 用在类、方法和方法参数上,但不能用于成员属性。
- @Valid:可以用在方法、构造函数、方法参数和成员属性上。
- 分组
- @Validated :支持分组验证,可以更细致地控制验证过程。此外,由于它是Spring专有的,因此可以更好地与Spring的其他功能(如Spring的依赖注入)集成。
- @Valid:主要支持标准的Bean验证功能,不支持分组验证。
- 嵌套验证
- @Validated :不支持嵌套验证。
- @Valid:支持嵌套验证,可以嵌套验证对象内部的属性。
这些理论性的东西没什么好说的,记住就行。我们主要看分组和嵌套验证是什么,它们怎么用。
实操阶段
话不多说,通过代码来看一下分组和嵌套验证。
为了提示友好,修改一下全局异常处理类:
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 参数校检异常
* @param e
* @return
*/
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResponseResult handle(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
StringJoiner joiner = new StringJoiner(";");
for (ObjectError error : bindingResult.getAllErrors()) {
String code = error.getCode();
String[] codes = error.getCodes();
String property = codes[1];
property = property.replace(code ,"").replaceFirst(".","");
String defaultMessage = error.getDefaultMessage();
joiner.add(property+defaultMessage);
}
return handleException(joiner.toString());
}
private ResponseResult handleException(String msg) {
ResponseResult result = new ResponseResult<>();
result.setMessage(msg);
result.setCode(500);
return result;
}
}
分组校验
分组验证是为了在不同的验证场景下能够对对象的属性进行灵活地验证,从而提高验证的精细度和适用性。一般我们在对同一个对象进行保存或修改时,会使用同一个类作为入参。那么在创建时,就不需要校验id,更新时则需要校验用户id,这个时候就需要用到分组校验了。
对于定义分组有两点要特别注意:
- 定义分组必须使用接口。
- 要校验字段上必须加上分组,分组只对指定分组生效,不加分组不校验。
有这样一个需求,在创建用户时校验用户名,修改用户时校验用户id。下面对我们对这个需求进行一个简单的实现。
- 创建分组
CreationGr0up 用于创建时指定的分组:
public interface CreationGr0up {
}
UpdateGr0up 用于更新时指定的分组:
public interface UpdateGr0up {
}
- 创建用户类
创建一个UserBean用户类,分别校验 username
字段不能为空和id
字段必须大于0,然后加上CreationGr0up
和 UpdateGr0up
分组。
/**
* @author 公众号-索码理(suncodernote)
*/
@Data
public class UserBean {
@NotEmpty( groups = {CreationGr0up.class})
private String username;
@Min(value = 18)
private Integer age;
@Email(message = "邮箱格式不正确")
private String email;
@Min(value = 1 ,groups = {UpdateGr0up.class})
private Long id;
}
- 创建接口
在ValidationController 中新建两个接口 updateUser
和 createUser
:
@RestController
@RequestMapping("validation")
public class ValidationController {
@GetMapping("updateUser")
public UserBean updateUser(@Validated({UpdateGr0up.class}) UserBean userBean){
return userBean;
}
@GetMapping("createUser")
public UserBean createUser(@Validated({CreationGr0up.class}) UserBean userBean){
return userBean;
}
}
- 测试
先对 createUser
接口进行测试,我们将id的值设置为0,也就是不满足id必须大于0的条件,同样 username 不传值,即不满足 username 不能为空的条件。 通过测试结果我们可以看到,虽然id没有满足条件,但是并没有提示,只提示了username不能为空。
再对 updateUser
接口进行测试,条件和测试 createUser
接口的条件一样,再看测试结果,和 createUser
接口测试结果完全相反,只提示了id最小不能小于1。
至此,分组功能就演示完毕了。
嵌套校验
介绍嵌套校验之前先看一下两个概念:
- 嵌套校验(Nested Validation) 指的是在验证对象时,对对象内部包含的其他对象进行递归验证的过程。当一个对象中包含另一个对象作为属性,并且需要对这个被包含的对象也进行验证时,就需要进行嵌套校验。
- 嵌套属性指的是在一个对象中包含另一个对象作为其属性的情况。换句话说,当一个对象的属性本身又是一个对象,那么这些被包含的对象就可以称为嵌套属性。
有这样一个需求,在保存用户时,用户地址必须要填写。下面来简单看下示例:
- 创建地址类 AddressBean
在AddressBean 设置 country
和city
两个属性为必填项。
@Data
public class AddressBean {
@NotBlank
private String country;
@NotBlank
private String city;
}
- 修改用户类,将AddressBean作为用户类的一个嵌套属性
特别提示:想要嵌套校验生效,必须在嵌套属性上加 @Valid
注解。
@Data
public class UserBean {
@NotEmpty(groups = {CreationGr0up.class})
private String username;
@Min(value = 18)
private Integer age;
private String email;
@Min(value = 1 ,groups = {UpdateGr0up.class})
private Long id;
//嵌套验证必须要加上@Valid
@Valid
@NotNull
private AddressBean address;
}
- 创建一个嵌套校验测试接口
@PostMapping("nestValid")
public UserBean nestValid(@Validated @RequestBody UserBean userBean){
System.out.println(userBean);
return userBean;
}
- 测试
我们在传参时,只传 country
字段,通过响应结果可以看到提示了city
字段不能为空。
可以看到使用了 @Valid
注解来对 Address 对象进行验证,这会触发对其中的 Address 对象的验证。通过这种方式,可以确保嵌套属性内部的对象也能够参与到整体对象的验证过程中,从而提高验证的完整性和准确性。
总结
本文介绍了@Valid
注解和@Validated
注解的不同,同时也进一步介绍了Springboot 参数校验的使用。不管是 JSR-303、JSR-380又或是 Hibernate Validator ,它们提供的参数校验注解都是有限的,实际工作中这些注解可能是不够用的,这个时候就需要我们自定义参数校验了。下篇文章将介绍一下如何自定义一个参数校验器。
来源:juejin.cn/post/7344958089429434406
Java 实现责任链模式 + 策略模式:优雅处理多级请求的方式
一、什么是责任链模式?
责任链模式(Chain of Responsibility Pattern) 是一种行为设计模式,它允许将请求沿着一个处理链传递,直到链中的某个对象处理它。这样,发送者无需知道哪个对象将处理请求,所有的处理对象都可以尝试处理请求或将请求传递给链上的下一个对象。

核心思想:将请求的发送者与接收者解耦,通过让多个对象组成一条链,使得请求沿着链传递,直到被处理。
责任链模式(Chain of Responsibility Pattern) 是一种行为设计模式,它允许将请求沿着一个处理链传递,直到链中的某个对象处理它。这样,发送者无需知道哪个对象将处理请求,所有的处理对象都可以尝试处理请求或将请求传递给链上的下一个对象。
核心思想:将请求的发送者与接收者解耦,通过让多个对象组成一条链,使得请求沿着链传递,直到被处理。
二、责任链模式的特点
- 解耦请求发出者和处理者:请求的发送者不需要知道具体的处理者是谁,增强了系统的灵活性和扩展性。
- 动态组合处理逻辑:可以根据需要动态改变链的结构,添加或移除处理者。
- 职责单一:责任链模式可以将每个验证逻辑封装到一个独立的处理器中,每个处理器负责单一的验证职责,符合单一职责原则。
- 可扩展性: 增加新的验证逻辑时,处理者只需继承一个统一的接口,并添加新的处理器,而不需要修改现有的代码。
- 清晰的流程: 将所有验证逻辑组织在一起,使得代码结构更加清晰,易于理解。
- 解耦请求发出者和处理者:请求的发送者不需要知道具体的处理者是谁,增强了系统的灵活性和扩展性。
- 动态组合处理逻辑:可以根据需要动态改变链的结构,添加或移除处理者。
- 职责单一:责任链模式可以将每个验证逻辑封装到一个独立的处理器中,每个处理器负责单一的验证职责,符合单一职责原则。
- 可扩展性: 增加新的验证逻辑时,处理者只需继承一个统一的接口,并添加新的处理器,而不需要修改现有的代码。
- 清晰的流程: 将所有验证逻辑组织在一起,使得代码结构更加清晰,易于理解。
三、责任链模式和策略模式结合的意义
- 责任链模式的作用:
- 用于动态处理请求,将多个处理逻辑串联起来。
- 策略模式的作用:
- 用于封装一组算法,使得可以在运行时动态选择需要的算法。
结合两者:
- 责任链模式负责串联和传递请求,而策略模式定义了每一个处理者的具体处理逻辑。
- 两者结合可以实现既动态构建责任链,又灵活应用不同策略来处理请求的需求。
- 责任链模式的作用:
- 用于动态处理请求,将多个处理逻辑串联起来。
- 策略模式的作用:
- 用于封装一组算法,使得可以在运行时动态选择需要的算法。
结合两者:
- 责任链模式负责串联和传递请求,而策略模式定义了每一个处理者的具体处理逻辑。
- 两者结合可以实现既动态构建责任链,又灵活应用不同策略来处理请求的需求。
四、责任链模式解决的问题
- 耦合过高:将请求的处理者从请求的发送者中解耦,使得处理者可以独立扩展或变更。
- 复杂的多条件判断:避免在代码中使用过多
if-else
或 switch-case
语句。 - 灵活性不足:通过链的动态组合可以轻松调整请求的传递逻辑或插入新的处理者。
- 代码重复:每个处理者只专注于处理它关心的部分,减少重复代码。
- 耦合过高:将请求的处理者从请求的发送者中解耦,使得处理者可以独立扩展或变更。
- 复杂的多条件判断:避免在代码中使用过多
if-else
或switch-case
语句。 - 灵活性不足:通过链的动态组合可以轻松调整请求的传递逻辑或插入新的处理者。
- 代码重复:每个处理者只专注于处理它关心的部分,减少重复代码。
五、代码中的责任链模式解析
场景 1:商品上架逻辑(多重校验)
实现一个类似的场景——商品上架逻辑(如校验商品信息、库存信息等),可以按照以下步骤实现:
- 定义责任链抽象接口
public interface MerchantAdminAbstractChainHandler extends Ordered {
/**
* 执行责任链逻辑
*
* @param requestParam 责任链执行入参
*/
void handler(T requestParam);
/**
* @return 责任链组件标识
*/
String mark();
}
- 定义商品上架的责任链标识:
public enum ChainBizMarkEnum {
MERCHANT_ADMIN_CREATE_PRODUCT_TEMPLATE_KEY,
MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY; // 新增商品上架责任链标识
}
- 定义每个处理器的通用行为:
@Component
public final class MerchantAdminChainContext implements ApplicationContextAware, CommandLineRunner {
/**
* 应用上下文,通过Spring IOC获取Bean实例
*/
private ApplicationContext applicationContext;
/**
* 保存商品上架责任链实现类
*
* Key:{@link MerchantAdminAbstractChainHandler#mark()}
* Val:{@link MerchantAdminAbstractChainHandler} 一组责任链实现 Spring Bean 集合
*
* 比如有一个商品上架模板创建责任链,实例如下:
* Key:MERCHANT_ADMIN_CREATE_PRODUCT_TEMPLATE_KEY
* Val:
* - 验证商品信息基本参数是否必填 —— 执行器 {@link ProductInfoNotNullChainFilter}
* - 验证商品库存 —— 执行器 {@link ProductInventoryCheckChainFilter}
*/
private final Map> abstractChainHandlerContainer = new HashMap<>();
/**
* 责任链组件执行
* @param mark 责任链组件标识
* @param requestObj 请求参数
*/
public void handler(String mark,T requestObj){
// 根据 mark 标识从责任链容器中获取一组责任链实现 Bean 集合
List abstractChainHandlers = abstractChainHandlerContainer.get(mark);
if (CollectionUtils.isEmpty(abstractChainHandlers)) {
throw new RuntimeException(String.format("[%s] Chain of Responsibility ID is undefined.", mark));
}
abstractChainHandlers.forEach(each -> each.handler(requestObj));
}
/**
* 执行方法,接收可变参数
* 本方法主要用于初始化和处理商品上架抽象责任链容器
* 它从Spring容器中获取所有MerchantAdminAbstractChainHandler类型的Bean,
* 并根据它们的mark进行分类和排序,以便后续处理
*
* @param args 可变参数,可能包含方法运行所需的额外信息
* @throws Exception 如果方法执行过程中遇到错误,抛出异常
*/
@Override
public void run(String... args) throws Exception {
// 从 Spring IOC 容器中获取指定接口 Spring Bean 集合
Map chainFilterMap = applicationContext.getBeansOfType(MerchantAdminAbstractChainHandler.class);
// 遍历所有获取到的Bean,并将它们根据mark分类存入抽象责任链容器中
chainFilterMap.forEach((beanName, bean) -> {
// 判断 Mark 是否已经存在抽象责任链容器中,如果已经存在直接向集合新增;如果不存在,创建 Mark 和对应的集合
List abstractChainHandlers = abstractChainHandlerContainer.getOrDefault(bean.mark(), new ArrayList<>());
abstractChainHandlers.add(bean);
abstractChainHandlerContainer.put(bean.mark(), abstractChainHandlers);
});
// 遍历抽象责任链容器,对每个 Mark 对应的责任链实现类集合进行排序
abstractChainHandlerContainer.forEach((mark, chainHandlers) -> {
// 对每个 Mark 对应的责任链实现类集合进行排序,优先级小的在前
chainHandlers.sort(Comparator.comparing(Ordered::getOrder));
});
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
- 定义商品上架的责任链处理器:
@Component
public class ProductInfoNotNullChainFilter implements MerchantAdminAbstractChainHandler {
@Override
public void handler(ProductUpShelfReqDTO requestParam) {
if (StringUtils.isEmpty(requestParam.getProductName())) {
throw new RuntimeException("商品名称不能为空!");
}
if (requestParam.getPrice() == null || requestParam.getPrice() <= 0) {
throw new RuntimeException("商品价格必须大于0!");
}
System.out.println("商品信息非空校验通过");
}
@Override
public int getOrder() {
return 1;
}
@Override
public String mark() {
return ChainBizMarkEnum.MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY.name();
}
}
@Component
public class ProductInventoryCheckChainFilter implements MerchantAdminAbstractChainHandler {
@Override
public void handler(ProductUpShelfReqDTO requestParam) {
if (requestParam.getStock() <= 0) {
throw new RuntimeException("商品库存不足,无法上架!");
}
System.out.println("商品库存校验通过");
}
@Override
public int getOrder() {
return 2;
}
@Override
public String mark() {
return ChainBizMarkEnum.MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY.name();
}
}
- 调用责任链进行处理:
@Service
@RequiredArgsConstructor
public class ProductServiceImpl {
private final MerchantAdminChainContext merchantAdminChainContext;
public void upShelfProduct(ProductUpShelfReqDTO requestParam) {
// 调用责任链进行校验
merchantAdminChainContext.handler(
ChainBizMarkEnum.MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY.name(),
requestParam
);
System.out.println("商品上架逻辑开始执行...");
// 后续的商品上架业务逻辑
}
}
实现一个类似的场景——商品上架逻辑(如校验商品信息、库存信息等),可以按照以下步骤实现:
- 定义责任链抽象接口
public interface MerchantAdminAbstractChainHandler extends Ordered {
/**
* 执行责任链逻辑
*
* @param requestParam 责任链执行入参
*/
void handler(T requestParam);
/**
* @return 责任链组件标识
*/
String mark();
}
- 定义商品上架的责任链标识:
public enum ChainBizMarkEnum {
MERCHANT_ADMIN_CREATE_PRODUCT_TEMPLATE_KEY,
MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY; // 新增商品上架责任链标识
}
@Component
public final class MerchantAdminChainContext implements ApplicationContextAware, CommandLineRunner {
/**
* 应用上下文,通过Spring IOC获取Bean实例
*/
private ApplicationContext applicationContext;
/**
* 保存商品上架责任链实现类
*
* Key:{@link MerchantAdminAbstractChainHandler#mark()}
* Val:{@link MerchantAdminAbstractChainHandler} 一组责任链实现 Spring Bean 集合
*
* 比如有一个商品上架模板创建责任链,实例如下:
* Key:MERCHANT_ADMIN_CREATE_PRODUCT_TEMPLATE_KEY
* Val:
* - 验证商品信息基本参数是否必填 —— 执行器 {@link ProductInfoNotNullChainFilter}
* - 验证商品库存 —— 执行器 {@link ProductInventoryCheckChainFilter}
*/
private final Map
/**
* 责任链组件执行
* @param mark 责任链组件标识
* @param requestObj 请求参数
*/
public void handler(String mark,T requestObj){
// 根据 mark 标识从责任链容器中获取一组责任链实现 Bean 集合
List
if (CollectionUtils.isEmpty(abstractChainHandlers)) {
throw new RuntimeException(String.format("[%s] Chain of Responsibility ID is undefined.", mark));
}
abstractChainHandlers.forEach(each -> each.handler(requestObj));
}
/**
* 执行方法,接收可变参数
* 本方法主要用于初始化和处理商品上架抽象责任链容器
* 它从Spring容器中获取所有MerchantAdminAbstractChainHandler类型的Bean,
* 并根据它们的mark进行分类和排序,以便后续处理
*
* @param args 可变参数,可能包含方法运行所需的额外信息
* @throws Exception 如果方法执行过程中遇到错误,抛出异常
*/
@Override
public void run(String... args) throws Exception {
// 从 Spring IOC 容器中获取指定接口 Spring Bean 集合
Map
// 遍历所有获取到的Bean,并将它们根据mark分类存入抽象责任链容器中
chainFilterMap.forEach((beanName, bean) -> {
// 判断 Mark 是否已经存在抽象责任链容器中,如果已经存在直接向集合新增;如果不存在,创建 Mark 和对应的集合
List
abstractChainHandlers.add(bean);
abstractChainHandlerContainer.put(bean.mark(), abstractChainHandlers);
});
// 遍历抽象责任链容器,对每个 Mark 对应的责任链实现类集合进行排序
abstractChainHandlerContainer.forEach((mark, chainHandlers) -> {
// 对每个 Mark 对应的责任链实现类集合进行排序,优先级小的在前
chainHandlers.sort(Comparator.comparing(Ordered::getOrder));
});
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
@Component
public class ProductInfoNotNullChainFilter implements MerchantAdminAbstractChainHandler {
@Override
public void handler(ProductUpShelfReqDTO requestParam) {
if (StringUtils.isEmpty(requestParam.getProductName())) {
throw new RuntimeException("商品名称不能为空!");
}
if (requestParam.getPrice() == null || requestParam.getPrice() <= 0) {
throw new RuntimeException("商品价格必须大于0!");
}
System.out.println("商品信息非空校验通过");
}
@Override
public int getOrder() {
return 1;
}
@Override
public String mark() {
return ChainBizMarkEnum.MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY.name();
}
}
@Component
public class ProductInventoryCheckChainFilter implements MerchantAdminAbstractChainHandler {
@Override
public void handler(ProductUpShelfReqDTO requestParam) {
if (requestParam.getStock() <= 0) {
throw new RuntimeException("商品库存不足,无法上架!");
}
System.out.println("商品库存校验通过");
}
@Override
public int getOrder() {
return 2;
}
@Override
public String mark() {
return ChainBizMarkEnum.MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY.name();
}
}
@Service
@RequiredArgsConstructor
public class ProductServiceImpl {
private final MerchantAdminChainContext merchantAdminChainContext;
public void upShelfProduct(ProductUpShelfReqDTO requestParam) {
// 调用责任链进行校验
merchantAdminChainContext.handler(
ChainBizMarkEnum.MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY.name(),
requestParam
);
System.out.println("商品上架逻辑开始执行...");
// 后续的商品上架业务逻辑
}
}
上述代码实现了一个基于 责任链模式 的电商系统,主要用于处理复杂的业务逻辑,如商品上架模板的创建。这种模式的设计使得每个业务逻辑通过一个独立的处理器(Handler)进行处理,并将这些处理器串联成一个链,通过统一的入口执行每一步处理操作。
1. 代码的组成部分与职责解析
(1) 责任链抽象接口:MerchantAdminAbstractChainHandler
- 定义了责任链中的基础行为:
void handler(T requestParam)
- 定义了该处理器的具体逻辑。
- 这是责任链的核心方法,每个处理器都会接收到传入的参数
requestParam
,并根据具体的业务逻辑进行相应的处理。
- 设计思想:
T
是一个泛型参数,可以适配不同类型的业务场景(如对象校验、数据处理等)。- 如果某个处理器不满足条件,可以抛出异常或者提供返回值来中断后续处理器的运行。
- 每个处理器只负责完成自己的一部分逻辑,保持模块化设计。
(2) 抽象处理器接口:MerchantAdminAbstractChainHandler
- 定义了责任链中每个节点的通用行为:
void handler(T requestParam)
- 责任链的核心方法,定义了如何处理传入的请求参数
requestParam
。 - 每个实现类都会根据具体的业务需求,在该方法中实现自己的处理逻辑,
比如参数校验、数据转换
等。 - 如果某个处理环节中发生错误,可以通过抛出异常中断责任链的执行。
handler(T requestParam)
:执行具体的处理逻辑。mark()
:返回处理器所属的责任链标识(Mark
)。
- 责任链的核心方法,定义了如何处理传入的请求参数
String mark()
- 返回当前处理器所属的责任链标识(Mark)。
- 不同的责任链可以通过
mark()
值进行分组管理。 - 比如在商品上架创建责任链中,
mark()
可以返回MERCHANT_ADMIN_CREATE_PRODUCT_TEMPLATE_KEY
。
int getOrder()
- 用于定义处理器的执行顺序。
- 通过实现
Ordered
接口的getOrder()
方法,开发者可以灵活地控制每个处理器在责任链中的执行顺序。 - 默认值为
Ordered.LOWEST_PRECEDENCE
(优先级最低),可以根据需求覆盖此方法返回更高的优先级(数值越小优先级越高)。
- 通过继承
Ordered
接口来用于指定处理器的执行顺序,优先级小的会先执行。(模版如下)
import org.springframework.core.Ordered;
/**
* 商家上架责任链处理器抽象接口
*
* @param 处理参数的泛型类型(比如请求参数)
*/
public interface MerchantAdminAbstractChainHandler extends Ordered {
/**
* 执行责任链的具体逻辑
*
* @param requestParam 责任链执行的入参
*/
void handler(T requestParam);
/**
* 获取责任链处理器的标识(mark)
*
* 每个处理器所属的责任链标识需要唯一,用于区分不同的责任链。
*
* @return 责任链组件标识
*/
String mark();
/**
* 获取责任链执行顺序
*
* Spring 的 {@link Ordered} 接口方法,数值越小优先级越高。
* 默认返回 `Ordered.LOWEST_PRECEDENCE`,表示优先级最低。
*
* @return 处理器的执行顺序。
*/
@Override
default int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
(2) 责任链上下文:MerchantAdminChainContext
- 负责管理责任链的初始化和执行:
- 在 Spring 容器启动时 (
CommandLineRunner
),扫描实现了MerchantAdminAbstractChainHandler
接口的所有 Spring Bean,并根据它们的mark()
属性将它们归类到不同的链条中。 - 在链条内部,根据
Ordered
的优先级对处理器进行排序。 - 提供统一的
handler()
方法,根据标识 (Mark
) 执行对应的责任链。
- 在 Spring 容器启动时 (
(3) 业务服务层:ProductInventoryCheckChainFilter
- 通过
MerchantAdminChainContext
调用对应的责任链,完成业务参数校验逻辑。 - 责任链完成校验后,后续可以继续执行其他具体的业务逻辑。
责任链的执行流程
通过 MerchantAdminChainContext
,上述两个处理器会被自动扫描并加载到责任链中。运行时,根据 mark()
和 getOrder()
的值,系统自动按顺序执行它们。
五、Java 实现责任链模式 + 策略模式
以下是实现一个责任链 + 策略模式的完整 Java 示例。
场景:模拟用户请求的审核流程(如普通用户审批、管理员审批、高级管理员审批),并结合不同策略处理请求。
1. 定义处理请求的接口
// 抽象处理者接口
public interface RequestHandler {
// 设置下一个处理者
void setNextHandler(RequestHandler nextHandler);
// 处理请求的方法
void handleRequest(UserRequest request);
}
// 抽象处理者接口
public interface RequestHandler {
// 设置下一个处理者
void setNextHandler(RequestHandler nextHandler);
// 处理请求的方法
void handleRequest(UserRequest request);
}
2. 定义用户请求类
// 请求类
public class UserRequest {
private String userType; // 用户类型(普通用户、管理员等)
private String requestContent; // 请求内容
public UserRequest(String userType, String requestContent) {
this.userType = userType;
this.requestContent = requestContent;
}
public String getUserType() {
return userType;
}
public String getRequestContent() {
return requestContent;
}
}
// 请求类
public class UserRequest {
private String userType; // 用户类型(普通用户、管理员等)
private String requestContent; // 请求内容
public UserRequest(String userType, String requestContent) {
this.userType = userType;
this.requestContent = requestContent;
}
public String getUserType() {
return userType;
}
public String getRequestContent() {
return requestContent;
}
}
3. 定义不同的策略(处理逻辑)
// 策略接口
public interface RequestStrategy {
void process(UserRequest request);
}
// 普通用户处理策略
public class BasicUserStrategy implements RequestStrategy {
@Override
public void process(UserRequest request) {
System.out.println("普通用户的请求正在处理:" + request.getRequestContent());
}
}
// 管理员处理策略
public class AdminUserStrategy implements RequestStrategy {
@Override
public void process(UserRequest request) {
System.out.println("管理员的请求正在处理:" + request.getRequestContent());
}
}
// 高级管理员处理策略
public class SuperAdminStrategy implements RequestStrategy {
@Override
public void process(UserRequest request) {
System.out.println("高级管理员的请求正在处理:" + request.getRequestContent());
}
}
// 策略接口
public interface RequestStrategy {
void process(UserRequest request);
}
// 普通用户处理策略
public class BasicUserStrategy implements RequestStrategy {
@Override
public void process(UserRequest request) {
System.out.println("普通用户的请求正在处理:" + request.getRequestContent());
}
}
// 管理员处理策略
public class AdminUserStrategy implements RequestStrategy {
@Override
public void process(UserRequest request) {
System.out.println("管理员的请求正在处理:" + request.getRequestContent());
}
}
// 高级管理员处理策略
public class SuperAdminStrategy implements RequestStrategy {
@Override
public void process(UserRequest request) {
System.out.println("高级管理员的请求正在处理:" + request.getRequestContent());
}
}
4. 实现责任链模式的处理者
// 具体处理者,结合策略
public class RequestHandlerImpl implements RequestHandler {
private RequestStrategy strategy; // 策略
private RequestHandler nextHandler; // 下一个处理者
public RequestHandlerImpl(RequestStrategy strategy) {
this.strategy = strategy;
}
@Override
public void setNextHandler(RequestHandler nextHandler) {
this.nextHandler = nextHandler;
}
@Override
public void handleRequest(UserRequest request) {
// 策略处理
strategy.process(request);
// 将请求传递给下一个处理者
if (nextHandler != null) {
nextHandler.handleRequest(request);
}
}
}
// 具体处理者,结合策略
public class RequestHandlerImpl implements RequestHandler {
private RequestStrategy strategy; // 策略
private RequestHandler nextHandler; // 下一个处理者
public RequestHandlerImpl(RequestStrategy strategy) {
this.strategy = strategy;
}
@Override
public void setNextHandler(RequestHandler nextHandler) {
this.nextHandler = nextHandler;
}
@Override
public void handleRequest(UserRequest request) {
// 策略处理
strategy.process(request);
// 将请求传递给下一个处理者
if (nextHandler != null) {
nextHandler.handleRequest(request);
}
}
}
5. 测试责任链 + 策略模式
public class ChainStrategyExample {
public static void main(String[] args) {
// 创建策略
RequestStrategy basicStrategy = new BasicUserStrategy();
RequestStrategy adminStrategy = new AdminUserStrategy();
RequestStrategy superAdminStrategy = new SuperAdminStrategy();
// 创建责任链处理者,并设置链条
RequestHandler basicHandler = new RequestHandlerImpl(basicStrategy);
RequestHandler adminHandler = new RequestHandlerImpl(adminStrategy);
RequestHandler superAdminHandler = new RequestHandlerImpl(superAdminStrategy);
basicHandler.setNextHandler(adminHandler);
adminHandler.setNextHandler(superAdminHandler);
// 模拟用户请求
UserRequest basicRequest = new UserRequest("普通用户", "请求访问资源 A");
UserRequest adminRequest = new UserRequest("管理员", "请求修改资源 B");
UserRequest superAdminRequest = new UserRequest("高级管理员", "请求删除资源 C");
// 处理请求
System.out.println("处理普通用户请求:");
basicHandler.handleRequest(basicRequest);
System.out.println("\n处理管理员请求:");
adminHandler.handleRequest(adminRequest);
System.out.println("\n处理高级管理员请求:");
superAdminHandler.handleRequest(superAdminRequest);
}
}
public class ChainStrategyExample {
public static void main(String[] args) {
// 创建策略
RequestStrategy basicStrategy = new BasicUserStrategy();
RequestStrategy adminStrategy = new AdminUserStrategy();
RequestStrategy superAdminStrategy = new SuperAdminStrategy();
// 创建责任链处理者,并设置链条
RequestHandler basicHandler = new RequestHandlerImpl(basicStrategy);
RequestHandler adminHandler = new RequestHandlerImpl(adminStrategy);
RequestHandler superAdminHandler = new RequestHandlerImpl(superAdminStrategy);
basicHandler.setNextHandler(adminHandler);
adminHandler.setNextHandler(superAdminHandler);
// 模拟用户请求
UserRequest basicRequest = new UserRequest("普通用户", "请求访问资源 A");
UserRequest adminRequest = new UserRequest("管理员", "请求修改资源 B");
UserRequest superAdminRequest = new UserRequest("高级管理员", "请求删除资源 C");
// 处理请求
System.out.println("处理普通用户请求:");
basicHandler.handleRequest(basicRequest);
System.out.println("\n处理管理员请求:");
adminHandler.handleRequest(adminRequest);
System.out.println("\n处理高级管理员请求:");
superAdminHandler.handleRequest(superAdminRequest);
}
}
六、为何责任链模式和策略模式结合使用?
- 责任链控制流程,策略定义处理逻辑:
- 责任链模式将处理请求的逻辑连接成链,便于动态调整请求传递的流程。
- 策略模式将处理逻辑封装为独立的策略,可以灵活复用和替换。
- 职责分离:
- 责任链模式负责管理请求的传递,策略模式专注于实现具体的业务逻辑。
- 结合使用可以让代码结构更清晰,职责分配更明确。
- 增强灵活性和可扩展性:
- 责任链可以动态增删处理者,策略可以动态选择或扩展新的处理逻辑,两者结合大大增强了系统的适配性和扩展性。
- 责任链控制流程,策略定义处理逻辑:
- 责任链模式将处理请求的逻辑连接成链,便于动态调整请求传递的流程。
- 策略模式将处理逻辑封装为独立的策略,可以灵活复用和替换。
- 职责分离:
- 责任链模式负责管理请求的传递,策略模式专注于实现具体的业务逻辑。
- 结合使用可以让代码结构更清晰,职责分配更明确。
- 增强灵活性和可扩展性:
- 责任链可以动态增删处理者,策略可以动态选择或扩展新的处理逻辑,两者结合大大增强了系统的适配性和扩展性。
通过责任链模式与策略模式的结合,可以应对复杂的处理流程和多变的业务需求,同时保持代码的简洁与高内聚的设计结构。
来源:juejin.cn/post/7457366224823124003
权限模型-ABAC模型
权限模型-ABAC模型
📝 ABAC 的概念
ABAC 的概念
ABAC(Attribute-Based Access Control)基于属性的访问控制的权限模型,是一种细粒度的权限控制模型,通过对请求中的各种属性进行分析和匹配,实现对权限的灵活的、动态的控制
。
ABAC(Attribute-Based Access Control)基于属性的访问控制的权限模型,是一种细粒度的权限控制模型,通过对请求中的各种属性进行分析和匹配,实现对权限的灵活的、动态的控制
。
ABAC 的组成部分
主体(Subject)
: 发起访问资源请求的用户或实体(如应用程序、系统)。主体具有多种属性,例如角色、身份、部门、敏感级别、创建时间。
💡Tips: 实际中可能就是存储用户信息的记录表、发起请求的设备信息等。
对象(Object)
: 被访问的资源或数据。对象可以是文件、数据库表、API 接口等,同样具备多种属性,如文件名、文件类型、敏感级别、创建时间等。操作(Action)
: 用户试图对资源的操作 ,例如”读”、“写”、“创建“、”删除“、”复制“等。环境(Environment)
: 外部环境属性,如访问时间、地点、网络状态、安全级别等,用于动态调整访问策略。策略(Policy)
: 定义允许或拒绝的访问的规则。策略基于主体、对象、操作和环境属性的组合,通过逻辑规则决定是否允许访问。
💡Tips: 策略如何定义?
一般来说 策略都有自己的语法设计,以 XML、JSON 这种形式去描述一个访问策略如何构成。 策略也是访问规则,本文中不再做区分。
主体(Subject)
: 发起访问资源请求的用户或实体(如应用程序、系统)。主体具有多种属性,例如角色、身份、部门、敏感级别、创建时间。
💡Tips: 实际中可能就是存储用户信息的记录表、发起请求的设备信息等。
对象(Object)
: 被访问的资源或数据。对象可以是文件、数据库表、API 接口等,同样具备多种属性,如文件名、文件类型、敏感级别、创建时间等。操作(Action)
: 用户试图对资源的操作 ,例如”读”、“写”、“创建“、”删除“、”复制“等。环境(Environment)
: 外部环境属性,如访问时间、地点、网络状态、安全级别等,用于动态调整访问策略。策略(Policy)
: 定义允许或拒绝的访问的规则。策略基于主体、对象、操作和环境属性的组合,通过逻辑规则决定是否允许访问。
💡Tips: 策略如何定义?
一般来说 策略都有自己的语法设计,以 XML、JSON 这种形式去描述一个访问策略如何构成。 策略也是访问规则,本文中不再做区分。
ABAC 工作的基本原理
ABAC 的基本原理是:系统根据主体、对象、操作、环境的属性,以及预定义的策略,动态生成访问决策。
简单流程
- 发起请求 : 通常一个访问请求由主体、对象(资源)、环境、操作中的一个或者多个组成。每个组成部分又有各自的属性,需要用到各个组成部分的属性,去动态构建一个访问规则。
例如 中午 12 点后禁止 A 部门的人访问 B 系统这个访问规则。
中午 12 点以后:环境(时间属性)
A 部门的人:主体(身份属性)
B 系统:对象(被访问的资源)
访问: 操作
- 匹配属性:在预设的访问规则库中查找与请求匹配的规则或规则集合。
- 规则评估:根据匹配的访问规则中的具体规则来评估请求。将请求中的属性值与规则中的条件进行对比,判断请求是否满足规则中的条件。例如请求中包含了访问的时间在 12 点以后,那么访问控制系统就会对比访问时间这个属性值。
- 返回结果:向用户返回最终的规则执行的结果。
💡Tips: 图中只是演示了一个大致的工作流程,实际要设计一个 ABAC 权限系统要复杂的多。
因为所有的规则条件是动态的、逻辑也是动态执行的。
ABAC的难点
试想以下场景:
- 当前文档是文档的拥有者且是拥有者才能编辑。
- 售卖的产品只能是上海地区的用户才能可见。
- 中午 12 点后禁止 A 部门的人访问 B 系统。
如果使用 RBAC 模型很难实现以上的需求。RBAC 是静态的权限模型
,没有对象的属性动态参与计算的,所以很难实现以上场景。
ABAC 系统非常灵活但实现比较困难,
1.属性收集和管理复杂度
- 属性管理
访问规则依赖属性
和属性值
去构建,特别是动态属性(实时位置,时间),如何确保获取到最新的属性值。
💡Tips: 属性是否可以动态增加也是构建系统的一部分,例如用户属性中你增加了一项职业,那该属性的值类型和获取该值的方法如何定义也是属性管理的一个难点。
- 数据一致性和同步
分布式系统中,各种属性的数据源可能分散在不同的系统中,如何准确的、高效的获取该属性和属性值。
访问规则依赖属性
和属性值
去构建,特别是动态属性(实时位置,时间),如何确保获取到最新的属性值。
💡Tips: 属性是否可以动态增加也是构建系统的一部分,例如用户属性中你增加了一项职业,那该属性的值类型和获取该值的方法如何定义也是属性管理的一个难点。
分布式系统中,各种属性的数据源可能分散在不同的系统中,如何准确的、高效的获取该属性和属性值。
2.访问规则的复杂度
- 条件逻辑
构建一个访问规则通常包含复杂的条件,条件可能是大于、小于、区间、地理位置等。这些条件需要仔细的定义和维护。
- 多条件组合
访问规则需要涵盖不同属性的组合条件,属性组合的数量随着属性的增加呈指数型增长。
- 策略管理
如果访问规则在数量一直增长,访问规则的生命周期(更新、删除)将变得复杂。如果其中属性的变动也会影响现有存在的访问规则。
- 动态性
ABAC进行决策时需要实时评估所有相关属性的当前值,并与策略条件进行匹配。这种评估会增加计算的开销,尤其在处理大量请求时对计算资源要求更高。
构建一个访问规则通常包含复杂的条件,条件可能是大于、小于、区间、地理位置等。这些条件需要仔细的定义和维护。
访问规则需要涵盖不同属性的组合条件,属性组合的数量随着属性的增加呈指数型增长。
如果访问规则在数量一直增长,访问规则的生命周期(更新、删除)将变得复杂。如果其中属性的变动也会影响现有存在的访问规则。
ABAC进行决策时需要实时评估所有相关属性的当前值,并与策略条件进行匹配。这种评估会增加计算的开销,尤其在处理大量请求时对计算资源要求更高。
3.透明性
- 可追溯性
ABAC的动态决策过程复杂,审计和跟踪某个过程中的条件匹配和组合算法变得困难。为了便于审计和问题排查,ABAC 系统通常需要记录详细的决策日志,这增加了额外的复杂性。
- 决策透明度
复杂的条件和组合逻辑使得管理员在排查和解释某个请求的决策较为困难,用户请求被拒绝可能难以理解其原因,这对系统性提出了挑战。
ABAC的动态决策过程复杂,审计和跟踪某个过程中的条件匹配和组合算法变得困难。为了便于审计和问题排查,ABAC 系统通常需要记录详细的决策日志,这增加了额外的复杂性。
复杂的条件和组合逻辑使得管理员在排查和解释某个请求的决策较为困难,用户请求被拒绝可能难以理解其原因,这对系统性提出了挑战。
ABAC 的实现
标准实现-XACML
XACML 是一种基于 XML 的标准访问控制控制策略语言,用于定义和管理复杂的访问控制需求。
💡Tips: XACML 是 ABAC 的一个标准实现,用 XML 文件定义访问的策略集,然后提交 XACML 引擎来进行权限决策。由于这个太过复杂,这里不讲述了。有兴趣的可以看下官网XACML version 3.0。(当然还有其他标准的实现)
ABAC 的权限系统设计的核心
目前 ABAC 系统没有单独使用的,基本都是搭配RBAC (基于角色的权限模型)来使用。目前已有的类似方案如 AWS的 IAM (Identity And Access Management)也都是借鉴了 ABAC 的设计理念来实现的精细权限控制。
一个 ABAC 系统通常需要考虑以下核心的三个关键步骤:
- 属性管理:属性的定义、结构和属性值的获取。
- 访问规则:访问规则的结构化和语法定义。
- 规则编辑器:规则编辑器和规则匹配引擎。
💡Tips: 目前这里探讨的设计因为没有具体的场景,这里的所说的设计权做参考。顺便一提属性管理、规则编辑器、访问规则其实设计的思路和CDP系统非常相似。
属性管理
属性有动态属性和静态属性,如性别那就是静态的,年龄、角色、地理位置这些都是动态的。
属性管理的难点是属性的定义和属性值获取。
- 属性的定义
一般来说属性的定义包括属性名、字段类型、来源(获取该属性值的方式)。
业务确认属性的范围,也就是说设计时确认了目前业务要用到哪些属性就不能进行更改(修改、删除)操作。如果是需要动态的进行属性的新增、修改就需要更抽象的灵活设计。
💡Tips: 如果其他的访问规则中使用了该属性,修改和删除都会影响使用该属性的访问规则。
- 属性值的获取
属性和属性值的来源单一
例如用户的属性就一张用户表,那直接读取用户表就好了。如果你的用户数据是分散在不同来源的,需要考虑的如何聚合数据和保证数据一致性的问题。
访问规则
目前现在是有一些ABAC 的设计系统大多都是采用 JSON 语言描述访问规则。该 JSON 中包含了访问规则中所用到的属性、条件、操作等。示例如下:
{
"subject": {
"role": "manager",
"department": "finance"
},
"object": {
"type": "document",
"sensitivity": "confidential"
},
"action": "view",
"environment": {
"ip": "192.168.1.*",
"time": {
"start": "09:00",
"end": "18:00"
}
},
"effect": "allow"
}
💡 实际开发中根据业务场景,系统中描述JSON 中的结构语义都有自己的规范。
规则编辑和规则匹配
ABAC 的核心部分是**如何构建一个规则编辑器和规则匹配引擎
**,这个规则编辑器需要满足各种复杂条件的组合(这里的条件指的是一个条件或多个条件)。这里的条件之间的关系可能不止“且”的关系,可能还存在“或”。
一些地方描述构建规则为动态 SQL 的构建,但是这种方式需要对应的资源需要映射为数据库记录且有对应的属性存在表结构中,简单就是理解是宽表+属性。可有些属性是没办法在数据库结构中体现的如访问位置、访问时间等,这些就需要在规则匹配引擎中做设计了。
💡Tips: 目前所说的规则编辑和规则匹配都是为了动态 SQL 的构建,这块比较有通用性(有些数据权限设计采用的就是该种思路。)。至于其他方式需要考虑具体的业务环境。
规则编辑器通常都设计成管理界面,通过界面上选取中的属性和条件构成一个JSON ,然后提交到规则匹配引擎去执行,将JSON 转换成动态 SQL,发起访问请求时去拿到访问规则构建的SQL去执行。
💡Tips 这里的规则匹配引擎最主要的工作就是根据 JSON 中的描述的规则,动态生成一段 SQL。
总结
事实上 ABAC 现在没有什么标准建模,借鉴ABAC 的设计思维达到你想要的基于属性控制权限就可以了。至于采用何种方式、是否搭配其他权限模型,具体业务、具体分析。
来源:juejin.cn/post/7445219433017376780
用java做一套离线且免费的智能语音系统,ASR+LLM+TTS
其实调用第三方接口完成一个智能语音系统是非常简单的,像阿里、科大讯飞、微软都有相关接口,直接根据官方文档集成就可以,但想要离线的就要麻烦一点了,主要是想不花钱,现在人工智能基本是python的天下,不得不感慨,再不学python感觉自己要被淘汰了。
言归正传,首先说一下标题中的ASR+LLM+TTS,ASR就是语音识别,LLM就是大语言模型,TTS就是文字转语音,要想把这几个功能做好对电脑性能要求还是蛮高的,本次方案是一个新的尝试也是减少对性能的消耗
1.先看效果
生成的音频效果放百度网盘 通过网盘分享的文件:result.wav
链接: pan.baidu.com/s/19ImtqunH… 提取码: hm67
听完之后是不是感觉效果很好,但是。。。后面再说吧
2.如何做
2.1ASR功能
添加依赖
<!-- 获取音频信息 -->
<dependency>
<groupId>org</groupId>
<artifactId>jaudiotagger</artifactId>
<version>2.0.3</version>
</dependency>
<!-- 语音识别 -->
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.7.0</version>
</dependency>
<dependency>
<groupId>com.alphacephei</groupId>
<artifactId>vosk</artifactId>
<version>0.3.32</version>
</dependency>
代码实现,需要提前下载模型,去vosk官网下载:VOSK Models (alphacephei.com)中文模型一个大的一个小的,小的识别速度快准确率低,大的识别速度慢准确率高
提前预加载模型,提升识别速度
private static final Model model = loadModel();
private static Model loadModel() {
try {
String path=System.getProperty("user.dir");
return new Model(path+"\vosk-model-small-cn-0.22");
} catch (Exception e) {
throw new RuntimeException("Failed to load model", e);
}
}
语音转文字方法实现
public String voiceToText(String filePath) {
File file = new File(filePath);
LibVosk.setLogLevel(LogLevel.DEBUG);
String msg = null;
int sampleRate = 0;
RandomAccessFile rdf = null;
/**
* "r": 以只读方式打开。调用结果对象的任何 write 方法都将导致抛出 IOException。
* "rw": 打开以便读取和写入。
* "rws": 打开以便读取和写入。相对于 "rw","rws" 还要求对“文件的内容”或“元数据”的每个更新都同步写入到基础存储设备。
* "rwd" : 打开以便读取和写入,相对于 "rw","rwd" 还要求对“文件的内容”的每个更新都同步写入到基础存储设备。
*/
try {
rdf = new RandomAccessFile(file, "r");
sampleRate=toInt(read(rdf));
System.out.println(file.getName() + " SampleRate:" + sampleRate); // 采样率、音频采样级别 8000 = 8KHz
rdf.close();
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
try (
InputStream ais = AudioSystem.getAudioInputStream(file);
Recognizer recognizer = new Recognizer(model, 16000)) {
int bytes;
byte[] b = new byte[1024];
while ((bytes = ais.read(b)) >= 0) {
recognizer.acceptWaveForm(b, bytes);
}
String result=recognizer.getResult();
JSONObject jsonObject = JSONObject.parseObject(result);
msg=jsonObject.getString("text");
} catch (UnsupportedAudioFileException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
return msg;
}
2.2LLM问答功能
这个就要请出神奇的羊驼ollama(链接:Ollama),下载即用非常简单,可以运行大部分主流大语言模型,在官网models选择要加载的模型在控制台运行对应的命令即可
添加依赖
<dependency>
<groupId>io.springboot.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
<version>1.0.3</version>
</dependency>
加入配置
spring:
ai:
ollama:
base-url: http://10.3.0.178:11434 //接口地址 默认端口11434
chat:
options:
model: qwen2 //模型名称
enabled: true
代码实现,引入OllamaChatClient,然后调用call方法
@Resource
private OllamaChatClient ollamaChatClient;
//msg为提问的信息
String ask=ollamaChatClient.call(msg);
2.3TTS功能
添加依赖
<dependency>
<groupId>com.hynnet</groupId>
<artifactId>jacob</artifactId>
<version>1.18</version>
</dependency>
代码实现
public boolean localTextToSpeech(String text, int volume, int speed,String outPath) {
try {
// 调用dll朗读方法
ActiveXComponent ax = new ActiveXComponent("Sapi.SpVoice");
// 音量 0 - 100
ax.setProperty("Volume", new Variant(volume));
// 语音朗读速度 -10 到 +10
ax.setProperty("Rate", new Variant(speed));
// 输入的语言内容
Dispatch dispatch = ax.getObject();
// 本地执行朗读
// Dispatch.call(dispatch, "Speak", new Variant(text));
//开始生成语音文件,构建文件流
ax = new ActiveXComponent("Sapi.SpFileStream");
Dispatch sfFileStream = ax.getObject();
//设置文件生成格式
ax = new ActiveXComponent("Sapi.SpAudioFormat");
Dispatch fileFormat = ax.getObject();
// 设置音频流格式
Dispatch.put(fileFormat, "Type", new Variant(22));
// 设置文件输出流格式
Dispatch.putRef(sfFileStream, "Format", fileFormat);
// 调用输出文件流打开方法,创建一个音频文件
Dispatch.call(sfFileStream, "Open", new Variant(outPath), new Variant(3), new Variant(true));
// 设置声音对应输出流为输出文件对象
Dispatch.putRef(dispatch, "AudioOutputStream", sfFileStream);
// 设置音量
Dispatch.put(dispatch, "Volume", new Variant(volume));
// 设置速度
Dispatch.put(dispatch, "Rate", new Variant(speed));
// 执行朗读
Dispatch.call(dispatch, "Speak", new Variant(text));
// 关闭输出文件
Dispatch.call(sfFileStream, "Close");
Dispatch.putRef(dispatch, "AudioOutputStream", null);
// 关闭资源
sfFileStream.safeRelease();
fileFormat.safeRelease();
// 关闭朗读的操作
dispatch.safeRelease();
ax.safeRelease();
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
到此为止功能就全部实现了,在不联网的情况下也可以免费使用,但这个TTS生成出来的语音太机器了,所以在上面的示例中我说但是,因为机器音实在太难受了,所以用了另一个方案,但这个方案需要联网,暂时看是不需要收费(后续使用发现是有接口调用次数限制)
,这个大家就看原作者介绍吧(ikfly/java-tts: java-tts 文本转语音 (github.com))
最终实现效果来看还是能接受的吧,整个过程速度还比较快,python的语音相关项目也看了很多,如果部署一个python的TTS服务,效果可能还会好点,但是我试过的速度都有点慢啊,还是容我再继续研究一下吧
来源:juejin.cn/post/7409329136555048999
从MySQL迁移到PostgreSQL经验总结
背景
最近一两周在做从MySQL迁移到PostgreSQL
的任务(新项目,历史包袱较小,所以迁移比较顺利), 感觉还是有一些知识,可以拿出来分享,希望对大家有所帮助。
最近一两周在做从MySQL迁移到PostgreSQL
的任务(新项目,历史包袱较小,所以迁移比较顺利), 感觉还是有一些知识,可以拿出来分享,希望对大家有所帮助。
为什么要转到PostgreSQL
因架构团队安全组安全需求,需要将Mysql迁移到PostgreSQL。实际迁移下来,发现PostgreSQL挺优秀的,比MySQL严谨很多,很不错。
因架构团队安全组安全需求,需要将Mysql迁移到PostgreSQL。实际迁移下来,发现PostgreSQL挺优秀的,比MySQL严谨很多,很不错。
迁移经验
引入PostgreSQL驱动,调整链接字符串
pagehelper方言调整
涉及order, group,name, status, type 等关键字,要用引号
括起来
JSON字段及JsonTypeHandler
项目中用到了比较多的JSON字段。在mysql中,也有JSON字段类型,但是有时候我们用了varchar或text,在mybatis typehandler中是当成字符来处理的。但是在postgresql中,相对严谨,如果字段类型是json,那么在java中会被封装为PGObject,所以我们原来的JsonTypeHandler就要被改造。
/**
* JSON类型处理器
*
* @author james.h.fu
* @create 2024/10/9 20:45
*/
@Slf4j
public class JsonTypeHandler extends BaseTypeHandler {
private static final ObjectMapper mapper = new ObjectMapper();
static {
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, Boolean.FALSE);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
private final Class clazz;
private TypeReferenceextends T> typeReference;
public JsonTypeHandler(Class clazz) {
if (clazz == null) throw new IllegalArgumentException("Type argument cannot be null");
this.clazz = clazz;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
setObject(ps, i, parameter);
}
@Override
public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
return toObject(rs, columnName);
}
@Override
public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return toObject(rs, columnIndex);
}
@Override
public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return toObject(cs, columnIndex);
}
protected TypeReferenceextends T> getTypeReference() {
return new TypeReference() {};
}
private String toJson(T object) {
try {
return mapper.writeValueAsString(object);
} catch (Exception ex) {
log.error("JsonTypeHandler error on toJson content:{}", JsonUtil.toJson(object), ex);
throw new RuntimeException("JsonTypeHandler error on toJson", ex);
}
}
private T toObject(String content) {
if (!StringUtils.hasText(content)) {
return null;
}
try {
if (clazz.getName().equals("java.util.List")) {
if (Objects.isNull(typeReference)) {
typeReference = getTypeReference();
}
return (T) mapper.readValue(content, typeReference);
}
return mapper.readValue(content, clazz);
} catch (Exception ex) {
log.error("JsonTypeHandler error on toObject content:{},class:{}", content, clazz.getName(), ex);
throw new RuntimeException("JsonTypeHandler error on toObject", ex);
}
}
// protected boolean isPostgre() {
// SqlSessionFactory sqlSessionFactory = SpringUtil.getBean(SqlSessionFactory.class);
// Configuration conf = sqlSessionFactory.getConfiguration();
// DataSource dataSource = conf.getEnvironment().getDataSource();
// try (Connection connection = dataSource.getConnection()) {
// String url = connection.getMetaData().getURL();
// return url.contains("postgresql");
// } catch (SQLException e) {
// throw new RuntimeException("Failed to determine database type", e);
// }
// }
@SneakyThrows
private void setObject(PreparedStatement ps, int i, T parameter) {
PGobject jsonObject = new PGobject();
jsonObject.setType("json");
jsonObject.setValue(JsonUtil.toJson(parameter));
ps.setObject(i, jsonObject);
}
@SneakyThrows
private T toObject(ResultSet rs, String columnName) {
Object object = rs.getObject(columnName);
return toObject(object);
}
@SneakyThrows
private T toObject(ResultSet rs, int columnIndex) {
Object object = rs.getObject(columnIndex);
return toObject(object);
}
@SneakyThrows
private T toObject(CallableStatement rs, int columnIndex) {
Object object = rs.getObject(columnIndex);
return toObject(object);
}
public T toObject(Object object) {
if (object instanceof String json) {
return this.toObject(json);
}
if (object instanceof PGobject pgObject) {
String json = pgObject.getValue();
return this.toObject(json);
}
return null;
}
}
<result column="router_info" jdbcType="OTHER" property="routerInfo" typeHandler="***.cms.cmslib.mybatis.JsonTypeHandler"/>
<set>
<if test="routerInfo != null">
router_info = #{routerInfo,typeHandler=***.cms.cmslib.mybatis.JsonTypeHandler}
if>
set>
where id = #{id}
如果JSON中存储是的List, Map,Set等类型时, 会存在泛型类型中类型擦除的问题
。因此,如果存在这种情况,我们需要扩展子类,在子类中提供详细的类型信息TypeReference
。
/**
* @author james.h.fu
* @create 2024/12/9 20:45
*/
public class ComponentUpdateListJsonTypeHandler extends JsonTypeHandler>
{
public ComponentUpdateListJsonTypeHandler(Class<List<ComponentUpdate>> clazz) {
super(clazz);
}
@Override
protected TypeReference getTypeReference() {
return new TypeReference<List<ComponentUpdate>>() {
};
}
}
- pgsql不支持mysql insert ignore语法, pgsql提供了类似的语法:
INSERT INTO orders (product_id, user_id)
VALUES (101, 202)
ON CONFLICT (product_id, user_id) DO NOTHING;
项目中用到了比较多的JSON字段。在mysql中,也有JSON字段类型,但是有时候我们用了varchar或text,在mybatis typehandler中是当成字符来处理的。但是在postgresql中,相对严谨,如果字段类型是json,那么在java中会被封装为PGObject,所以我们原来的JsonTypeHandler就要被改造。
/**
* JSON类型处理器
*
* @author james.h.fu
* @create 2024/10/9 20:45
*/
@Slf4j
public class JsonTypeHandler extends BaseTypeHandler {
private static final ObjectMapper mapper = new ObjectMapper();
static {
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, Boolean.FALSE);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
private final Class clazz;
private TypeReferenceextends T> typeReference;
public JsonTypeHandler(Class clazz) {
if (clazz == null) throw new IllegalArgumentException("Type argument cannot be null");
this.clazz = clazz;
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException {
setObject(ps, i, parameter);
}
@Override
public T getNullableResult(ResultSet rs, String columnName) throws SQLException {
return toObject(rs, columnName);
}
@Override
public T getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return toObject(rs, columnIndex);
}
@Override
public T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return toObject(cs, columnIndex);
}
protected TypeReferenceextends T> getTypeReference() {
return new TypeReference() {};
}
private String toJson(T object) {
try {
return mapper.writeValueAsString(object);
} catch (Exception ex) {
log.error("JsonTypeHandler error on toJson content:{}", JsonUtil.toJson(object), ex);
throw new RuntimeException("JsonTypeHandler error on toJson", ex);
}
}
private T toObject(String content) {
if (!StringUtils.hasText(content)) {
return null;
}
try {
if (clazz.getName().equals("java.util.List")) {
if (Objects.isNull(typeReference)) {
typeReference = getTypeReference();
}
return (T) mapper.readValue(content, typeReference);
}
return mapper.readValue(content, clazz);
} catch (Exception ex) {
log.error("JsonTypeHandler error on toObject content:{},class:{}", content, clazz.getName(), ex);
throw new RuntimeException("JsonTypeHandler error on toObject", ex);
}
}
// protected boolean isPostgre() {
// SqlSessionFactory sqlSessionFactory = SpringUtil.getBean(SqlSessionFactory.class);
// Configuration conf = sqlSessionFactory.getConfiguration();
// DataSource dataSource = conf.getEnvironment().getDataSource();
// try (Connection connection = dataSource.getConnection()) {
// String url = connection.getMetaData().getURL();
// return url.contains("postgresql");
// } catch (SQLException e) {
// throw new RuntimeException("Failed to determine database type", e);
// }
// }
@SneakyThrows
private void setObject(PreparedStatement ps, int i, T parameter) {
PGobject jsonObject = new PGobject();
jsonObject.setType("json");
jsonObject.setValue(JsonUtil.toJson(parameter));
ps.setObject(i, jsonObject);
}
@SneakyThrows
private T toObject(ResultSet rs, String columnName) {
Object object = rs.getObject(columnName);
return toObject(object);
}
@SneakyThrows
private T toObject(ResultSet rs, int columnIndex) {
Object object = rs.getObject(columnIndex);
return toObject(object);
}
@SneakyThrows
private T toObject(CallableStatement rs, int columnIndex) {
Object object = rs.getObject(columnIndex);
return toObject(object);
}
public T toObject(Object object) {
if (object instanceof String json) {
return this.toObject(json);
}
if (object instanceof PGobject pgObject) {
String json = pgObject.getValue();
return this.toObject(json);
}
return null;
}
}
<result column="router_info" jdbcType="OTHER" property="routerInfo" typeHandler="***.cms.cmslib.mybatis.JsonTypeHandler"/>
<set>
<if test="routerInfo != null">
router_info = #{routerInfo,typeHandler=***.cms.cmslib.mybatis.JsonTypeHandler}
if>
set>
where id = #{id}
如果JSON中存储是的List泛型类型中类型擦除的问题
。因此,如果存在这种情况,我们需要扩展子类,在子类中提供详细的类型信息TypeReference
。
/**
* @author james.h.fu
* @create 2024/12/9 20:45
*/
public class ComponentUpdateListJsonTypeHandler extends JsonTypeHandler>
{
public ComponentUpdateListJsonTypeHandler(Class<List<ComponentUpdate>> clazz) {
super(clazz);
}
@Override
protected TypeReference getTypeReference() {
return new TypeReference<List<ComponentUpdate>>() {
};
}
}
- pgsql不支持mysql insert ignore语法, pgsql提供了类似的语法:
INSERT INTO orders (product_id, user_id)
VALUES (101, 202)
ON CONFLICT (product_id, user_id) DO NOTHING;
但是与mysql insert ignore并不完全等价, 关于这个点如何改造, 需要结合场景或者业务逻辑来斟酌定夺.
- pgsql也不支持INSERT ... ON DUPLICATE KEY UPDATE, 如果代码中有使用这个语法, pgsql提供了类似的语法:
INSERT INTO users (email, name, age)
VALUES ('test@example.com', 'John', 30)
ON CONFLICT (email)
DO UPDATE SET
name = EXCLUDED.name,
age = EXCLUDED.age;
EXCLUDED 是一个特殊的表别名,用于引用因冲突而被排除(Excluded)的、尝试插入的那条数据.
CONFLICT也可以直接面向唯一性约束. 假如users有一个唯一性约束: unique_email_constraint, 上述SQL可以改成:
INSERT INTO users (email, name, age)
VALUES ('test@example.com', 'John', 30)
ON CONFLICT ON CONSTRAINT unique_email_constraint
DO UPDATE SET
name = EXCLUDED.name,
age = EXCLUDED.age;
- 分页:mysql的分页使用的是: limit B(offset),A(count), 但是pgsql不支持这种语法, pgsql支持的是如下两种:
(1)、limit A offset B; (2)、OFFSET B ROWS FETCH NEXT A ROWS ONLY;
- pgsql查询区分大小写, 而mysql是不区分的
- 其它情况 (1)、代码中存在取1个数据的场景,原来mysql写法是
limit 0,1
, 要调整为limit 1
; (2)、在mysql中BIT(1)或tinyint(值0,1)可以转换为Boolean。但是在pgsql中不支持。需要明确使用boolean类型或INT类型, 或者使用typerhandler处理。
ALTER TABLE layout
ALTER COLUMN init_instance TYPE INT2
USING CASE
WHEN init_instance = B'1' THEN 1
WHEN init_instance = B'0' THEN 0
ELSE NULL
END;
update component c
set init_instance = cp.init_instance
from component_publish cp
where c.init_instance is null and c.id = cp.component_id ;
(3)、迁移数据后,统一将自增列修改
DO $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT
tc.sequencename
FROM pg_sequences tc
LOOP
EXECUTE format('ALTER SEQUENCE %I RESTART WITH 100000', rec.sequencename);
RAISE NOTICE 'Reset sequence % to 100000', rec.sequencename;
END LOOP;
END $$;
总结
在日常开发中,我们一定要再严谨一些,规范编码。这样能让写我的代码质量更好,可移植性更高。
附录
在PostgreSQL 中,有哪些数据类型?
PostgreSQL 支持多种数据类型,下面列出一些常用的数据类型:
- 数值类型
smallint
:2字节整数integer
:4字节整数bigint
:8字节整数decimal
或numeric
:任意精度的数值real
:4字节浮点数double precision
:8字节浮点数smallserial
:2字节序列整数serial
:4字节序列整数bigserial
:8字节序列整数
- 字符与字符串类型
character varying(n)
或varchar(n)
:变长字符串,最大长度为ncharacter(n)
或char(n)
:定长字符串,长度为ntext
:变长字符串,没有长度限制
- 日期/时间类型
date
:存储日期(年月日)time [ (p) ] [ without time zone ]
:存储时间(时分秒),可指定精度p,默认不带时区time [ (p) ] with time zone
:存储时间(时分秒),可指定精度p,带时区timestamp [ (p) ] [ without time zone ]
:存储日期和时间,默认不带时区timestamp [ (p) ] with time zone
:存储日期和时间,带时区interval
:存储时间间隔
- 布尔类型
boolean
:存储真或假值
- 二进制数据类型
bytea
:存储二进制字符串
- 几何类型
point
:二维坐标点line
:无限长直线lseg
:线段box
:矩形框path
:闭合路径或多边形polygon
:多边形circle
:圆
- 网络地址类型
cidr
:存储IPv4或IPv6网络地址inet
:存储IPv4或IPv6主机地址和可选的CIDR掩码macaddr
:存储MAC地址
- 枚举类型
enum
:用户定义的一组排序标签
- 位串类型
bit( [n] )
:固定长度位串bit varying( [n] )
:变长位串
- JSON类型
json
:存储JSON数据jsonb
:存储JSON数据,以二进制形式存储,并支持查询操作
- UUID类型
uuid
:存储通用唯一标识符
- XML类型
xml
:存储XML数据
这些数据类型可以满足大多数应用的需求。在创建表时,根据实际需要选择合适的数据类型是非常重要的。
在MyBatis中,jdbcType有哪些?
jdbcType
是 MyBatis 和其他 JDBC 相关框架中用于指定 Java 类型和 SQL 类型之间映射的属性。以下是常见的 jdbcType
值及其对应的 SQL 数据类型:
- NULL:表示 SQL NULL 类型
- VARCHAR:表示 SQL VARCHAR 或 VARCHAR2 类型
- CHAR:表示 SQL CHAR 类型
- NUMERIC:表示 SQL NUMERIC 类型
- DECIMAL:表示 SQL DECIMAL 类型
- BIT:表示 SQL BIT 类型
- TINYINT:表示 SQL TINYINT 类型
- SMALLINT:表示 SQL SMALLINT 类型
- INTEGER:表示 SQL INTEGER 类型
- BIGINT:表示 SQL BIGINT 类型
- REAL:表示 SQL REAL 类型
- FLOAT:表示 SQL FLOAT 类型
- DOUBLE:表示 SQL DOUBLE 类型
- DATE:表示 SQL DATE 类型(只包含日期部分)
- TIME:表示 SQL TIME 类型(只包含时间部分)
- TIMESTAMP:表示 SQL TIMESTAMP 类型(包含日期和时间部分)
- BLOB:表示 SQL BLOB 类型(二进制大对象)
- CLOB:表示 SQL CLOB 类型(字符大对象)
- ARRAY:表示 SQL ARRAY 类型
- DISTINCT:表示 SQL DISTINCT 类型
- STRUCT:表示 SQL STRUCT 类型
- REF:表示 SQL REF 类型
- DATALINK:表示 SQL DATALINK 类型
- BOOLEAN:表示 SQL BOOLEAN 类型
- ROWID:表示 SQL ROWID 类型
- LONGNVARCHAR:表示 SQL LONGNVARCHAR 类型
- NVARCHAR:表示 SQL NVARCHAR 类型
- NCHAR:表示 SQL NCHAR 类型
- NCLOB:表示 SQL NCLOB 类型
- SQLXML:表示 SQL XML 类型
- JAVA_OBJECT:表示 SQL JAVA_OBJECT 类型
- OTHER:表示 SQL OTHER 类型
- LONGVARBINARY:表示 SQL LONGVARBINARY 类型
- VARBINARY:表示 SQL VARBINARY 类型
- LONGVARCHAR:表示 SQL LONGVARCHAR 类型
在使用 MyBatis 或其他 JDBC 框架时,选择合适的 jdbcType
可以确保数据正确地在 Java 和数据库之间进行转换。
来源:juejin.cn/post/7460410854775455794
如何统一管理枚举类?
Hello,大家好,今天我们来聊一下关于系统中的枚举是如何统一进行管理的。
业务场景
我们公司有这样的一个业务场景前端表单中 下拉选择的枚举值,是需要从后端获取的。那么这时候有个问题,我们不可能每次新增加一个枚举,都需要 改造获取枚举 的相关接口(getEnum),所以我们就需要对系统中的所有枚举类,进行统一的一个管理。
核心思路
为了解决这个问题,我们采用了如下的方案
- 当服务启动时,统一对 枚举类 进行 注册发现
- 枚举管理类,对外暴露一个方法,可以根据我的key 去获取对应的枚举值
相关实现
枚举定义
基于以上的思想,我们对枚举类定义了如下的规范,例如
@**IsEnum**
public enum BooleanEnum implements BaseEnums {
YES("是", "1"),
NO("否", "2")
;
private String text;
@EnumValue
private String value;
YesNoEnum(String text, String value) {
this.text = text;
this.value = value;
}
public String getText() {
return text;
}
@Override
public String getValue() {
return value;
}
@Override
public String toString() {
return "YesNoEnum{" +
"text='" + text + '\'' +
", value=" + value +
'}';
}
@Override
public EnumRequest toJson() {
return new EnumRequest(value, text);
}
@JsonCreator
public static YesNoEnum fromCode(String value) {
for (YesNoEnum status : YesNoEnum.values()) {
if (status.value.equals(value)) {
return status;
}
}
return null; // 或抛出异常
}
}
- 所有枚举均使用 @IsEnum进行标记(这是一个自定义注解)
- 所有枚举均实现 BaseEnums 接口(具体作用后续会提到)
- 所有的枚举的 value 值 统一使用 String 类型,并使用
@EnumValue
注解进行标记
- 这主要是为了兼容Mybatis Plus 查表时 基础数据类型与枚举类型的转化
- 如果将
value
定义为Object类型,Mybatis Plus 无法正确识别,会报错
- 使用
@JsonCreator
注解标记转化函数
- 这个注解是有Jackson提供的,使用了从 基础数据类型到枚举类型的转化
注册发现
那么我们是如何实现服务的注册发现的呢?在这里主要是 使用了 SpringBoot 提供的接口CommandLineRunner
(关于CommandLineRunner
可以参考这篇文章blog.csdn.net/python113/a…
在应用启动时,我们回去扫描 枚举所在的 包,通过 类上 是否包含 IsEnum
注解,判断是否需要对外暴露
// 指定要扫描的包
String basePackage = "com.xxx.enums";
// 创建扫描器
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(EnumMarker.class));
// 扫描指定包
Set<BeanDefinition> beans = scanner.findCandidateComponents(basePackage);
// 注册枚举
for (org.springframework.beans.factory.config.BeanDefinition beanDefinition : beans) {
try {
Class<?> enumClass = Class.forName(beanDefinition.getBeanClassName());
if (Enum.class.isAssignableFrom(enumClass)) {
enumManager.registerEnum((Class<? extends Enum<?>>) enumClass);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
ClassPathScanningCandidateComponentProvider
是 Spring 框架中的一个类,主要用于扫描指定路径下符合条件的候选组件(通常是 Bean 定义)。它允许我们在类路径中扫描并筛选符合特定条件(如标注特定注解、实现某接口等)的类,以实现自动化配置、依赖注入等功能。
典型用途
在基于注解的 Spring 应用中,我们可以使用它来动态扫描特定包路径下的类并注册成 Spring Bean。例如,Spring 扫描 @Component
、@Service
、@Repository
等注解标注的类时就会用到它。
主要方法
addIncludeFilter(TypeFilter filter)
:添加一个包含过滤器,用于筛选扫描过程中包含的类。findCandidateComponents(String basePackage)
:扫描指定包路径下的候选组件(符合条件的类),并返回符合条件的BeanDefinition
对象集合。addExcludeFilter(TypeFilter filter)
:添加一个排除过滤器,用于排除特定类。
最终呢,会将找到的枚举值,放在一个EnumManager中的一个Map集合中
private final Map<Class<?>, List<Enum<?>>> enumMap = new HashMap<>(); // 类与枚举类型的映射关系
private final Map<String, List<Enum<?>>> enumNameMap = new HashMap<>(); // 名称与枚举的映射管理
public <E extends Enum<E>> void registerEnum(Class<? extends Enum<?>> enumClass) {
Enum<?>[] enumConstants = enumClass.getEnumConstants();
final List<Enum<?>> list = Arrays.asList(enumConstants);
enumMap.put(enumClass, list);
enumNameMap.put(enumClass.getSimpleName(), list);
}
enumClass.getEnumConstants()
是 Java 反射 API 中的一个方法,用于获取某个枚举类中定义的所有枚举实例。getEnumConstants()
会返回一个包含所有枚举常量的数组,每个元素都是该枚举类的一个实例。
这样子我们就可以通过枚举的名称或者class 获取枚举列表返回给前端
enumMap.get(enumClass); enumNameMap.get(enumName);
请求与响应
我们项目中使用的序列化器 是Jackson,通过 @JsonValue
与@JsonCreator
两个注解实现的。
@JsonValue
:对象序列化为json时会调用这个注解标记的方法
@JsonValue
public EnumRequest toJson() {
return new EnumRequest(value, text);
}
@JsonCreator
:json反序列化对象时会调用这个注解标记的方法
@JsonCreator
public static YesNoEnum fromCode(String value) {
for (YesNoEnum status : YesNoEnum.values()) {
if (status.value.equals(value)) {
return status;
}
}
return null; // 或抛出异常
}
但是这里有个坑,我们SpringBoot的版本是2.5,使用 @JsonCreator
时会报错,这时候只需要降低jackson 的版本就可以了
// 排除 spring-boot-starter-web 中的jsckson
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.10.5</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.10.5</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.10.5</version>
</dependency>
Mybatis Plus 枚举中的使用
- 在applicaton.yml文件中添加如下参数
# 在控制台输出sql
mybatis-plus:
type-enums-package: com.xxx.enums // 定义枚举的扫描包
- 将枚举值中 存入到数据库的字段 使用
@EnumValue
注解进行标记,例如:上面提供的YesNoEnum
类中的value字段使用@EnumValue
进行了标记 数据库中实际保存的就是 value 的值(1/2) - 将domian 中 直接定义为枚举类型就可以了,是不是非常简单呢?
以上就是本篇文章的全部内容,如果有更好的方案欢迎小伙伴们评论区讨论!
来源:juejin.cn/post/7431838327844995098
【谈一谈】Redis是AP还是CP?
【谈一谈】Redis是AP还是CP?
再说这个话题之前,这里的是
AP
和CP
不是"A片"和"C骗"啊 !~哈哈哈,就离谱,博文后面我会解释下的
我说下自己对
Redis
的感觉,我一直很好奇Redis
,不仅仅是当缓存用那么简单,包括的它的底层设计
所以,思考再三,我决定先从
Redis
基础开始写(基础是王道!~万丈高楼平地起,我米开始!~嘿嘿)
一、总纲图:
二、什么是CAP?
要想谈一谈我们本文的主题
AP
和CP
,可能有的小伙伴会说: 这我也不是 怎么熟悉啊!
那么我们先复习下大名鼎鼎的
CAP
理论
CAP
理论
看下面的这张图,我们会发现
CAP
对应的三个单词【建议自己画画图,印象深刻】
C
: 一致性(Consistency)--
- 每次读取都会收到最新的写入数据或者错误信息
- (
注
:这里面的一致性,指的是强一致性
,不是市面上所说的所有节点在相同时间看到是一样的数据)
A
:可用性(Availability)--
- 每个请求都会收到非错误地响应,但是这个响应的信息不保证是最新的 ,只保证可用
P
:分区容错性(Partition Tolerance)--
- 就是网络节点间丢弃或者延迟一定数量(就是任意数量)信息,也不影响大局,系统还是能够正常运行
好了,我们言归正传,回到我们的主题上面
三、为啥说Redis是AP?不是CP?
我们知道,
Redis
是一个开源的内存数据库,且是执行单线程处理
但是网上,若是喜欢读博客的小伙伴,会发现很多人说这样一句话:
- 单机的
Redis
是CP
的,集群的REDIS
是AP
的
这句话真的对吗? 大家在看下文前,倾思考思考!~我当时读到第一反应就是疑惑,于是我就去查询大量资料
有的人说:
CAP
是针对分布式场景中,如果是单机REDIS
,就压根儿和什么分布式不着边,都没P
了!!还说哈AP
和CP
??
- 在单机的
REDIS
中,应为只有一个实例,那么他的一致性是有保障的,如果这个节点挂了,就没有可用性可言了,所以他是CP
系统
我在这里说下,以上两个观点都特么错的!!!以偏概全,混淆是非!~就是AP!!
~哈哈哈!你可能会说:我去,那你证明啊,这特么为啥是错的啊!,别急嘛!我们往下读,让你心服口服,嘿嘿
REDIS
是AP
的理由
第一点: 一致性
我们都知道,
REDIS
的设计目标是高性能,高扩展和高可用性 ,
而且
REDIS
的一致性模型是最终一致性:
(什么意思呢?)就是在某个时间点读取的数据可能不是最新的,但殊途同归,最终会达到一致的状态
为什么Redis
无法保持强一致性??
主要原因: 异步复制
- 因为
Redis
在分布式的设计中采用的是异步复制
,者导致在节点之间存在数据在同步和延迟不一致的情况存在
换句话说:
- 当某个节点的数据发生改变,
Redis
会将这个节点的修改操作发送给其他节点进行同步~(这是正常步骤,没毛病是不,我们继续往下看) - 但是(不怕一万,就怕万一来了,哈哈哈)因为网络传输的延迟,拥塞等原因,这些操作没有立即被被其他节点收到和执行,
- 从而产生节点之间数据不一致的情况!!!
- 当某个节点的数据发生改变,
抛开上面的影响点,
节点故障
对Redis
的一致性影响也是很大的
举个例子:
当一个节点宕机时,这个节点的 数据就可能同步不到其他节点上,这就会导致数据在节点间不一致
你可能有疑惑?那
Redis
不是有哨兵和复制等机制吗?
但是,问题就是但是,哈哈~这些机制是能提高系统的可用性和容错性,能完全解决吗?
~(你没看错,就是完全解决,能吗??)不能吧,自己主观推下也能想到那种万一场景吧!!!
你说既然异步不行,那么我就用同步机制就不好了!!不就是
CP
了???
~~no!no!NO !哈哈哈,年轻人,想的太简单了哈!
我们看看官网是怎么说的()
Redis
客户端可以使用WAIT
命令请求特定数据进行同步复制- 使用
WAIT
,只能说发生故障时丢失写操作的概率会大大降低,且是在难以触发的故障模式情况下 - 但是!!
WAIT
只能确保数据在Redis
实例中有指定数量的副本被确认
不能将一组
REdis
转换为具有强一制性的CP
系统
什么意思?
在故障转移期间,由于
Redis
的持久化配置,当中已确认的写操作,仍然可能会丢失
完结!~
士不可以不弘毅,任重而道远,诸君共勉!~
来源:juejin.cn/post/7338721296866574376
若依框架——防重复提交自定义注解
防重复提交
1、自定义防重复提交注解
/**
* 自定义注解防止表单重复提交
*
* @author ruoyi
*
*/
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit
{
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
public int interval() default 5000;
/**
* 提示消息
*/
public String message() default "不允许重复提交,请稍候再试";
}
@Inherited:
该元注解表示如果一个类使用了这个 RepeatSubmit 注解,那么它的子类也会自动继承这个注解。这在某些需要对一组相关的控制器方法进行统一重复提交检查的场景下很有用,子类无需再次显式添加该注解。
@Target(ElementType.METHOD):
表明这个注解只能应用在方法上。在实际应用中,通常会将其添加到控制器类的处理请求的方法上,比如 Spring MVC 的 @RequestMapping 注解修饰的方法。
@Retention(RetentionPolicy.RUNTIME):
意味着该注解在运行时仍然存在,可以通过反射机制获取到。这样在运行时,通过 AOP(面向切面编程)等技术拦截方法调用时,就能够读取到注解的属性值,从而实现重复提交的检查逻辑。
@Documented:
这个元注解用于将注解包含在 JavaDoc 中。当生成项目文档时,使用了该注解的方法会在文档中显示该注解及其属性,方便其他开发者了解该方法具有防止重复提交的功能以及相关的配置参数。
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
public int interval() default 5000;
定义了一个名为 interval 的属性,类型为 int,表示两次提交之间允许的最小时间间隔,单位是毫秒。默认值为 5000,即 5 秒。如果两次提交的时间间隔小于这个值,就会被视为重复提交。
/**
* 提示消息
*/
public String message() default "不允许重复提交,请稍候再试";
定义了一个名为 message 的属性,类型为 String,用于在检测到重复提交时返回给客户端的提示消息。默认消息为 “不允许重复提交,请稍候再试”。开发者可以根据具体业务需求,在使用注解时自定义这个提示消息。
2、防止重复提交的抽象类
抽象类可以自己有 具体方法
/**
* 防止重复提交拦截器
*
* @author ruoyi
*/
@Component
public abstract class RepeatSubmitInterceptor implements HandlerInterceptor
{
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
if (handler instanceof HandlerMethod)
{
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
if (annotation != null)
{
if (this.isRepeatSubmit(request, annotation))
{
AjaxResult ajaxResult = AjaxResult.error(annotation.message());
ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
return false;
}
}
return true;
}
else
{
return true;
}
}
/**
* 验证是否重复提交由子类实现具体的防重复提交的规则
*
* @param request 请求信息
* @param annotation 防重复注解参数
* @return 结果
* @throws Exception
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation);
}
2.1、preHandle 方法
自定义抽象类拦截器 RepeatSubmitInterceptor 实现了 HandlerInterceptor 接口,重写 preHandle 方法
preHandle方法是负责拦截请求的
- 如果isRepeatSubmit方法返回true,表示当前请求是重复提交。此时会创建一个包含错误信息的AjaxResult对象,错误信息就是RepeatSubmit注解中设置的message。然后通过ServletUtils.renderString方法将AjaxResult对象转换为 JSON 字符串,并将其作为响应返回给客户端,同时返回false,阻止请求继续处理。
- 如果方法上不存在RepeatSubmit注解,或者isRepeatSubmit方法返回false,表示当前请求不是重复提交,就返回true,允许请求继续执行后续的处理流程。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
if (handler instanceof HandlerMethod)
{
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
if (annotation != null)
{
if (this.isRepeatSubmit(request, annotation))
{
AjaxResult ajaxResult = AjaxResult.error(annotation.message());
ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
return false;
}
}
return true;
}
else
{
return true;
}
}
参数说明:
- HttpServletRequest request:提供了关于当前 HTTP 请求的信息,如请求头、请求参数、请求方法等。
- HttpServletResponse response:用于设置 HTTP 响应,例如设置响应头、响应状态码、写入响应内容等。
- Object handler:代表即将被执行的处理器对象,在 Spring MVC 中,它通常是一个 HandlerMethod,但也可能是其他类型。
方法解释:
if (handler instanceof HandlerMethod)
{
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
首先检查 handler 是否是 HandlerMethod 类型的 , 不是的话,直接放行,不做重复提交检查, 因为该拦截器主要针对被 @RepeatSubmit 注解标记的方法进行处理。
如果 handler 是 HandlerMethod 类型的话,将 handler 转换成为 HandlerMethod 并获取对应的 Method 对象。
然后通过 getMethod() 方法 获取 方法,并通过 getAnnotation 方法获取 RepeatSubmit 注解 ,
if (annotation != null) {
if (this.isRepeatSubmit(request, annotation)) {
AjaxResult ajaxResult = AjaxResult.error(annotation.message());
ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
return false;
}
}
return true;
判断是否获取到 RepeatSubmit 注解,没有获取到,返回 true , 允许请求继续执行后续的处理流程。
运用 isRepeatSubmit 方法 判断是否是 重复提交
如果当前请求是重复提交 将注解的 错误信息 封装给结果映射对象
并调用 renderString 方法 将字符串渲染到客户端
/**
* 将字符串渲染到客户端
*
* @param response 渲染对象
* @param string 待渲染的字符串
*/
public static void renderString(HttpServletResponse response, String string)
{
try
{
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
}
catch (IOException e)
{
e.printStackTrace();
}
}
2.2、isRepeatSubmit 方法
判断是否重复提交 true 重复提交 false 不重复提交
/**
* 验证是否重复提交由子类实现具体的防重复提交的规则
*
* @param request 请求信息
* @param annotation 防重复注解参数
* @return 结果
* @throws Exception
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation);
public final String REPEAT_PARAMS = "repeatParams";
public final String REPEAT_TIME = "repeatTime";
// 令牌自定义标识
@Value("${token.header}")
private String header; // token.header = "Authorization"
@Autowired
private RedisCache redisCache;
@SuppressWarnings("unchecked")
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation)
{
String nowParams = "";
if (request instanceof RepeatedlyRequestWrapper)
{
RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
nowParams = HttpHelper.getBodyString(repeatedlyRequest);
}
// body参数为空,获取Parameter的数据
if (StringUtils.isEmpty(nowParams))
{
nowParams = JSON.toJSONString(request.getParameterMap());
}
Map<String, Object> nowDataMap = new HashMap<String, Object>();
nowDataMap.put(REPEAT_PARAMS, nowParams);
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();
// 唯一值(没有消息头则使用请求地址)
String submitKey = StringUtils.trimToEmpty(request.getHeader(header));
// 唯一标识(指定key + url + 消息头)
String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey;
Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
if (sessionObj != null)
{
Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
if (sessionMap.containsKey(url))
{
Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, annotation.interval()))
{
return true;
}
}
}
Map<String, Object> cacheMap = new HashMap<String, Object>();
cacheMap.put(url, nowDataMap);
redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);
return false;
}
@SuppressWarnings("unchecked")
注解 @SuppressWarnings("unchecked"): 这个注解用于抑制编译器的 “unchecked” 警告。在代码中,可能存在一些未经检查的类型转换操作,使用该注解可以告诉编译器忽略这些警告。
String nowParams = "";
初始化一个字符串变量 nowParams 用于存储当前请求的参数。
if (request instanceof RepeatedlyRequestWrapper)
{
RepeatedlyRequestWrapper repeatedlyRequest = (RepeatedlyRequestWrapper) request;
nowParams = HttpHelper.getBodyString(repeatedlyRequest);
}
判断 当前请求是否是 RepeatedlyRequestWrapper 类型的
RepeatedlyRequestWrapper 是自定义的 允许多次请求的请求体 (详情见备注)
如果是的话,强转对象,并且 通过 getBodyString 方法 (详情见备注) 获取请求体的字符串内容,并且赋值给 nowParams
// body参数为空,获取Parameter的数据
if (StringUtils.isEmpty(nowParams))
{
nowParams = JSON.toJSONString(request.getParameterMap());
}
Map<String, Object> nowDataMap = new HashMap<String, Object>();
nowDataMap.put(REPEAT_PARAMS, nowParams); //REPEAT_PARAMS = "repeatParams"
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis()); //REPEAT_TIME = "repeatTime"
if (StringUtils.isEmpty(nowParams)):如果通过上述方式获取的 nowParams 为空,说明请求体可能为空,此时通过 JSON.toJSONString(request.getParameterMap()) 将请求参数转换为 JSON 字符串,并赋值给 nowParams。这样无论请求参数是在请求体中还是在 URL 参数中,都能获取到。
- Map<String, Object> nowDataMap = new HashMap<String, Object>(); 创建一个新的 HashMap 用于存储当前请求的数据。
- nowDataMap.put(REPEAT_ PARAMS, nowParams); 将获取到的请求参数存入 nowDataMap 中,使用常量 REPEAT_PARAMS 作为键。
- nowDataMap.put(REPEAT_TIME, System.currentTimeMillis()); 将当前时间戳存入 nowDataMap 中,使用常量 REPEAT_TIME 作为键。
// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();
// 唯一值(没有消息头则使用请求地址)
String submitKey = StringUtils.trimToEmpty(request.getHeader(header));
// 唯一标识(指定key + url + 消息头)
String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey;
// REPEAT_SUBMIT_KEY = "repeat_submit:"
- String url = request.getRequestURI(); 获取当前请求的 URI。
- String submitKey = StringUtils.trimToEmpty(request.getHeader(header)); 从请求头中获取指定的键值(header 变量可能是在类中定义的一个常量,表示要获取的请求头字段),并去除两端的空白字符。如果请求头中不存在该字段,则返回空字符串。
- String cacheRepeatKey = CacheConstants . REPEAT_SUBMIT_KEY + url + submitKey; 使用一个常量 CacheConstants.REPEAT _SUBMIT_KEY 与请求 URI 和 submitKey 拼接生成一个唯一的缓存键 cacheRepeatKey。这个键用于在缓存中存储和检索与该请求相关的重复提交信息。
Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);
if (sessionObj != null)
{
Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
if (sessionMap.containsKey(url))
{
Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, annotation.interval()))
{
return true;
}
}
}
Map<String, Object> cacheMap = new HashMap<String, Object>();
cacheMap.put(url, nowDataMap);
redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);
rerurn false;
通过缓存键 先去 redis 中,看 是否存在相同的缓存信息 如果存在,说明之前有过类似的请求 ,进入判断
因为这里 redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS); 传了map,所以说 redisCache.getCacheObject(cacheRepeatKey); 得到的map,就是同样的类型的,所以键值就是 url 。
检查 sessionMap 这个 Map 中是否包含以当前请求的 url 作为键的记录。这一步是因为在缓存的数据结构中,url 被用作内层键来存储每个请求的具体数据。如果存在这个键,说明之前已经有针对该 url 的请求被缓存。
接下来
调用 compareParams 方法比较当前请求的数据 nowDataMap 和之前请求的数据 preDataMap 的参数是否相同,同时调用 compareTime 方法比较当前请求时间和之前请求时间的间隔是否小于 @RepeatSubmit 注解中配置的 interval 时间。如果参数相同且时间间隔小于设定值,说明当前请求可能是重复提交,返回 true。
如果缓存中不存在当前请求 url 的记录,或者当前请求不被判定为重复提交,则执行以下操作: Map<String, Object> cacheMap = new HashMap<String, Object>();:创建一个新的 HashMap 用于存储当前请求的数据。 cacheMap.put(url, nowDataMap);:将当前请求的 url 作为键,nowDataMap(包含当前请求参数和时间)作为值存入 cacheMap。 redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);:将 cacheMap 以 cacheRepeatKey 为键存入 Redis 缓存中,缓存时间为 @RepeatSubmit 注解中配置的 interval 时间,时间单位为毫秒。这样下次相同 url 的请求过来时,就可以从缓存中获取到之前的请求数据进行比较。
2.2.1、compareParams 方法
/**
* 判断参数是否相同
*/
private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap)
{
String nowParams = (String) nowMap.get(REPEAT_PARAMS);
String preParams = (String) preMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}
2.2.2、compareTime 方法
/**
* 判断两次间隔时间
*/
private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap, int interval)
{
long time1 = (Long) nowMap.get(REPEAT_TIME);
long time2 = (Long) preMap.get(REPEAT_TIME);
if ((time1 - time2) < interval)
{
return true;
}
return false;
}
备注:
RepeatedlyRequestWrapper
一个自定义的请求包装类,允许多次读取请求体
/**
* 构建可重复读取inputStream的request
*
* @author ruoyi
*/
public class RepeatedlyRequestWrapper extends HttpServletRequestWrapper
{
private final byte[] body;
public RepeatedlyRequestWrapper(HttpServletRequest request, ServletResponse response) throws IOException
{
super(request);
request.setCharacterEncoding(Constants.UTF8);
response.setCharacterEncoding(Constants.UTF8);
body = HttpHelper.getBodyString(request).getBytes(Constants.UTF8);
}
@Override
public BufferedReader getReader() throws IOException
{
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() throws IOException
{
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream()
{
@Override
public int read() throws IOException
{
return bais.read();
}
@Override
public int available() throws IOException
{
return body.length;
}
@Override
public boolean isFinished()
{
return false;
}
@Override
public boolean isReady()
{
return false;
}
@Override
public void setReadListener(ReadListener readListener)
{
}
};
}
}
getBodyString 方法
将二进制的输入流数据转换为易于处理的字符串形式,方便后续对请求体内容进行解析和处理
public static String getBodyString(ServletRequest request)
{
StringBuilder sb = new StringBuilder();
BufferedReader reader = null;
try (InputStream inputStream = request.getInputStream())
{
reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line = "";
while ((line = reader.readLine()) != null)
{
sb.append(line);
}
}
catch (IOException e)
{
LOGGER.warn("getBodyString出现问题!");
}
finally
{
if (reader != null)
{
try
{
reader.close();
}
catch (IOException e)
{
LOGGER.error(ExceptionUtils.getMessage(e));
}
}
}
return sb.toString();
}
来源:juejin.cn/post/7460129833931849737
一次关键接口设计和优化带来的思考
实习时负责实现一个任务新增的接口,本来以为应该可以轻松拿捏,结果在实现过程中发现还有点小复杂,优化了很多版,并且其中涉及到了很多之前学过的知识点,故记录一下。
接口基本信息
在无人机管理系统中,对无人机执行任务时的监控是非常重要的模块,系统的用户可以为无人机创建新的飞行任务,除了任务的基本信息外,用户还需要为飞行任务分配负责人,设备,飞手(操作无人机的人),航线,栅栏(任务区域)等信息,而后端实现时需要做好各种校验,对用户数据进行整理转换并插入不同的数据库表中,考虑与系统其他模块的关系(例如航线稽查模块),在系统内通知相关用户,发送邮件给相关用户,另外还要考虑接口幂等性,数据库事务问题,接口的进一步优化。
接口实现
- 参数校验
- 参数非空校验,格式校验,业务上的校验。
- 其中业务上的校验比较复杂:要保证设备,飞手,航线都存在,且是一 一对应关系;要确保任务的负责人有权限调动相关设备和人员(认证鉴权模块);确保设备,飞手都是可用状态;要检查设备所在位置与任务区域;要检查设备在指定时间内是否已被占用。
- 幂等性校验
- 新增或编辑接口都可能会产生幂等性问题,尤其这种关键的新增接口一般都要保证幂等性。
- 这里我使用的方案是创建任务时生成一个token保存在redis中,并返回给前端,前端提交任务时在请求中携带token,后端检查到redis中有token证明是第一次访问,删除token并执行后续逻辑(去redis中查并删除token用lua脚本保证原子性),如果请求重复提交则后端查不到token直接返回。
- 也顺便研究了一下其他幂等性方案,包括前端防重复提交,唯一id限制数据库插入,防重表,全局唯一请求id等,发现还是目前使用redis的这种方案更简单高效。
- 生成任务对象,设置任务基本信息,并将下列得到信息赋予任务对象
- 从线程上下文获取到当前用户信息设为负责人
- 用设备id,用户id去对应表批量查找对应数据(注意一个任务中设备,飞手,航线是一 一对应,为一个组合,一个任务中可能有多个这种组合)
- 将航线转化为多个地理点,保存到列表用于后续批量插入任务航线表
- 为每条航线创建稽查事务对象,保存到列表用于后续批量插入稽查表
- 将任务区域转化为多个地理点,保存到列表用于后续批量插入任务区域表
- 批量插入数据
- 将任务对象插入任务表,将之前保存的列表分别批量插入到航线表,区域表,稽查表。
- 任务创建成功
- 更新任务状态
- 通过Kafka异步发送邮件通知飞手和负责人
private final String LUA_SCRIPT =
"if redis.call('EXISTS', KEYS[1]) == 1 then\n" +
" redis.call('DEL', KEYS[1])\n" +
" return true\n" +
"else\n" +
" return false\n" +
"end";
DefaultRedisScript<Boolean> script = new DefaultRedisScript<>(LUA_SCRIPT, Boolean.class);
Boolean success = redisTemplate.execute(script, Collections.singletonList(token));
if (success == null || !success) {
throw new Exception(GlobalErrorCodeEnum.FAIL);
}
// 后续业务逻辑
接口优化
费尽九牛二虎之力写完接口,de完bug后,真正的挑战才开始,此时测试了一下接口的性能,好家伙,平均响应时间1000多ms,肯定是需要优化的,故开始思考优化方案以及测试方案。
压测方案
- 先屏蔽幂等性校验,设置好接口参数(多个设备,航线长度设置为较长,区域正常设置)
- 在三种场景下进行测试(弱压力场景:1分钟内100个用户访问。高并发场景:1秒内100个用户访问。高频率场景:2个用户以10QPS持续访问10秒)。以下图片是相关设置
- 主要关注接口的平均响应时间,吞吐量和错误率。同时CPU使用率,磁盘IO,网络IO也要关注。
优化方案1
首先是把接口中一些不必要的操作删除;并且需要多次查询和插入的数据库操作都改为了批量操作;调整好索引,确保查询能正常走索引。代码与压测结果如下:
注意本文提供的代码仅用于展示,只展示关键步骤,不包含完整实现,若代码中有错误请忽略,理解思路即可。
弱压力和高频率下接口的平均响应时间降低为200ms左右,高并发情况下仍然需要500ms以上,没有出现错误情况,吞吐量也正常。看来数据库操作还是主要耗时的地方。
@Transactional(propagation = Propagation.REQUIRED, rollbackFor = EcpException.class)
public boolean insertTask(TaskInfoVO taskInfoVO) {
TaskInfo taskInfo = new TaskInfo();
// 基本信息查询与填充,分配负责人
// ...
// 查询并分配设备
// ...
List<Devices> devices = deviceService.selectList(new QueryWrapper<dxhpDevices>().in("identity_auth_id", identityAuthIds));
taskInfo.setDevice(getIdentityAuthId());
// 查询并分配飞手
// ...
List<User> devicePerson = userService.selectBatchIds(devicePersonIds);
taskInfo.setDevicePerson(getDevicePerson());
// 处理并分配航线
// ...
List<TaskTrajectory> trajectoryList = getTrajectoryList(taskInfoVO.getTrajectoryList(), taskId);
taskInfo.setTaskTrajectoryId(trajectorysId);
// 对每条航线创建初始稽查记录
// ...
List<Check> checkList = getCheckList(taskInfoVO, taskId, trajectorysId);
taskInfo.setCheckEventId(checkEventsId);
// 分配区域
// ...
List<Range> taskRangeList = getTaskRange(range, taskId);
taskInfo.setTaskRangeId(taskRangeId);
// 插入任务表
this.dao.insert(taskInfo);
// 批量插入任务航线表
trajectoryService.insertBatch(trajectoryList);
// 批量插入任务区域表
...
// 批量插入稽查表
...
}
优化方案2
这里发现数据库的主键使用了uuid,根据之前的学习,uuid是无序的,在插入数据库时会造成页分裂导致效率降低,故考虑把uuid改为数据库自增主键。压测结果如下:
三种情况下的接口平均响应时间都略有降低,但是我重复测试后又发现有时几乎与之前一样,效果不稳定,所以实际使用uuid插入是否真的比自增id插入效率低还不好说,要看具体业务场景。
后来问了导师为什么用uuid做主键,原因是使用uuid方便分库分表,因为不会重复,而自增id在分库分表时可能还要考虑每个表划分id起始点,比较麻烦。
另外,在分布式系统中分布式id的生成是个很重要的基础服务,除了uuid还有雪花算法,数据库唯一主键,redis递增主键,号段模式。
优化方案3
串行改为并行,开启多个线程去并行查询不同模块的数据并做数据库的插入操作,主要使用CompletableFuture类。代码和压测结果如下:
三种场景平均响应时间分别为:82ms,397ms,185ms。弱压力和高频率下性能有所提升,高并发下提升不明显,原因是高并发情况本身CPU就拉满了,再使用多线程去并行就没什么用了。
另外这里使用了自定义的线程池,实际业务中如果需要使用线程池,需要合理设置线程池的相关参数,例如核心线程池,最大线程数,线程池类型,阻塞队列,拒绝策略等,还要考虑线程池隔离。并且需要谨慎分析业务逻辑是否适合使用多线程,有时候加了多线程反而效果更差。
// 开启异步线程执行任务,指定线程池
CompletableFuture.runAsync(() -> {
// 处理航线数据
// ...
List<TaskTrajectory> trajectoryList = getTrajectoryList(taskInfoVO.getTrajectoryList(), taskId);
taskInfo.setTaskTrajectoryId(trajectorysId);
// 批量插入数据库
trajectoryService.insertBatch(trajectoryList)
}, executor);
// 其他模块的操作同理
优化方案4
开启Kafka,将插入操作都变为异步的,即任务表的数据插入后发消息到Kafka中,其他相关表的插入都通过去Kafka中读取消息后再慢慢执行。代码和压测结果如下:
弱压力和高频率下的性能差异不大,但是高并发情况下接口的响应时间又飙到了近1000ms。
经过排查,在高并发时CPU和网络IO都拉满了,应该是瞬时向Kafka发送大量消息导致网卡压力比较大,接口的消息发送不出去导致响应时间飙升。如果是正常生产环境下肯定有多台机器分散请求,同时发数据到Kafka,并且有Kafka集群分担接收压力,但是目前只能在我自己机器上测,故高并发场景下将1秒100个请求降为1秒20个请求,并且前面的优化重新测试,比较性能。结果如下
批量插入:接口平均响应时间354ms
uuid改为自增id:接口平均响应时间323ms
串行改并行:接口平均响应时间331ms
用kafka做异步插入:接口平均响应时间191ms
可以看出使用了异步插入后效果还是十分明显的,且CPU和网络IO也都处于合理的范围内。至此优化基本结束,从一开始的近1000ms的响应速度优化到200ms左右,还是有一定提升的。
// 生产者代码
List<TaskTrajectory> trajectoryList = getTrajectoryList(taskInfoVO.getTrajectoryList(), taskId);
String key = IdUtils.uuid(); // 标识不同数据,方便后续Kafka消息防重
MessageVO messageVO = new MessageVO();
messageVO.setMsgID("trajectoryService"); // 告知要操作的类
messageVO.setMsgBody(JSON.toJSONString(trajectoryList)); // 要操作的数据
// 发送消息并指定主题和分区
kafkaTemplate.send("taskTopic", "Partition 1", JSON.toJSONString(messageVO));
// 消费者代码
// 使用 @KafkaListener监听并指定对应的主题和分区
@KafkaListener(id = "listener", topics = "taskTopic", topicPartitions = @TopicPartition(topic = "taskTopic", partitions = "0"))
public void recvTaskMessage(String message, Acknowledgment acknowledgment) {
// 接收消息
MessageVO messageVo = JSON.parseObject(message, MessageVO.class);
// 根据消息的唯一ID,配合redis判断消息是否重复
...
// 消费消息
List<TaskTrajectory> list = JSON.parseArray(messageVo.getMsgBody(), TaskTrajectory.class);
trajectoryService.insertBatch(list);
//手动确认消费完成,通知 Kafka 服务器该消息已经被处理。
acknowledgment.acknowledge();
}
其他问题
问题1
引入了Kafka后需要考虑的问题:消息重复消费,消息丢失,消息堆积,消息有序。
消息重复消费:生产者生成消息时带上唯一id,保存到redis中,消费者消费过后就把id从redis中删除,若有重复的消息到来,消费者去redis中找不到对应id则不处理。(与前面的接口幂等性方案类似)
消息丢失:生产者发送完消息后会回调判断消息是否发送成功,Kafka的Broker收到消息后要回复ACK给生产者,若没有发送成功要重试。Kafka自身则通过副本机制保证消息不丢失。消费者接收并处理完消息后才回复ACK,即设置手动提交offset。
消息堆积:加机器,提高配置,生产者限流。
消息有序:一个消费者消费一个partition,partition中的消息有序,消费者按顺序处理即可。若消费者开启多线程,则要考虑在内存中为每个线程开启队列,相同key的消息按顺序入队处理。
问题2
长事务问题:像新增任务这类接口肯定是需要加事务的,一开始我直接使用了spring的声明式事务,即@Transactional,并且我看其他业务接口好像也都是这样用的,后来思考了一下新增任务这个接口要先查好几个表,再批量插入好几个表,如果用@Transactional全锁住了那肯定会出问题,故后来使用TransactionTemplate编排式事务只对插入的操作加事务。
另外,远程调用的方法也不用加事务,因为无法回滚远程的数据库操作,除非加分布式事务(效率低),一般关键业务远程调用成功但是后续调用失败的话需要设计兜底方案,对远程调用操作的数据进行补偿,保证最终一致性。
// 避免长事务,不使用@Transactional,使用事务编排
transactionTemplate.execute(transactionStatus -> {
try {
this.dao.insert(taskInfo);
trajectoryService.insertBatch(trajectoryList);
...
} catch (Exception e) {
transactionStatus.setRollbackOnly(); // 异常手动设置回滚
}
return true;
});
问题3
线程池隔离:一些关键的接口使用的线程池要与普通接口使用的线程池隔离,否则一旦普通接口把线程池打满,关键接口也会不可用。例如我上面的优化有使用了多线程,可能需要单独开一个线程池或者使用与其他普通接口不同的线程池。
第三方接口异常重试:如果说需要调用第三方接口或者远程服务,需要做好调用失败的兜底方案,根据业务考虑是重试还是直接失败,重试的时间和次数限制等。
接口的权限:黑白名单设置,可用Bloom过滤器实现
日志:关键的业务代码注意打日志进行监测,方便后续排查异常。
以上是我在设计实现一个重要接口,并对其进行优化时所思考的一些问题,当然上面提到的内容不一定完全正确,可能有很多还没考虑到的地方,有些问题也可能有更成熟的解决方案,但是整个思考过程还是很有收获的,期待能够继续成长。
来源:juejin.cn/post/7410601536126795811
各种O(PO,BO,DTO,VO等) 是不是人为增加系统复杂度?
在Java和其他编程语言的开发过程中,经常会用到几个以"O"结尾的缩写,比如PO,BO,DTO,VO等等,O在这里是Object的缩写,不同的O代表了不同的数据类型,很多时候这些O看起来都是差不多的,干的事情好像也只是一个简单的封装,那么搞出这么多O出来是不是人为增加了系统的复杂度呢?
各种O都是干什么的?
想要搞清楚标题中的问题,我们首先得了解这些O都是什么东西?这里给大家介绍几种常见的O:
- PO (Persistent Object) - 持久化对象。 持久化对象通常对应数据库中的一个表,主要用于表示数据库中存储的数据。PO中的属性通常和数据表的列一一对应,用于ORM(对象关系映射)框架中,如Hibernate,JPA等。
- BO (Business Object) - 业务对象。 业务对象主要封装了业务逻辑。它可以包含多个PO,或者是一个PO的扩展,增加了业务处理的逻辑。BO通常在业务层被使用,用于实现业务操作,比如计算、决策等。
- VO (Value Object) - 值对象。 值对象是一种用于传输数据的简单对象,它通常不包含业务逻辑,只包含数据属性和get/set方法。值对象主要用于业务层与表示层之间的数据传递,它的数据可能是由多个PO组合而成。
- DTO (Data Transfer Object) - 数据传输对象。 数据传输对象类似于VO,它也是用于层与层之间的数据传递。DTO通常用于远程通信,比如Web服务之间的数据传递。DTO通常不包含任何业务逻辑,只是用于在不同层次或不同系统之间传输数据。
有时候我们还会看到DO、POJO等概念,它们又是什么呢?
- DO (Domain Object) - 领域对象。 领域对象是指在问题领域内被定义的对象,它可以包含数据和行为,并且通常代表现实世界中的实体。在DDD(领域驱动设计)中,领域对象是核心概念,用于封装业务逻辑和规则。这里需要注意DO和BO的区别,虽然都是搞业务逻辑,DO通常是业务领域中单一实体的抽象,它关注于单个业务实体的属性和行为;而BO则通常涉及到业务流程的实现,可能会协调多个DO来完成一个业务操作。
- POJO (Plain Old Java Object) - 简单老式Java对象。 POJO是指没有遵循特定Java对象模型、约定或框架(如EJB)的简单Java对象。POJO通常用于表示数据结构,它们的实例化和使用不依赖于特定的容器或框架。
为什么要划分各种O?
在软件开发中划分不同的O主要是为了实现关注点分离(Separation of Concerns,SoC),提高代码的可维护性、可读性和可扩展性。
关注点分离的典型案例:MVC模式。
下面展开列举了一些划分这些对象的原因:
- 明确职责:通过将不同的职责分配给不同的对象,可以使每个对象都有明确的职责,这样代码更容易理解和维护。
- 减少耦合:不同层次之间通过定义清晰的接口(如特定的对象)交互,减少了直接的依赖关系,降低了耦合度。
- 抽象层次:通过定义不同的对象,可以在不同的抽象层次上操作,比如在数据层处理PO,在业务层处理BO,这样可以在合适的层次上做出决策。
- 灵活性:当系统需要变更时,由于职责和层次的清晰划分,更容易做出局部的修改而不影响到整个系统。不同的对象可能针对性能有不同的优化,例如PO可能被优化以提高数据库操作的性能。
- 安全性:通过使用不同的对象,可以控制敏感数据的暴露。例如,可以在DTO中排除一些不应该传输到前端的敏感信息。
- 测试性:分离的对象使得单元测试变得更加容易,因为可以针对每个对象进行独立的测试。
- 交互清晰:在不同的系统组件或层次之间传递数据时,清晰的对象定义可以让数据交互更加清晰,减少数据传递中的错误。
总之,通过划分各种“O”对象,开发者可以更好地组织代码,将复杂系统分解为更小、更易于管理的部分,同时也有助于团队成员之间的沟通和协作。这种划分在设计模式和软件工程实践中是一种常见且有效的方法。
OO不分的惨痛经历
说个实际的惨痛经验。
很多时候我会感觉这些O之间存在很多重复的代码,比如重复的属性定义、简单的方法封装,DRY(Don't Repeat Yourself)原则不是说让大家避免重复嘛,所以我也曾经尝试在程序中统一它们。
但是总有一些O之间存在或多或少的差异,比如:
- 这个O需要一个A属性,仅用于内部状态管理,不会暴露到外部,其它O都不需要。
- 还有这个接口需要返回一个B属性,其它接口都不需要。
这时候,你怎么办?如果使用同一个类型,那就得加上这些属性,尽管它们在某些时候用不到。根据你的选择,你可能在所有的地方都给这个属性赋值,也可能仅在业务需要的时候给他们赋值。
看个实际的例子:在一个复杂的电商系统中,商品的管理可能涉及到库存管理、价格策略、促销信息等多个方面。
// 商品类
public class Product {
private Long id; // 来自商品表
private String name; // 来自商品表
private double price; // 来自商品表,传输时需要特殊格式
private int stock; // 来自库存表,仅在下单判断中需要,展示层不需要
private String promotionInfo; // 来自促销表,展示层需要
// 构造器、getter和setter方法省略
}
但是这却带来了很大的危害:
- 调用接口的同学会问,这个属性什么时候会有值,什么时候会没值?
- 优化的同学会问,计算这个属性的值会影响性能,能删掉吗?
- 交接的同学会问,这个属性是干什么用的,为什么不给他赋值?
总之会增加了大量的沟通成本与维护难度。一旦这样做了,后边就会特别别扭,改不完,根本改不完。
在软件工程化的今天,各类O的设计看似增加了复杂度,但是实际上是对系统模块化、职责划分以及实际应用场景的合理抽象和封装,有助于提高软件质量和团队协作效率。
老老实实写吧,不同的O就是不同的东西,它们不是重复的,只是在代码上看着像,就像人有四肢,动物也有四肢,但是它们不能共用,否则出来的就是四不像。
图片来源:ozhanozturk.com/2018/01/28/…
当然如果只是一个很简单的程序或者一次性的程序,我们确实没必要划分这么多的O出来,直接在接口方法中访问数据库也不是不可以的。
前端中O的使用
虽然各种O一般活跃在各种后端程序中,但是前端也不乏O的身影,只是没有后端那么形式化。
以下是一些可能在前端开发中遇到的以“O”结尾的数据对象:
- VO (View Object) - 视图对象。在前端框架中,VO可以代表专门为视图层定制的数据对象。这些对象通常是从后端接口获取的数据经过加工或格式化后,用于在界面上显示的对象。
- DTO (Data Transfer Object) - 数据传输对象。虽然DTO通常用于后端服务间的数据传输,但在前端中也可以用来表示从后端接口获取的数据结构。前端的DTO通常是指通过Ajax或Fetch API从服务器获取的原始数据结构。
- VMO (ViewModel Object) - 视图模型对象。在MVVM(Model-View-ViewModel)架构中,VMO可以代表视图模型对象,它是模型和视图之间的连接器。在Vue.js中,Vue实例本身就可以被看作是一个VMO,因为它包含了数据和行为,同时也是视图的反映。
- SO (State Object) - 状态对象 尽管不是标准的术语,但在使用如Vuex这样的状态管理库时,SO可以用来指代代表应用状态的对象。这些状态对象通常包含了应用的核心数据,如用户信息、应用设置等。
在实际的Vue开发过程中,开发者可能不会严格区分这些概念,而是更多地关注于组件的状态、属性(props)、事件和生命周期。组件内部的数据通常以数据属性(data)的形式存在,而组件间的数据传递通常使用属性(props)和事件(emits)。在处理与后端的数据交互时,开发者可能会定义一些专门的对象来适应后端的接口,但是这些都不是Vue框架强制的概念或规则。
简单地说,这些“O”其实就是帮我们把代码写得更清晰、更有条理,虽然一开始看着很麻烦,但时间一长,你会发现这样做能省下不少力气。就像我们的衣柜,虽然分类放好衣服需要点时间,但每天早上起来挑衣服的时候,不就轻松多了吗?
记住,合适的工具用在合适的地方,能让你事半功倍!
关注萤火架构,加速技术提升!
来源:juejin.cn/post/7336020150867230757
如何实现一个通用的接口限流、防重、防抖机制
介绍
最近上了一个新项目,考虑到一个问题,在高并发场景下,我们无法控制前端的请求频率和次数,这就可能导致服务器压力过大,响应速度变慢,甚至引发系统崩溃等严重问题。为了解决这些问题,我们需要在后端实现一些机制,如接口限流、防重复提交和接口防抖,而这些是保证接口安全、稳定提供服务,以及防止错误数据 和 脏数据产生的重要手段。
而AOP适合在在不改变业务代码的情况下,灵活地添加各种横切关注点,实现一些通用公共的业务场景,例如日志记录、事务管理、安全检查、性能监控、缓存管理、限流、防重复提交等功能。这样不仅提高了代码的可维护性,还使得业务逻辑更加清晰专注,关于AOP不理解的可以看这篇文章。
最近上了一个新项目,考虑到一个问题,在高并发场景下,我们无法控制前端的请求频率和次数,这就可能导致服务器压力过大,响应速度变慢,甚至引发系统崩溃等严重问题。为了解决这些问题,我们需要在后端实现一些机制,如接口限流、防重复提交和接口防抖,而这些是保证接口安全、稳定提供服务,以及防止错误数据 和 脏数据产生的重要手段。
而AOP适合在在不改变业务代码的情况下,灵活地添加各种横切关注点,实现一些通用公共的业务场景,例如日志记录、事务管理、安全检查、性能监控、缓存管理、限流、防重复提交等功能。这样不仅提高了代码的可维护性,还使得业务逻辑更加清晰专注,关于AOP不理解的可以看这篇文章。
接口限流
接口限流是一种控制访问频率的技术,通过限制在一定时间内允许的最大请求数来保护系统免受过载。限流可以在应用的多个层面实现,比如在网关层、应用层甚至数据库层。常用的限流算法有漏桶算法(Leaky Bucket)、令牌桶算法(Token Bucket)等。限流不仅可以防止系统过载,还可以防止恶意用户的请求攻击。
限流框架大概有
- spring cloud gateway集成redis限流,但属于网关层限流
- 阿里Sentinel,功能强大、带监控平台
- srping cloud hystrix,属于接口层限流,提供线程池与信号量两种方式
- 其他:redisson、redis手撸代码
接口限流是一种控制访问频率的技术,通过限制在一定时间内允许的最大请求数来保护系统免受过载。限流可以在应用的多个层面实现,比如在网关层、应用层甚至数据库层。常用的限流算法有漏桶算法(Leaky Bucket)、令牌桶算法(Token Bucket)等。限流不仅可以防止系统过载,还可以防止恶意用户的请求攻击。
限流框架大概有
- spring cloud gateway集成redis限流,但属于网关层限流
- 阿里Sentinel,功能强大、带监控平台
- srping cloud hystrix,属于接口层限流,提供线程池与信号量两种方式
- 其他:redisson、redis手撸代码
本文主要是通过 Redisson 的分布式计数来实现的 固定窗口 模式的限流,也可以通过 Redisson 分布式限流方案(令牌桶)的的方式RRateLimiter。
在高并发场景下,合理地实施接口限流对于保障系统的稳定性和可用性至关重要。
- 自定义接口限流注解类
@AccessLimit
/**
* 接口限流
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
/**
* 限制时间窗口间隔长度,默认10秒
*/
int times() default 10;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 上述时间窗口内允许的最大请求数量,默认为5次
*/
int maxCount() default 5;
/**
* redis key 的前缀
*/
String preKey();
/**
* 提示语
*/
String msg() default "服务请求达到最大限制,请求被拒绝!";
}
- 利用
AOP
实现接口限流
/**
* 通过AOP实现接口限流
*/
@Component
@Aspect
@Slf4j
@RequiredArgsConstructor
public class AccessLimitAspect {
private static final String ACCESS_LIMIT_LOCK_KEY = "ACCESS_LIMIT_LOCK_KEY";
private final RedissonClient redissonClient;
@Around("@annotation(accessLimit)")
public Object around(ProceedingJoinPoint point, AccessLimit accessLimit) throws Throwable {
String prefix = accessLimit.preKey();
String key = generateRedisKey(point, prefix);
//限制窗口时间
int time = accessLimit.times();
//获取注解中的令牌数
int maxCount = accessLimit.maxCount();
//获取注解中的时间单位
TimeUnit timeUnit = accessLimit.timeUnit();
//分布式计数器
RAtomicLong atomicLong = redissonClient.getAtomicLong(key);
if (!atomicLong.isExists() || atomicLong.remainTimeToLive() <= 0) {
atomicLong.set(0);
atomicLong.expire(time, timeUnit);
}
long count = atomicLong.incrementAndGet();
;
if (count > maxCount) {
throw new LimitException(accessLimit.msg());
}
// 继续执行目标方法
return point.proceed();
}
public String generateRedisKey(ProceedingJoinPoint point, String prefix) {
//获取方法签名
MethodSignature methodSignature = (MethodSignature) point.getSignature();
//获取方法
Method method = methodSignature.getMethod();
//获取全类名
String className = method.getDeclaringClass().getName();
// 构建Redis中的key,加入类名、方法名以区分不同接口的限制
return String.format("%s:%s:%s", ACCESS_LIMIT_LOCK_KEY, prefix, DigestUtil.md5Hex(String.format("%s-%s", className, method)));
}
}
- 调用示例实现
@GetMapping("/getUser")
@AccessLimit(times = 10, timeUnit = TimeUnit.SECONDS, maxCount = 5, preKey = "getUser", msg = "服务请求达到最大限制,请求被拒绝!")
public Result getUser() {
return Result.success("成功访问");
}
防重复提交
在一些业务场景中,重复提交同一个请求可能会导致数据的不一致,甚至严重影响业务逻辑的正确性。例如,在提交订单的场景中,重复提交可能会导致用户被多次扣款。为了避免这种情况,可以使用防重复提交技术,这对于保护数据一致性、避免资源浪费非常重要
- 自定义接口防重注解类
@RepeatSubmit
/**
* 自定义接口防重注解类
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {
/**
* 定义了两种防止重复提交的方式,PARAM 表示基于方法参数来防止重复,TOKEN 则可能涉及生成和验证token的机制
*/
enum Type { PARAM, TOKEN }
/**
* 设置默认的防重提交方式为基于方法参数。开发者可以不指定此参数,使用默认值。
* @return Type
*/
Type limitType() default Type.PARAM;
/**
* 允许设置加锁的过期时间,默认为5秒。这意味着在第一次请求之后的5秒内,相同的请求将被视为重复并被阻止
*/
long lockTime() default 5;
//提供了一个可选的服务ID参数,通过token时用作KEY计算
String serviceId() default "";
/**
* 提示语
*/
String msg() default "请求重复提交!";
}
- 利用
AOP
实现接口防重处理
/**
* 利用AOP实现接口防重处理
*/
@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RepeatSubmitAspect {
private final String REPEAT_SUBMIT_LOCK_KEY_PARAM = "REPEAT_SUBMIT_LOCK_KEY_PARAM";
private final String REPEAT_SUBMIT_LOCK_KEY_TOKEN = "REPEAT_SUBMIT_LOCK_KEY_TOKEN";
private final RedissonClient redissonClient;
private final RedisRepository redisRepository;
@Pointcut("@annotation(repeatSubmit)")
public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
}
/**
* 环绕通知, 围绕着方法执行
* 两种方式
* 方式一:加锁 固定时间内不能重复提交
* 方式二:先请求获取token,再删除token,删除成功则是第一次提交
*/
@Around("pointCutNoRepeatSubmit(repeatSubmit)")
public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
//用于记录成功或者失败
boolean res = false;
//获取防重提交类型
String type = repeatSubmit.limitType().name();
if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
//方式一,参数形式防重提交
//通过 redissonClient 获取分布式锁,基于IP地址、类名、方法名生成唯一key
String ipAddr = IPUtil.getIpAddr(request);
String preKey = repeatSubmit.preKey();
String key = generateTokenRedisKey(joinPoint, ipAddr, preKey);
//获取注解中的锁时间
long lockTime = repeatSubmit.lockTime();
//获取注解中的时间单位
TimeUnit timeUnit = repeatSubmit.timeUnit();
//使用 tryLock 尝试获取锁,如果无法获取(即锁已被其他请求持有),则认为是重复提交,直接返回null
RLock lock = redissonClient.getLock(key);
//锁自动过期时间为 lockTime 秒,确保即使程序异常也不会永久锁定资源,尝试加锁,最多等待0秒,上锁以后 lockTime 秒自动解锁 [lockTime默认为5s, 可以自定义]
res = lock.tryLock(0, lockTime, timeUnit);
} else {
//方式二,令牌形式防重提交
//从请求头中获取 request-token,如果不存在,则抛出异常
String requestToken = request.getHeader("request-token");
if (StringUtils.isBlank(requestToken)) {
throw new LimitException("请求未包含令牌");
}
//使用 request-token 和 serviceId 构造Redis的key,尝试从Redis中删除这个键。如果删除成功,说明是首次提交;否则认为是重复提交
String key = String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_TOKEN, repeatSubmit.serviceId(), requestToken);
res = redisRepository.del(key);
}
if (!res) {
log.error("请求重复提交");
throw new LimitException(repeatSubmit.msg());
}
return joinPoint.proceed();
}
private String generateTokenRedisKey(ProceedingJoinPoint joinPoint, String ipAddr, String preKey) {
//根据ip地址、用户id、类名方法名、生成唯一的key
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
String className = method.getDeclaringClass().getName();
String userId = "seven";
return String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_PARAM, preKey, DigestUtil.md5Hex(String.format("%s-%s-%s-%s", ipAddr, className, method, userId)));
}
}
- 调用示例
@PostMapping("/saveUser")
@RepeatSubmit(limitType = RepeatSubmit.Type.PARAM,lockTime = 5,timeUnit = TimeUnit.SECONDS,preKey = "saveUser",msg = "请求重复提交")
public Result saveUser() {
return Result.success("成功保存");
}
接口防抖
接口防抖是一种优化用户操作体验的技术,主要用于减少短时间内高频率触发的操作。例如,当用户快速点击按钮时,我们可以通过防抖机制,只处理最后一次触发的操作,而忽略前面短时间内的多次操作。防抖技术常用于输入框文本变化事件、按钮点击事件等场景,以提高系统的性能和用户体验。
后端接口防抖处理主要是为了避免在短时间内接收到大量相同的请求,特别是由于前端操作(如快速点击按钮)、网络重试或异常情况导致的重复请求。后端接口防抖通常涉及记录最近的请求信息,并在特定时间窗口内拒绝处理相同或相似的请求。
- 定义自定义注解
@AntiShake
// 该注解只能用于方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)// 运行时保留,这样才能在AOP中被检测到
public @interface AntiShake {
String preKey() default "";
// 默认防抖时间1秒
long value() default 1000L;
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}
- 实现
AOP
切面处理防抖
@Aspect // 标记为切面类
@Component // 让Spring管理这个Bean
@RequiredArgsConstructor // 通过构造方法注入依赖
public class AntiShakeAspect {
private final String ANTI_SHAKE_LOCK_KEY = "ANTI_SHAKE_LOCK_KEY";
private final RedissonClient redissonClient;
@Around("@annotation(antiShake)") // 拦截所有标记了@AntiShake的方法
public Object aroundAdvice(ProceedingJoinPoint joinPoint, AntiShake antiShake) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
long currentTime = System.currentTimeMillis();
// 获取方法签名和参数作为 Redis 键
String ipAddr = IPUtil.getIpAddr(request);
String key = generateTokenRedisKey(joinPoint, ipAddr, antiShake.preKey());
RBucket bucket = redissonClient.getBucket(key);
Long lastTime = bucket.get();
if (lastTime != null && currentTime - lastTime < antiShake.value()) {
// 如果距离上次调用时间小于指定的防抖时间,则直接返回,不执行方法
return null; // 根据业务需要返回特定值
}
// 更新 Redis 中的时间戳
bucket.set(currentTime, antiShake.value(), antiShake.timeUnit());
return joinPoint.proceed(); // 执行原方法
}
private String generateTokenRedisKey(ProceedingJoinPoint joinPoint, String ipAddr, String preKey) {
//根据ip地址、用户id、类名方法名、生成唯一的key
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
String className = method.getDeclaringClass().getName();
String userId = "seven";
return String.format("%s:%s:%s", ANTI_SHAKE_LOCK_KEY, preKey, DigestUtil.md5Hex(String.format("%s-%s-%s-%s", ipAddr, className, method, userId)));
}
}
- 调用示例代码
@PostMapping("/clickButton")
@AntiShake(value = 1000, timeUnit = TimeUnit.MILLISECONDS, preKey = "clickButton")
public Result clickButton() {
return Result.success("成功点击按钮");
}
接口防抖整体思路与防重复提交思路类似,防重复提交代码也可重用于接口防抖
关于作者
来自一线程序员Seven的探索与实践,持续学习迭代中~
本文已收录于我的个人博客:http://www.seven97.top
公众号:seven97,欢迎关注~
来源:juejin.cn/post/7408859165433364490
MySQL误删数据怎么办?
一、背景
某天,张三打算操作数据库,删除自己项目的无用数据,但是一不小心数据删多了。被误删的数据,如何恢复呢?本文将介绍相关方法,以及现成的一些工具。
例子:
有一个表
create table person
(
id bigint primary key auto_increment comment 'id',
name varchar(50) comment '名称'
) engine = innodb;
原本是要执行这条SQL语句:
delete from person where id > 500000;
不小心执行了这条SQL语句:
delete from person;
二、解决方案
处理这个问题的解决思路就是,基于binlog找回被删除的数据,将被删除的数据重新插入到数据库。
对于binlog文件来说,实际上保存的是对于数据库的正向操作。比如说,插入数据insert
,binlog中保存的也是insert
语句;删除数据delete
,binlog中保存的也是delete
语句。
因此,想要恢复被删除的数据,主要有两种方式:
描述 | 优点 | 缺点 |
---|---|---|
找到数据插入的位置,重新执行数据的插入操作 | 1. 比较方便,不需要生成逆向操作,直接执行sql脚本重新插入数据即可 2. 对binlog的模式没有限制,row模式、statement模式都能找到具体的数据 | 1. 如果数据插入之后还有更新操作,插入的数据不是最新的,会有问题 2. 如果被删除的数据比较多,插入的位置比较多,找到插入的位置比较困难 |
找到数据被删除的位置,生成逆向操作,重新执行插入操作 | 1. 只要找到数据被删除的位置即可找到所有被删除的数据,比较方便 | 1. 需要通过脚本生成逆向操作,才能将数据恢复 2. 需要保证binlog模式是row模式,才能找到被删除的数据。否则,statement模式不会找到具体的数据 |
下面就针对上面的两种方式,进行详细的讲解
1. 通用操作
首先介绍两种方式都需要使用到的一些通用的操作,主要用于设置binlog、找到binlog文件
1.1 确认binlog开启
1.1.1 查询开启状态
首先要保证binlog是开启的,不然数据肯定是没办法恢复回来的。
在MySQL中,可以通过执行以下SQL查询来检查是否已经开启了binlog:
SHOW VARIABLES LIKE 'log_bin';
这个查询将返回一个结果集,其中包含名为log_bin
的系统变量的值。如果log_bin
的值为ON
,则表示binlog已经开启;如果值为OFF
,则表示binlog没有开启。
mysql> SHOW VARIABLES LIKE 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin | ON |
+---------------+-------+
1 row in set (0.01 sec)
1.1.2 开启binlog
如果发现没有开启,可以通过修改MySQL配置文件(通常是my.cnf
或my.ini
,Linux下MySQL的配置文件目录一般是/etc/mysql
)中的[mysqld]
部分来开启binlog。如果在配置文件中找到了类似以下的设置,则表示binlog已经开启:
[mysqld]
log-bin=mysql-bin
server-id=1
- 修改配置启用了binlog之后,需要重启MySQL服务才能使更改生效
mysql-bin
表示binlog文件的前缀
- server-id 设置了MySQL服务器的唯一ID,必须设置ID,否则没办法开启binlog
1.2 binlog模式
刚刚提到,对于delete操作,只有row模式才能找到被删除数据的具体值,因此需要确认开启的binlog模式。
1.2.1 查询binlog模式
要查询MySQL的binlog模式,您可以使用以下SQL命令:
SHOW VARIABLES LIKE 'binlog_format';
这将返回一个结果集,其中包含当前的binlog格式。可能的值有:
ROW
:表示使用行模式(row-based replication),这是推荐的设置,因为它提供了更好的数据一致性。STATEMENT
:表示使用语句模式(statement-based replication),在这种模式下,可能会丢失一些数据,因为它仅记录执行的SQL语句。MIXED
:表示混合模式(mixed-based replication),在这种模式下,MySQL会根据需要自动切换行模式和语句模式。
1.2.2 配置binlog模式
可以通过修改MySQL配置文件(通常是my.cnf
或my.ini
,Linux下MySQL的配置文件目录一般是/etc/mysql
)中的[mysqld]
部分来修改binlog模式。
在[mysqld]
部分下,添加或修改以下行,将binlog_format
设置为想要的模式(ROW
、STATEMENT
或MIXED
):
[mysqld]
binlog_format=ROW
随后重启mysql服务使其生效
1.3 binlog信息查询
通过以下操作,我们可以找到binlog文件
1.3.1 查询当前使用的binlog文件
通过show master status;
可以找到当前正在使用的binlog文件
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000217
Position: 668127868
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 29dc2bf9-f657-11ee-b369-08c0eb829a3c:1-291852745,
744ca9cd-5f86-11ef-98d6-0c42a131d16f:1-5374311
1 row in set (0.00 sec)
1.3.2 找到所有binlog文件名
show master logs;
可以找到所有binlog文件名
mysql> show master logs;
+------------------+------------+
| Log_name | File_size |
+------------------+------------+
| mysql-bin.000200 | 1073818388 |
| mysql-bin.000201 | 1073757563 |
| mysql-bin.000202 | 1074635635 |
| mysql-bin.000203 | 1073801053 |
| mysql-bin.000204 | 1073856643 |
| mysql-bin.000205 | 1073910661 |
| mysql-bin.000206 | 1073742603 |
| mysql-bin.000207 | 1195256434 |
| mysql-bin.000208 | 1085915611 |
| mysql-bin.000209 | 1073990985 |
| mysql-bin.000210 | 1075942323 |
| mysql-bin.000211 | 1074716392 |
| mysql-bin.000212 | 1073763938 |
| mysql-bin.000213 | 1073780482 |
| mysql-bin.000214 | 1074029712 |
| mysql-bin.000215 | 1073832842 |
| mysql-bin.000216 | 1079999184 |
| mysql-bin.000217 | 668173793 |
+------------------+------------+
1.3.3 查询binlog保存位置
SHOW VARIABLES LIKE 'log_bin_basename';
可以找到binlog文件保存的目录位置。比如说/var/lib/mysql/mysql-bin
表示目录为/var/lib/mysql/
下的以mysql-bin
为前缀的文件。
我们通过文件的最后修改时间,可以看出binlog覆盖的时间范围。一般后缀的数字越大,表示越新。
mysql> SHOW VARIABLES LIKE 'log_bin_basename';
+------------------+--------------------------+
| Variable_name | Value |
+------------------+--------------------------+
| log_bin_basename | /var/lib/mysql/mysql-bin |
+------------------+--------------------------+
1 row in set (0.00 sec)
bash-4.2# ls /var/lib/mysql/mysql-bin* -alh
-rw-r----- 1 mysql mysql 1.1G Sep 9 02:28 /var/lib/mysql/mysql-bin.000200
-rw-r----- 1 mysql mysql 1.1G Sep 9 02:32 /var/lib/mysql/mysql-bin.000201
-rw-r----- 1 mysql mysql 1.1G Sep 9 02:39 /var/lib/mysql/mysql-bin.000202
-rw-r----- 1 mysql mysql 1.1G Sep 9 02:45 /var/lib/mysql/mysql-bin.000203
-rw-r----- 1 mysql mysql 1.1G Sep 9 07:52 /var/lib/mysql/mysql-bin.000204
-rw-r----- 1 mysql mysql 1.1G Sep 9 12:10 /var/lib/mysql/mysql-bin.000205
-rw-r----- 1 mysql mysql 1.1G Sep 10 04:40 /var/lib/mysql/mysql-bin.000206
-rw-r----- 1 mysql mysql 1.2G Sep 10 07:00 /var/lib/mysql/mysql-bin.000207
-rw-r----- 1 mysql mysql 1.1G Sep 11 07:54 /var/lib/mysql/mysql-bin.000208
-rw-r----- 1 mysql mysql 1.1G Sep 12 03:03 /var/lib/mysql/mysql-bin.000209
-rw-r--r-- 1 root root 24M Sep 11 09:06 /var/lib/mysql/mysql-bin.000209.event.log
-rw-r----- 1 mysql mysql 1.1G Sep 12 03:30 /var/lib/mysql/mysql-bin.000210
-rw-r----- 1 mysql mysql 1.1G Sep 12 08:33 /var/lib/mysql/mysql-bin.000211
-rw-r----- 1 mysql mysql 1.1G Sep 12 08:35 /var/lib/mysql/mysql-bin.000212
-rw-r----- 1 mysql mysql 1.1G Sep 12 22:00 /var/lib/mysql/mysql-bin.000213
-rw-r----- 1 mysql mysql 1.1G Sep 13 10:26 /var/lib/mysql/mysql-bin.000214
-rw-r----- 1 mysql mysql 1.1G Sep 13 10:29 /var/lib/mysql/mysql-bin.000215
-rw-r----- 1 mysql mysql 1.1G Sep 14 01:42 /var/lib/mysql/mysql-bin.000216
-rw-r----- 1 mysql mysql 637M Sep 14 06:11 /var/lib/mysql/mysql-bin.000217
-rw-r----- 1 mysql mysql 4.1K Sep 14 01:42 /var/lib/mysql/mysql-bin.index
2. 方案一:找到insert语句,重新插入
需要执行以下几个步骤:
- 确认
insert
插入数据的时间,找到对应的binlog文件 - 解析该binlog文件,指定时间点,在binlog文件中找到插入数据的位置
- 重新解析binlog文件,指定binlog位置。对解析出来的文件进行重放。
2.1 找到binlog文件
比如说,数据是在9月12日12:00插入的,那么我们看上方的所有binlog文件,可以看出插入语句应该保存在mysql-bin.000213文件中。
2.2 根据时间点解析binlog文件
通过mysqlbinlog
将binlog文件解析成可读的sql文件。
mysqlbinlog --base64-output=decode-rows -v --start-datetime="2024-09-12 11:59:00" --stop-datetime="2024-09-12 12:01:00" mysql-bin.000213 > binlog.sql
--base64-output=decode-rows
:将二进制日志中的行事件解码为 SQL 语句。
-v
或--verbose
:输出详细的事件信息。
--start-datetime="2024-09-12 11:59:00"
:从指定的日期和时间开始读取二进制日志。通过指定时间范围,可以减小解析出来的sql文件,避免太多无用信息使得查询位置比较困难。
--stop-datetime="2024-09-12 12:01:00"
:在指定的日期和时间停止读取二进制日志。
mysql-bin.000213
:要解析的二进制日志文件的路径和名称。
>
:将命令的输出重定向到指定的文件。
binlog.sql
:保存解码后的 SQL 语句的文件名。
2.2.1 statement模式确认binlog位置
我们可以找到insert int0 person values (1, 'first')
,并且分别在前后的BEGIN
和COMMIT
找到position。
BEGIN
往前找有一个position at 219
,COMMIT
往后找有一个position at 445
,这就是插入语句的实际binlog范围。
# at 219
#240914 17:14:26 server id 1 end_log_pos 300 CRC32 0xb8159bc1 Query thread_id=1267 exec_time=0 error_code=0
SET TIMESTAMP=1726305266/*!*/;
SET @@session.pseudo_thread_id=1267/*!*/;
SET @@session.foreign_key_checks=1, @@session.sql_auto_is_null=0, @@session.unique_checks=1, @@session.autocommit=1/*!*/;
SET @@session.sql_mode=1436549120/*!*/;
SET @@session.auto_increment_increment=1, @@session.auto_increment_offset=1/*!*/;
/*!\C latin1 *//*!*/;
SET @@session.character_set_client=8,@@session.collation_connection=8,@@session.collation_server=8/*!*/;
SET @@session.lc_time_names=0/*!*/;
SET @@session.collation_database=DEFAULT/*!*/;
BEGIN
/*!*/;
# at 300
#240914 17:14:26 server id 1 end_log_pos 414 CRC32 0xb7e0263b Query thread_id=1267 exec_time=0 error_code=0
use `tests`/*!*/;
SET TIMESTAMP=1726305266/*!*/;
insert int0 person values (1, 'first')
/*!*/;
# at 414
#240914 17:14:26 server id 1 end_log_pos 445 CRC32 0x9345e6ca Xid = 30535
COMMIT/*!*/;
# at 445
2.2.2 row模式确认binlog位置
row模式下,与statement模式下的binlog格式有少许差别,但方法是一致的。
我们可以找到以 ###
开头的几行,包含INSERT INTO
语句。并且分别在前后的BEGIN
和COMMIT
找到position。
BEGIN
往前找有一个position at 219
,COMMIT
往后找有一个position at 426
,这就是插入语句的实际binlog范围。
# at 219
#240914 17:16:36 server id 1 end_log_pos 292 CRC32 0xe9082d52 Query thread_id=20 exec_time=0 error_code=0
SET TIMESTAMP=1726305396/*!*/;
SET @@session.pseudo_thread_id=20/*!*/;
SET @@session.foreign_key_checks=1, @@session.sql_auto_is_null=0, @@session.unique_checks=1, @@session.autocommit=1/*!*/;
SET @@session.sql_mode=1436549120/*!*/;
SET @@session.auto_increment_increment=1, @@session.auto_increment_offset=1/*!*/;
/*!\C latin1 *//*!*/;
SET @@session.character_set_client=8,@@session.collation_connection=8,@@session.collation_server=8/*!*/;
SET @@session.lc_time_names=0/*!*/;
SET @@session.collation_database=DEFAULT/*!*/;
BEGIN
/*!*/;
# at 292
#240914 17:16:36 server id 1 end_log_pos 345 CRC32 0x1832ced4 Table_map: `tests`.`person` mapped to number 111
# at 345
#240914 17:16:36 server id 1 end_log_pos 395 CRC32 0x32d6a21b Write_rows: table id 111 flags: STMT_END_F
### INSERT INTO `tests`.`person`
### SET
### @1=1
### @2='first'
# at 395
#240914 17:16:36 server id 1 end_log_pos 426 CRC32 0x07619928 Xid = 149
COMMIT/*!*/;
# at 426
2.3 根据binlog位置解析binlog文件
上一步,我们已经找到了具体的位置,现在我们可以重新解析binlog文件,指定binlog位置。内容和上方实际上没有太大差异。
mysqlbinlog --start-position=219 --stop-position=426 mysql-bin.000213 > binlog.sql
需要注意的是,这个解析语句,删掉了--base64-output=decode-rows -v 参数。因为这些参数是用于解码binlog的,是让开发人员更方便看到binlog被解析之后的格式。但是对mysql来说是没办法使用的。
2.4 重放数据
解析的这个文件就是一个sql脚本文件,通过往常的方式执行sql脚本即可
mysql -uroot -proot < binlog.sql
或者mysql客户端登陆之后,通过source命令执行
source binlog.sql;
3. 方案二:找到delete语句,生成逆向操作,重新insert
3.1 找到binlog文件
比如说,数据是在9月12日12:00删除的,那么我们看上方的所有binlog文件,可以看出插入语句应该保存在mysql-bin.000213文件中。
3.2 根据时间点解析binlog文件
操作和上方2.2的操作没有差异,我们主要来比较一下statement模式和row模式的差别。我们会发现statement模式下,没办法找到所有被删除的数据的具体数据,而row模式能找到。
3.2.1 statement模式
我们可以看到binlog只保存了一句 delete from person
。很遗憾,啥数据都没有,也没办法根据它生成逆向操作。
# at 445
#240914 17:15:13 server id 1 end_log_pos 510 CRC32 0x6a7a66e4 Anonymous_GTID last_committed=1 sequence_number=2 rbr_only=no
SET @@SESSION.GTID_NEXT= 'ANONYMOUS'/*!*/;
# at 510
#240914 17:15:13 server id 1 end_log_pos 591 CRC32 0x55e4225b Query thread_id=1267 exec_time=0 error_code=0
SET TIMESTAMP=1726305313/*!*/;
BEGIN
/*!*/;
# at 591
#240914 17:15:13 server id 1 end_log_pos 685 CRC32 0x10938b9d Query thread_id=1267 exec_time=0 error_code=0
SET TIMESTAMP=1726305313/*!*/;
delete from person
/*!*/;
# at 685
#240914 17:15:13 server id 1 end_log_pos 716 CRC32 0x1ea4a681 Xid = 30610
COMMIT/*!*/;
# at 716
3.2.2 row模式
可以看到binlog以 ###
开头的几行,WHERE
之后,把被删除数据的每一个字段都作为条件嵌入到sql语句中了。条件的顺序,就是表结构的字段顺序。比如说@1
对应的就是id
,@2
对应的就是name
。
# at 1574
#240914 17:16:38 server id 1 end_log_pos 1642 CRC32 0x944b1b94 Query thread_id=20 exec_time=1260 error_code=0
SET TIMESTAMP=1726305398/*!*/;
BEGIN
/*!*/;
# at 1642
#240914 17:16:38 server id 1 end_log_pos 1695 CRC32 0x435282e2 Table_map: `tests`.`person` mapped to number 111
# at 1695
#240914 17:16:38 server id 1 end_log_pos 1745 CRC32 0x3063bf8c Delete_rows: table id 111 flags: STMT_END_F
### DELETE FROM `tests`.`person`
### WHERE
### @1=1
### @2='first'
# at 1745
#240914 17:16:38 server id 1 end_log_pos 1776 CRC32 0x086c2270 Xid = 3391
COMMIT/*!*/;
3.3 生成逆向操作
根据上面的binlog文件,我们可以通过脚本生成逆向操作。
insert int0 person values (1, 'first');
3.4 重放数据
与 2.4 一致
三、常见工具
目前有一些开源的工具,可以帮助我们解析binlog,并且自动生成binlog记录的操作的逆向操作。
1. binlog2mysql
binlog2sql由美团点评DBA团队(上海)出品,python脚本实现。主要原理是伪装成slave,向master获取binlog,并且根据binlog生成逆向操作。
下载地址:GitHub - danfengcao/binlog2sql: Parse MySQL binlog to SQL you want
在执行之前,需要确认mysql server已设置以下参数:
[mysqld]
server_id = 1
log_bin = /var/log/mysql/mysql-bin.log
max_binlog_size = 1G
binlog_format = row
binlog_row_image = full
获取正向操作:
> python binlog2sql.py -h127.0.0.1 -P13306 -uroot -p --start-file=mysql-bin.000002
Password:
INSERT INTO `tests`.`person`(`id`, `name`) VALUES (1, 'first'); #start 4 end 395 time 2024-09-14 17:16:36
DELETE FROM `tests`.`person` WHERE `id`=1 AND `name`='first' LIMIT 1; #start 426 end 667 time 2024-09-14 17:16:38
- 通过命令,输入用户名、密码、端口号、地址等,并且指定binlog文件
- 通过输出,可以看出所有正向操作,以及每个正向操作的时间、binlog位置
获取逆向操作:
> python binlog2sql.py -h127.0.0.1 -P13306 -uroot -p --start-file=mysql-bin.000002 --flashback
Password:
INSERT INTO `tests`.`person`(`id`, `name`) VALUES (1, 'first'); #start 426 end 667 time 2024-09-14 17:16:38
DELETE FROM `tests`.`person` WHERE `id`=1 AND `name`='first' LIMIT 1; #start 4 end 395 time 2024-09-14 17:16:36
- 命令中,新增一个参数 --flashback,用于指定回滚
- 通过输出,可以看出所有逆向操作。并且可以看出相对于正向操作来说,逆向操作的顺序是相反的,按时间从后往前排序
还有其他工具,比如说MyFlash等,大家可以自行研究
MyFlash:GitHub - Meituan-Dianping/MyFlash: flashback mysql data to any point
四、总结
- 我们可以通过binlog找回误删的数据,前提是开启了binlog。建议binlog模式为row模式,否则没办法根据正向操作生成逆向操作。
- 有一些开源工具可以自动解析binlog,并且生成逆向操作。
来源:juejin.cn/post/7416737238614589503
Spring Boot3,启动时间缩短 10 倍!
前面松哥写了一篇文章和大家聊了 Spring6 中引入的新玩意 AOT(见Spring Boot3 新玩法,AOT 优化!)。
文章发出来之后,有小伙伴问松哥有没有做性能比较,老实说,这个给落下了,所以今天再来一篇文章,和小伙伴们梳理比较小当我们利用 Native Image 的时候,Spring Boot 启动性能从参数上来说,到底提升了多少。
先告诉大家结论:启动速度提升 10 倍以上。
1. Native Image
1.1 GraalVM
不知道小伙伴们有没有注意到,现在当我们新建一个 Spring Boot 工程的时候,再添加依赖的时候有一个 GraalVM Native Support
,这个就是指提供了 GraalVM 的支持。
那么什么是 GraalVM 呢?
GraalVM 是一种高性能的通用虚拟机,它为 Java 应用提供 AOT 编译和二进制打包能力,基于 GraalVM 打出的二进制包可以实现快速启动、具有超高性能、无需预热时间、同时需要非常少的资源消耗,所以你把 GraalVM 当作 JVM 来用,是没有问题的。
在运行上,GraalVM 同时支持 JIT 和 AOT 两种模式:
- JIT 是即时编译(Just-In-Time Compilation)的缩写。它是一种在程序运行时将代码动态编译成机器码的技术。与传统的静态编译(Ahead-of-Time Compilation)不同,静态编译是在程序执行之前将代码编译成机器码,而 JIT 编译器在程序运行时根据需要将代码片段编译成机器码,然后再运行。所以 JIT 的启动会比较慢,因为编译需要占用运行时资源。我们平时使用 Oracle 提供的 Hotspot JVM 就属于这种。
- AOT 是预先编译(Ahead-of-Time Compilation)的缩写。它是一种在程序执行之前将代码静态编译成机器码的技术。与即时编译(JIT)不同,即时编译是在程序运行时动态地将代码编译成机器码。AOT 编译器在程序构建或安装阶段将代码转换为机器码,然后在运行时直接执行机器码,而无需再进行编译过程。这种静态编译的方式可以提高程序的启动速度和执行效率,但也会增加构建和安装的时间和复杂性。AOT 编译器通常用于静态语言的编译过程,如 C、C++ 等。
如果我们在 Java 应用程序中使用了 AOT 技术,那么我们的 Java 项目就会被直接编译为机器码可以脱离 JVM 运行,运行效率也会得到很大的提升。
那么什么又是 Native Image 呢?
1.2 Native Image
Native Image 则是 GraalVM 提供的一个非常具有特色的打包技术,这种打包方式可以将应用程序打包为一个可脱离 JVM 在本地操作系统上独立运行的二进制包,这样就省去了 JVM 加载和字节码运行期预热的时间,提升了程序的运行效率。
Native Image 具备以下特点:
- 即时启动:由于不需要 JVM 启动和类加载过程,Native Image 可以实现快速启动和即时执行。
- 减少内存占用:编译成本地代码后,应用程序通常会有更低的运行时内存占用,因为它们不需要 JVM 的额外内存开销。
- 静态分析:在构建 Native Image 时,GraalVM 使用静态分析来确定应用程序的哪些部分是必需的,并且只包含这些部分,这有助于减小最终可执行文件的大小。
- 即时性能:虽然 JVM 可以通过JIT(Just-In-Time)编译在运行时优化代码,但 Native Image 提供了即时的、预先优化的性能,这对于需要快速响应的应用程序特别有用。
- 跨平台兼容性:Native Image 可以为不同的操作系统构建特定的可执行文件,包括 Linux、macOS 和 Windows,即在 Mac 和 Linux 上自动生成系统可以执行的二进制文件,在 Windows 上则自动生成 exe 文件。
- 安全性:由于 Native Image 不依赖于 JVM,因此减少了 JVM 可能存在的安全漏洞的攻击面。
- 与 C 语言互操作:Native Image 可以与本地 C 语言库更容易地集成,因为它们都是在同一环境中运行的本地代码。
根据前面的介绍大家也能看到,GraalVM 所做的事情就是在程序运行之前,该编译的就编译好,这样当程序跑起来的时候,运行效率就会高,而这一切,就是利用 AOT 来实现的。
但是!对于一些涉及到动态访问的东西,GraalVM 似乎就有点力不从心了,原因很简单,GraalVM 在编译构建期间,会以 main 函数为入口,对我们的代码进行静态分析,静态分析的时候,一些无法触达的代码会被移除,而一些动态调用行为,例如反射、动态代理、动态属性、序列化、类延迟加载等,这些都需要程序真正跑起来才知道结果,这些就无法在编译构建期间被识别出来。
而反射、动态代理、序列化等恰恰是我们 Java 日常开发中最最重要的东西,不可能我们为了 Native Image 舍弃这些东西!因此,从 Spring6(Spring Boot3)开始支持 AOT Processing!AOT Processing 用来完成自动化的 Metadata 采集,这个采集主要就是解决反射、动态代理、动态属性、条件注解动态计算等问题,在编译构建期间自动采集相关的元数据信息并生成配置文件,然后将 Metadata 提供给 AOT 编译器使用。
道理搞明白之后,接下来通过一个案例来感受下 Native Image 的威力吧!
2. 准备工作
首先需要我们安装 GraalVM。
GraalVM 下载地址:
下载下来之后就是一个压缩文件,解压,然后配置一下环境变量就可以了,这个默认大家都会,我就不多说了。
GraalVM 配置好之后,还需要安装 Native Image 工具,命令如下:
gu install native-image
装好之后,可以通过如下命令检查安装结果:
另一方面,Native Image 在进行打包的时候,会用到一些 C/C++ 相关的工具,所以还需要在电脑上安装 Visual Studio 2022,这个我们安装社区版就行了(visualstudio.microsoft.com/zh-hans/dow…
下载后双击安装就行了,安装的时候选择 C++ 桌面应用开发。
如此之后,准备工作就算完成了。
3. 实践
接下来我们创建一个 Spring Boot 工程,并且引入如下两个依赖:
然后我们开发一个接口:
@RestController
public class HelloController {
@Autowired
HelloService helloService;
@GetMapping("/hello")
public String hello() {
return helloService.sayHello();
}
}
@Service
public class HelloService {
public String sayHello() {
return "hello aot";
}
}
这是一个很简单的接口,接下来我们分别打包成传统的 jar 和 Native Image。
传统 jar 包就不用我多说了,大家执行 mvn package 即可:
mvn package
打包完成之后,我们看下耗时时间:
耗时不算很久,差不多 3.7s 左右,算是比较快了,最终打成的 jar 包大小是 18.9MB。
再来看打成原生包,执行如下命令:
mvn clean native:compile -Pnative
这个打包时间就比较久了,需要耐心等待一会:
可以看到,总共耗时 4 分 54 秒。
Native Image 打包的时候,如果我们是在 Windows 上,会自动打包成 exe 文件,如果是 Mac/Linux,则生成对应系统的可执行文件。
这里生成的 aot_demo.exe 文件大小是 82MB。
两种不同的打包方式,所耗费的时间完全不在一个量级。
再来看启动时间。
先看 jar 包启动时间:
耗时约 1.326s。
再来看 exe 文件的启动时间:
好家伙,只有 0.079s。
1.326/0.079=16.78
启动效率提升了 16.78 倍!
我画个表格对比一下这两种打包方式:
jar | Native Image | |
---|---|---|
包大小 | 18.9MB | 82MB |
编译时间 | 3.7s | 4分54s |
启动时间 | 1.326s | 0.079s |
从这张表格中我们可以看到,Native Image 在打包的时候比较费时间,但是一旦打包成功,项目运行效率是非常高的。Native Image 很好的解决了 Java 冷启动耗时长、Java 应用需要预热等问题。
最后大家可以自行查看打包成 Native Image 时候的编译结果,如下图:
看过松哥之前将的 Spring 源码分析的小伙伴,这块的代码应该都很好明白,这就是直接把 BeanDefinition 给解析出来了,不仅注册了当前 Bean,也把当前 Bean 所需要的依赖给注入了,将来 Spring 执行的时候就不用再去解析 BeanDefinition 了。
同时我们可以看到在 META-INF 中生成了 reflect、resource 等配置文件。这些是我们添加的 native-maven-plugin 插件所分析出来的反射以及资源等信息,也是 Spring AOT Processing 这个环节处理的结果。
来源:juejin.cn/post/7330071686489817128
CMS垃圾回收器的工作原理是什么?为什么它会被官方废弃?
你好,我是猿java。
1. 网上关于 CMS的文章很多,为什么要重复造车轮?
答:网上很多关于 CMS收集器的文章写得不够具体,有的甚至一知半解,更多的是不假思索的转载,想通过自己对 CMS的理解以及大量资料的佐证,提供更具体形象正确的分析。
2. CMS已经被弃用,为什么还要分析它?
答:首先,CMS收集器依然是面试中的一个高频问题;
其次,CMS作为垃圾收集器的一个里程碑,作为 Java程序员,不了解原理,于情于理说不过去;
3. JVM已经把垃圾回收自动化了,为什么还要讲解 CMS?
答:排查生产环境的各种内存溢出,内存泄漏,垃圾回收导致性能瓶颈等技术问题,如果不懂原理,如何排查和优化?
温馨提示:如果没有特殊说明,本文提及的虚拟机默认为 HotSpot虚拟机。
背景
首先,了解下 HotSpot虚拟机中 9款垃圾回收器的发布时间及其对应的 JDK版本,如下图:
接着,了解下 CMS垃圾回收器的生命线:
- 2002年9月,JDK 1.4.1 版本,CMS实验性引入;
- 2003年6月,JDK 1.4.2 版本,CMS正式投入使用;
- 2017年9月,JDK 9 版本,CMS被标记弃用;
- 2020年3月,JDK 14 版本,CMS从 JDK中移除;
效力 18年,一代花季回收器,从此退出历史舞台;
什么是垃圾
既然分析的是垃圾回收器,那么,我们首先需要知道:在 JVM 中,什么是“垃圾”?
这里的“垃圾”用了双引号,是因为它和我们生活中理解的垃圾不一样。在 JVM中,垃圾(Garbage)是指那些不再被应用程序使用的对象,也就是说这些对象不再可达,即对象已死。
如何判断对象不可达(已死)?
在 JVM中,通过一种可达性分析(Reachability Analysis)算法来判断对象是否可达。 该算法的基本思路是:通过 GC Roots 集合里的根对象作为起始点,一直追踪所有存在引用关系的对象(这条引用关系链路叫做引用链 Reference Chain), 如果某对象到 GC Roots之间没有引用链,那么该对象就是不可达。 如下图,obj4, obj5,obj6 尽管相互直接关联,但是没有 GC Root连接,所以是不可达,同理 obj7也不可达:
关于可达性分析,还有一种方法是引用技术算法,该方法的思路是:在对象中添加一个计数器,增加一次引用计数器 +1,减少一次引用计数器 -1,当计数器始终为 0时代表不被使用,这种方法一般是用于 Python的CPython 和微软的COM(Component Object Model)等技术中,JVM中使用的是可达性分析算法,这点需要特别注意。
哪些对象可以作为 GC Roots?
GC Roots 是 GC Root的集合,本质上是一组必须活跃的对象引用,主要包含以下几种类型:
虚拟机栈中的引用对象:每个线程的虚拟机栈中的局部变量表中的引用。这些引用可能是方法的参数、局部变量或临时状态。
方法区中的类静态属性引用对象:所有加载的类的静态字段。静态属性是类级别的,因此它们在整个Java虚拟机中是全局可访问的。
方法区中的常量引用对象:方法区中的常量池(例如字符串常量池)中的引用。
本地方法栈中的JNI引用:由 Java本地接口(JNI)代码创建的引用,例如,Java代码调用了本地 C/C++库。
活跃的 Java线程:每个执行中的Java线程本身也是一个GC Root。
同步锁(synchronized block)持有的对象:被线程同步持有的对象。
Java虚拟机内部的引用:比如基本数据类型对应的Class对象,一些常见的异常对象(如NullPointerException、OutOfMemoryError)的实例,系统类加载器。
反射引用的对象:通过反射API持有的对象。
临时状态:例如,从Java代码到本地代码的调用。
这里举个简单的例子来解释 GC Root 以及 GC Root可达对象,如下代码:
public class RootGcExample {
private static Object sObj = new Object(); // 静态字段 sObj是 Gc Root
private static void staticMethod() {
Object mObj = new Object(); // 方法局部变量 mObj是 Gc Root
// ...
}
public static void main(String[] args) {
Object obj = new Object(); // 局部变量obj 是 Gc Root
staticMethod();
}
}
上述例子中,sObj 是一个静态变量引用,指向了一个 Object对象,因此,sObj是一个 Gc Root, 在staticMethod静态方法中,mObj 是一个方法局部变量,它也是一个 Gc Root, 在 main方法中,obj也是一个Gc Root。堆中的 Object对象就是 GC Root可达对象,上述关系可以描绘成下图:
回收哪里的垃圾?
从 CMS 简介可以知道 CMS是用于老年代的垃圾回收,但是对于这种抽象的文字描述,很多小伙伴肯定还是没有体感, 因此,我们把视角放眼到整个 JVM运行时的内存结构上,从整体上看看垃圾回收器到底回收的是哪些区域的垃圾, CMS 又是回收哪里的垃圾,如下图:
垃圾在哪里?
在了解了“垃圾”在 JVM中是如何定义之后,我们不禁会问到:这些“垃圾”存放在哪里呢?
在回答这个问题之前,我们先来了解 JVM的内存结构,根据 Java虚拟机规范,JVM内存包含以下几个运行时区域,如下图:
为了更好地理解 JVM内存结构,这里对各个区域做一个详细的介绍:
- 堆空间(Heap):它是 JVM内存中最大的一块线程共享的区域,用于存放 Java应用创建的对象实例和数组。堆空间进一步细分为几个区域:
- 年轻代:Young Generation,大部分的对象都是在这里创建。年轻代又分为一个 Eden区和两个 Survivor区(S0和S1)。这里的大部分对象生命周期比较短,会被垃圾回收器快速回收。
- 老年代:Old Generation 或 Tenured Generation,在年轻代中经过多次垃圾回收仍然存活的对象会被移动到老年代,或者一些大对象会直接被分配到老年代,这里的对象一般存活时间较长,垃圾回收频率较低。
- 永久代:Permanent Generation,PermGen,Java 8之前版本的叫法,用于存放类信息、方法信息、常量等。在 Java 8及之后的版本,永久代被元空间(Metaspace)所替代。
- 元空间:Metaspace,Java 8及之后版本的叫法,用于存放类的元数据信息,它使用本地物理内存,不在 JVM堆内。
- 方法区(Method Area):方法区是堆的一个逻辑区域,它是线程共享的,用于存储已被 JVM加载的类结构信息,常量、静态变量、即时编译后的代码缓存等数据。为了和堆区分开来,它也被叫做“非堆(Non-Heap)”。这个区域的回收对象主要是常量池和类型的卸载,而且回收的效果比较差。
关于方法区有一个误区:JDK 8以前,HotSpot虚拟机为了像堆一样管理方法区的垃圾回收,就使用永久代来实现方法区,因此有人就把方法区直接叫做永久代,而其它虚拟机不存在永久代的概念,因此,方法区如何实现属于虚拟机内部的机制,不是 JVM统一规范。另外,HotSpot发现永久代实现方法区这种做法会导致内存溢出,因此从 JDK8开始,把永久代彻底废除,改用和 JRockit一样的元空间。方法区也改用本地内存实现。
- 程序计数器(Program Counter Register):这是一个较小的线程私有内存空间,用于存储当前线程执行的字节码的行号指示器。每个线程都有自己的程序计数器,但这部分内存通常不涉及垃圾回收。
- 虚拟机栈(Java Virtual Machine Stack):每个 Java方法执行时都会创建一个线程私有的栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口信息等。虚拟机栈在方法执行完毕后会自动清理,因此也不是垃圾回收的重点。
- 本地方法栈(Native Method Stack):用于支持本地方法的执行(即通过JNI调用的非Java代码),它是线程私有的。本地方法栈也会在方法执行完毕后自动清理。
通过上述 JVM内存区域的介绍,我们可以发现 JVM各个内存区域都可能产生垃圾,只是程序计算器,本地方法区,虚拟机栈 3个区域随线程而生,随线程而亡,垃圾被自动回收,方法区回收效果比较差,而堆中的“垃圾”才是回收器关注的重点,因此,垃圾收集器重点关注的是 JVM的堆,而 CMS回收的是堆中的老年代,如下图:
到这里为止,我们已经从 JVM内存结构视角上掌握了垃圾收集器回收的区域以及 CMS 负责的区域。
接下来,分析一下 GC回收常用的几个重要技术点:三色标记法(Tricolor Marking),卡表(Card Table),写屏障(Write Barrier),理解它们可以帮助我们更好地去理解 GC回收的原理。
几个重要技术点
三色标记法
在垃圾收集器中,主要采用三色标记算法来标记对象的可达性:
- 白色:表示对象尚未被访问。初始状态时,所有的对象都被标记为白色。
- 灰色:表示对象已经被标记为存活,但其引用的对象还没有全部被扫描。灰色对象可能会引用白色对象。
- 黑色:表示对象已经被标记为存活,并且该对象的所有引用都已经被扫描过。黑色对象不会引用任何白色对象。
三色标记算法的工作流程大致如下:
- 初始化时,所有对象都标记为白色。
- 将所有的 GC Roots 对象标记为灰色,并放入灰色集合。
- 从集合中选择一个灰色对象,将其标记为黑色,并将其引用的所有白色对象标记为灰色,然后放入灰色集合。
- 重复步骤3,直到灰色集合为空。
- 最后,所有黑色对象都是活跃的,白色对象都是垃圾。
卡表
对于分代垃圾回收器,势必存在一个跨代引用的问题,通常会使用一种名为记忆集(Remembered Set)的数据结构,它是一种用于记录从非收集区指向收集区的指针集合的数据结构。
而卡表就是最常用的一种记忆集,它是一个字节数组,用于记录堆内存的映射关系,下面是 HotSpot虚拟机默认的卡表标记逻辑:
// >> 9 代表右移 9位,即 2^9 = 512 字节
CARD_TABLE[this address >> 9] = 0;
每个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块叫做“卡页(Card Page)”。因为卡页代表的是一个区域,所以可能存在很多对象,只要有一个对象存在跨代引用,就把数组的值设为1,称该元素“变脏(Dirty)”,该卡页叫“脏页(Dirty Page)”,如下:
// >> 9 代表右移 9位,即2^9=512
CARD_TABLE[this address >> 9] = 1;
当垃圾回收时,只要筛选卡表中有变脏的元素,即数组值为 1,就能判断出其对应的内存区域存在对象跨代引用,卡表和卡页的关系如下图:
写屏障
在 HotSpot虚拟机中,写屏障本质上是引用字段被赋值这个事件的一个环绕切面(Around AOP),即一个引用字段被赋值的前后可以为程序提供额外的动作(比如更新卡表),写屏障分为:前置写屏障(Pre-Write-Barrier)和后置写屏障(Post-Write-Barrier)2种类型。
需要注意的是:这里的写屏障和多线程并发中的内存屏障不是一个概念。
分析完几个重要的技术点之后,接下来,我们正式分析 CMS回收器。
CMS 简介
CMS 是 Concurrent Mark Sweep 的简称,中文翻译为并发标记清除,它的目标是减少垃圾回收时应用线程的停顿时间,并且实现应用线程和 GC线程并发执行。
CMS 用于老年代的垃圾回收,使用的是标记-清除算法。通过 -XX:+UseConMarkSweepGC 参数即可启动 CMS回收器。
在 CMS之前的 4款回收器(Serial,Serial Old,ParNew,Parallel Scavenge) ,应用线程和 GC线程无法并发执行,必须 Stop The World(将应用线程全部挂起), 并且它们关注的是可控的吞吐量,而 CMS回收器,应用线程和 GC线程可以并发执行,目标是缩短回收时应用线程的停顿时间,这是 CMS和其它 4款回收器本质上的区别,也是它作为里程碑的一个标志。
CMS 回收过程
从整体上看,CMS 垃圾回收主要包含 5个步骤(网上很多 4,6,7个步骤的版本,其实都大差不差,没有本质上的差异):
- Initial Mark(初始标记):会Stop The World
- Concurrent Marking(并发标记)
- Remark(重复标记):会Stop The World
- Concurrent Sweep(并发清除)
- Resetting(重置)
整个过程可以抽象成下图:
在讲解回收过程之前,先分析三色标记法,这样可以帮助我们更好地去理解 GC的原理。
1. 初始标记
初始标记阶段会 Stop The World(STW),即所有的应用线程(也叫 mutator线程)被挂起。
该阶段主要任务是:枚举出 GC Roots以及标识出 GC Roots直接关联的存活对象,包括那些可能从年轻代可达的对象。
那么,GC Roots是如何被枚举的?GC Roots的直接关联对象是什么?为什么需要 STW?
GC Roots是如何被枚举的?
通过上文对 GC Roots的描述可知,作为 GC Roots的对象类型有很多种,遍及 JVM中的多个区域,对于现如今这种大内存的 VM,如果需要临时去扫描各区域来获取 GC Roots,那将是很大的一个工程量,因此,JVM采用了一种名为 OopMap(Object-Oriented Programming Map)的数据结构,它用于在垃圾收集期间快速地定位和更新堆中的对象引用(OOP,Object-Oriented Pointer)。
OopMap是在 JVM在编译期间生成的,主要作用是提供一个映射,通过这个映射垃圾收集器可以知道在特定的程序执行点(如safepoint)哪些位置(比如在栈或寄存器中)存放着指向堆中对象的引用,这样就可以快速定位 GC Roots。
使用OopMap的优点包括:
- 提高效率:OopMap使得垃圾收集器能够快速准确地找到和更新所有的对象引用,从而减少垃圾收集的时间。
- 减少错误:手动管理对象引用的位置容易出错,OopMap提供了一种自动化的方式来追踪这些信息。
- 便于优化:由于 OopMap是在编译时生成的,编译器可以进行优化,比如减少需要记录的引用数量,从而减少垃圾收集的开销。
在 HotSpot虚拟机中,OopMap是实现精确垃圾收集的关键组件之一。
什么是 GC Roots直接关联的对象?
所谓直接关联对象就是 GC Root直接引用的对象,下面以一个示例来说明,如下代码:
public class AssociatedObjectExample {
public static void main(String[] args) {
Associated obj = new Associated(); // Associated 是 GC Root obj 直接关联
((Associated) obj).bObj = new BigObject(); // BigObject是 GC Root obj 的间接关联的对象,BigObject是一个大对象,直接分配到老年代
}
static class Associated {
BigObject bObj; // 与Associated对象直接关联的对象
}
static class BigObject {
// 其它代码
}
}
上述例子中,obj是一个 GC Root,Associated对象就是它的直接关联对象,bObj是一个 GC Root,BigObject对象是它的直接关联对象,obj可以通过 Associated对象间接关联 到 BigObject对象,但 BigObject对象不是 obj的直接关联对象,而是间接关联对象。 整个关联关系可以描绘成下图:
为什么需要 STW?
为什么初始标记阶段需要 Stop The World?这里主要归纳成两个原因:
- 确定 Roots集合:初始标记阶段的主要任务是识别出所有的 GC Roots,这是后续并发标记阶段的起点。 在多线程运行的环境中,如果应用线程和垃圾回收线程同时运行,应用线程可能会改变对象引用关系,导致 Roots集合不准确。 因此,需要暂停应用线程,以确保 GC Roots的准确性和一致性。
- 避免并发问题:在初始标记阶段,垃圾回收器需要更新一些共享的数据结构,例如标记位图或者引用队列。 如果应用线程在此时运行,可能会引入并发修改的问题,导致数据不一致。STW可以避免这种情况的发生。
2.并发标记**
这里的并发是指应用线程和 GC线程可以并发执行。
在并发标记阶段主要完成 2个事情:
- 遍历对象图,标记从 GC Roots可以追踪到所有可达的存活对象;
- 处理并发修改
因为应用线程仍在继续工作,因此老年代的对象可能会发生以下几种变化:
- 新生代的对象晋升到老年代;
- 直接在老年代分配对象;
- 老年代对象的引用关系发生变更;
为了防止这些并发修改被遗漏,CMS 使用了后置写屏障(Write Barrier)机制,确保这些更改会被记录在“卡表(Card Table)”中,同时将相应的卡表条目标记为脏(dirty),以便后续处理。
如下图:从 GC Roots追溯哦所有可达对象,并将它们修改为已标记,即黑色。
当老年代中,D 到 E到引用被修改时,就会触发写屏障机制,最终 E就会被写进脏页,如下图:
并发标记会出现对象可达性误判问题,如下图:假如对象 D对象被标记成黑色,E对象被标记为灰色(图左半部分),这时,工作线程将 E对象修改成不再指向F,并将 D对象指向 F对象(图右半部分),按照三色标记算法,D对象为黑色,不会再往下追溯,所以 F对象就无法被标记从而变成垃圾,“存活”对象凭空消失了,这是很可怕的问题,那么 CMS是如何解决这种问题的呢?
解决这种问题,通常有两种方案:
- 增量更新(Incremental Update)
当新增黑色对象指向白色对象关系时(D->F),需要记录这次新增,等并发扫描结束后,将这些黑色的对象作为 GC Root,重新扫描一次,也就是把这些黑色对象看成灰色对象,它们指向的白色对象就可以被正常标记。CMS采取的就是这种方式。
- 原始快照(Snapshot At The Beginning,SATB)
当删除灰色对象指向白色对象关系时(E->F),需要记录这次删除,等并发扫描结束后,将这些灰色的对象作为 GC Root,按照删除 E对象指向 F对象前一刻的快照(也就是E->F 还是可达的)重新扫描一次,即不管关系删除与否,都会按照删除前那一刻快照的对象图来进行搜索标记。G1,Shenandoah采取的是这种方式。
3.重新标记
重复标记阶段也会 Stop The World,即挂起所有的应用程序线程,该阶段主要完成事情是:
- 并发预清理:在重新标记阶段之前,CMS可能会执行一个可选的并发预清理步骤,以尽量减少重新标记阶段的工作量。(该过程在很多文章中会单独成一个大步骤讲解)
- 修正标记结果:由于在并发标记阶段导致的并发修改,导致漏标,错标,因此需要暂停应用线程(STW),确保修正这些标记结果。
- 处理卡表:检查并发标记阶段修改的这些脏卡,并重新标记引用的对象,以确保所有可达对象都被正确识别。
- 处理最终可达对象:处理那些在并发标记阶段被识别出的“最终可达”(Finalizable)对象。这些对象需要执行它们的 finalize方法,finalize方法可能会使对象重新变为可达状态。
- 处理弱引用、软引用、幻象引用等:处理各种不同类型的引用,确保它们按照预期被处理。例如,弱引用在 GC后会被清除,软引用在内存不足时会被清除,而幻象引用则在对象被垃圾收集器回收时被放入引用队列。
4.并发清除
这里的并发也是指应用线程和 GC线程可以并发执行,并发清除阶段主要完成 2个事情:
- 清除并发标记阶段标记为死亡的对象;
- 并发清除结束后,CMS 会利用空闲列表(free-list)将未被标记的内存(即垃圾对象占据的内存)收集起来,组成一个空闲列表,用于新对象的内存分配;
5.重置
清理和重置 CMS回收器的内部数据结构,为下一次垃圾回收做准备。
到此,回收过程就分析完毕,接下来总结下 CMS的优点和缺点。
CMS 的优点
低停顿时间
相对 Serial,Serial Old,ParNew,Parallel Scavenge 4款回收器,CMS收集器的主要优势是减少垃圾收集时的停顿时间,特别是减少了Full GC的停顿时间,这对于延迟敏感的应用程序非常有利。
并发收集
CMS在回收过程中,应用线程和 GC线程可以并发执行,从而减少了垃圾收集对应用程序的影响。
适合多核处理器
由于CMS利用了并发执行,它能够更好地利用现代多核处理器的能力,将垃圾收集的工作分散到多个CPU核心。
CMS 的缺点
浮动垃圾
在并发清除阶段,因为应用线程可以并发工作,可能会产生垃圾,这些垃圾在当前 GC无法处理,需要到下一次 GC才能进行处理,因此,这些垃圾就叫做“浮动垃圾”。
Concurrent Mode Failure
JDK5 默认设置下,当老年代使用了68%的空间后就会被激活 CMS回收,从JDK 6开始,垃圾回收启动阈值默认提升至92%,我们可以通过 -XX:CMSInitiatingOccupancyFraction 参数自行调节。
如果阈值是 68%,可能导致空间没有完全利用,频繁产生 GC,如果是92%,又会更容易面临另一种风险,要是预留的内存无法满足程序分配新对象的需要,就会出现一次 Concurrent Mode Failure(并发失败),因此会引发 FullGC。
这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。
内存碎片
因为 CMS采用的是标记-清理算法,当清理之后就会产生很多不连续的内存空间,这就叫做内存碎片。如果老年代无法使用连续空间来分配对象,就会出发 Full GC。为了解决这个问题,CMS收集器提供了 -XX:+UseCMS-CompactAtFullCollection 参数进行碎片压缩整理,参数默认是开启的,不过 从JDK 9开始废弃。
总结
- 本文不仅讲解了 CMS回收器,更是铺垫了很多 GC相关的基础知识,比如 安全点,三色标记法,卡表,写屏障。
- CMS 是 Concurrent Mark Sweep 的简称,中文翻译为并发标记清除,它的目标是减少垃圾回收时应用线程的停顿时间,并且实现应用线程和 GC线程并发执行。
- CMS 用于老年代的垃圾回收,使用的是标记-清除算法。通过 -XX:+UseConMarkSweepGC 参数即可启动 CMS收集器。
- CMS 主要包含:初始标记,并发标记,重新标记,并发清除,重置 5个过程。
- CMS 收集器使用三色标记法来标记对象,采用写屏障,卡表和脏页的方式来防止并发标记中修改的引用被漏标。
- CMS 收集器有 3大缺点:浮动垃圾,并发失败以及内存碎片。
尽管 CMS收集器已经被官方废弃了,但是它这种优化思路值得我们日常开发中借鉴。
希望文章可以给你带来收获和思考,如果有任何疑问,欢迎评论区留言讨论。如果本文对你有帮助,请帮忙点个在看,点个赞,或者转发给更多的小伙伴,获取三色标记法相关资料,请关注公众号,回复:三色
网站推荐
[CiteSeerX](citeseerx.ist.psu.edu)是一个公开的学术文献数字图书馆和搜索引擎,主要集中在计算机和信息科学领域的文献。该网站允许用户搜索、查看和下载相关的学术论文和文献,包括论文、会议记录、技术报告等。
CiteSeerX的特点包括:
- 自动引文索引:CiteSeerX使用算法自动从文档中提取引文,并创建文献之间的引用链接。
- 自动元数据提取:它能自动识别文档的元数据,如标题、作者、出版年份等。
- 相关文档推荐:根据用户的搜索和查看历史,CiteSeerX可以推荐相关的文档。
- 文档更新:CiteSeerX会自动在网络上查找和索引新文档,以保持数据库的更新。
CiteSeerX由宾夕法尼亚州立大学的信息科学与技术学院维护和管理。该项目是科研人员和学生获取计算机科学和相关学科文献的重要资源之一。
参考
HotSpot Virtual Machine Garbage Collection Tuning Guide
Java Garbage Collection Basics
Why does CMS collector collect root references from young generation on Initial Mark phase?
Memory Management in the Java HotSpot Virtual Machine
A Generational Mostly-concurrent Garbage Collector
The JVM Write Barrier - Card Marking
原创好文
来源:juejin.cn/post/7445517512609447951
升级到 Java 21 是值得的
升级到 Java 21 是值得的
又到了一年中的这个时候——New Relic 的年度“State of the Java Ecosystem”调查结果出来了,我一如既往地深入研究了它。虽然我认为该报告做得很好并且提出了很好的问题,但我对有多少 Java 开发人员正在使用低版本感到沮丧。
您使用的是 Java 21 吗?确实应该使用了。
在开始调查之前,作为一名 Java 爱好者,我想谈谈我最喜欢的关于 Java 21 的一些事情。
首先我要说的是,Spring Boot 3.x 是当前 Java 虚拟机 (JVM) 上最流行的服务器端技术栈,至少需要 Java 17。它不支持 Java 8,这是第二个版本。根据调查,最常用的版本。
我很高兴看到 Java 17 的采用进展相对较快,但您确实应该使用 Java 21。Java 21 比 Java 8 好得多。它在所有方面都在技术上优越。它更快、更安全、更易于操作、性能更高、内存效率更高。
道德上也很优越。当您的孩子发现您在生产中使用 Java 8 时,您不会喜欢他们眼中流露出羞愧和悲伤的表情。
做正确的事,成为你希望在世界上看到的改变:使用 Java 21。它充满了优点,基本上是自 Java 7 以来的一种全新语言:Lambdas,Multiline strings。Smart switch expressions。 var
。Pattern matching。Named tuples(在 Java 中称为 records
)。
当然,最重要的是虚拟线程。虚拟线程是一件大事。它们提供了与 async
/ await
或 suspensions 相同的优点,但没有其他语言中冗长代码。
是的,你明白我的意思了。与其他语言相比,Java 的虚拟线程提供了更好的解决方案,并且代码更少。
如果你不知道我在说什么,并且使用其他语言,那么你现在会很生气。java?比您最喜欢的语言更简洁?不可能的!但我并没有错。
为什么虚拟线程很重要
要了解virtual threads,您需要了解创建它们是为了解决的问题。如果您还没有体验过虚拟线程,那么它们有点难以描述。我会尽力。
Java 有阻塞操作——比如 Thread.sleep(long)
、 InputStream.read
和 OutputStream.write
。如果您调用其中一个方法,程序将不会前进到下一行,直到这些方法完成它们正在做的事情并返回。
大多数网络服务都是 I/O 密集的,这意味着它们将大部分时间花在输入和输出方法上,例如 InputStream.read
和 OutputStream.write
。
任务提交到线程池中却没有更多线程的服务是很常见的,但仍然无法返回响应,因为所有现有线程都在等待某些 I/O 操作发生,例如跨线程的 I/O HTTP 边界、数据库或消息队列的 I/O。
有多种方法可以解锁 I/O。您可以使用 java.nio
,它非常复杂,会引起焦虑。您可以使用reactive式编程,它的工作原理是范式的(paradigmatically),但它是对整个代码库的完整重构。
因此,我们的想法是:如果编译器知道您何时执行了可能会阻塞的操作(例如 InputStream.read
)并重新排序代码的执行,这不是很好吗?因此,当您执行阻塞操作时,等待代码将从当前执行线程移出,直到阻塞操作完成,然后在准备好恢复执行后将其放回另一个线程。
这样,您就可以继续使用阻塞语义。第一行在第二行之前执行。这提高了可调试性和可扩展性。您不再垄断线程只是为了在等待某些事情完成时浪费它们。这将是两全其美:非阻塞 I/O 的可扩展性与更简单的阻塞 I/O 的明显简单性、可调试性和可维护性。
许多其他语言,如 Rust、Python、C#、TypeScript 和 JavaScript,都支持 async
/ await
。 Kotlin 支持 suspend
。这些关键字提示运行时您将要做一些阻塞的事情,并且它应该重新排序执行。这是一个 JavaScript 示例:
async function getCustomer(){ /* call a database */ }
const result = await getCustomer();
问题症结在于要调用 async
函数,还必须位于 async
函数中:
async function getCustomer(){ /* call a database */ }
async function main(){
const result = await getCustomer();
}
async
关键字 是病毒式的。它蔓延开来。最终,你的代码会陷入 async
/ await
的泥潭——你为什么在任何可能的地方使用async/await
呢?因为,它比使用低级、非阻塞 I/O 或反应式编程要好,但也只是勉强好。
Java 提供了一种更好的方法。只需为您的线程使用不同的工厂方法即可。
如果您使用 ExecutorService
创建新线程,请使用创建虚拟线程的新版本。
var es = Executors.newVirtualThreadPerTaskExecutor();
// ^- this is different and you'll probably only do it once
// or twice in a typical application
var future = es.submit(() -> System.out.println("hello, virtual threads!"));
如果您直接在较低级别创建线程,则使用新的工厂方法:
// this is different
var thread = Thread.ofVirtual().start(() -> System.out.println("hello, virtual threads!"));
您的大部分代码保持完全不变,但现在您的可扩展性得到了显着提高。如果您创建数百万个线程,运行时不会喘息。我无法预测您的结果会是什么,但您很有可能不再需要运行给定服务的几乎同样多的实例来处理负载。
如果您使用的是 Spring Boot 3.2(您是,不是吗?),那么您甚至不需要执行任何操作。只需在 application.properties
中指定 spring.threads.virtual.enabled=true
,然后向管理层请求加薪,费用由大幅降低的云基础设施成本支付。
并非每个应用程序都可以在技术上实现跨越,但其中绝大多数可以而且应该。
使用情况报告分析
最后,这让我回到了 New Relic 报告。不要误会我的意思:它做得非常好,值得一读。就像莎士比亚悲剧一样,它写得很好,讲述了一个悲伤的故事。
有一个完整的部分证实了显而易见的事实:天空是蓝色的,云彩无处不在。在容器中部署工作负载似乎是主流模式,受访者表示 70% 的 Java 工作负载使用容器。坦白说,我很惊讶它这么低。
同样令人感兴趣的是从单核配置转向多核的趋势。根据调查,30% 的容器化应用程序正在使用 Java 9 的 -XX:MaxRAMPercentage
标志,该标志限制了 RAM 使用。 G1 是最流行的垃圾收集器。一切都很好。
当涉及到 Java 版本时,该报告发生了悲剧性的转变。超过一半的应用程序(56%)在生产中使用 Java 11,而 2022 年这一比例为 48%。Java 8(十年前的 2014 年发布)紧随其后,近 33% 的应用程序在生产中使用它。根据调查,三分之一的应用程序仍在使用 Java 版本,该版本在《Flappy Bird》游戏被下架、《冰桶挑战》横扫 Vine、《Ellen DeGeneres 奥斯卡》自拍照火爆的同一年推出。
多个用户使用 Amazon 的 OpenJDK 分发版。该报告表明,这是因为甲骨文暂时为其发行引入了更严格的许可。但我想知道其中有多少只是 Amazon Web Services(最多产的基础设施即服务 (IaaS) 供应商)上 Java 工作负载默认分布的函数。自几年前推出以来,该发行版已受到广泛欢迎。 2020年,它的市场份额为2.18%,现在则为31%。如果这么多人可以如此迅速地迁移到完全不同的发行版,那么他们应该能够使用同一发行版的新版本,不是吗?
我想,趋势中还是有一些希望的。 Java 17 用户采用率一年内增长了 430%。因此,也许我们会在 Java 21 中看到类似的数字——Java 21 已经全面发布近六个月了。
你还在等什么?
正如我在 Voxxed Days 的演讲中所说,我相信现在是成为 Java 和 Spring Boot 开发人员的最佳时机。 Java 和 Spring 开发人员拥有最好的玩具。我什至还没有提到 GraalVM 本机映像,它可以显着缩短给定 Java 应用程序的启动时间并减少内存占用。这已经与 Java 21 完美配合。
这些东西就在这里,它们太棒了。能否实现这一跳跃取决于我们。这并不难。试试看。
安装 SDKMan,运行 sdk install java 21.0.2-graalce
然后运行 sdk default java 21.0.2-graalce
。这将为您提供 Java 21 和 GraalVM 本机映像编译器。访问 Spring Initializr,这是我在网络上第二喜欢的地方(仅次于生产),网址为 start.spring.io。配置一个新项目。选择 Java 21(自然!)。添加 GraalVM Native Support
。添加 Web
。点击 Generate
按钮并将其加载到您的 IDE 中。在 application.properties
中指定 spring.threads.virtual.enabled=true
。创建一个简单的 HTTP 控制器:
@Controller
class Greetings {
@GetMapping("/hi")
String hello(){
return "hello, Java 21!";
}
}
将其编译为 GraalVM 本机映像: ./gradlew nativeCompile
。运行 build
文件夹中的二进制文件。
现在,您已经有了一个应用程序,该应用程序只占用非 GraalVM 本机映像所需 RAM 的一小部分,并且还能够扩展到每秒更多的请求。简单,而且令人惊奇。
原文地址:We CAN Have Nice Things: Upgrading to Java 21 Is Worth It - The New Stack
来源:juejin.cn/post/7345763454814765083
工作中 Spring Boot 五大实用小技巧,来看看你掌握了几个?
0. 引入
Spring Boot 以其简化配置、快速开发和微服务支持等特点,成为了 Java 开发的首选框架。本文将结合我在实际工作中遇到的问题,分享五个高效的 Spring Boot 的技巧。希望这些技巧能对你有所帮助。
1. Spring Boot 执行初始化逻辑
1.1 背景
项目的某次更新,数据库中的某张表新增了一个字段,且与业务有关联,需要对新建的字段根据对应的业务进行赋值操作。
一种解决方案就是,更新前手动写 SQL 更新字段的值,但这样做的效率太低,而且每给不同环境更新一次,就需要手动执行一次,容易出错且效率低。
另一种方案则是在项目启动时进行初始化操作,完成字段对应值的更新,这种方案效率更高且不容易出错。
1.2 实现
Spring Boot 提供了多种方案用于项目启动后执行初始化的逻辑。
- 实现
CommandLineRunner
接口,重写run方法。
@Slf4j
@Component
public class InitListen implements CommandLineRunner {
@Override
public void run(String... args) {
// 初始化相关逻辑...
}
}
- 实现
ApplicationRunner
接口,重写run方法。
@Slf4j
@Component
public class InitListen implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// 初始化相关逻辑...
}
}
- 实现
ApplicationListener
接口
@Slf4j
@Configuration
public class StartClientListener implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent arg0) {
// 初始化逻辑
}
}
针对于上述这个需求,如何实现仅更新一次字段的值?
可在数据库字典表中设置一个更新标识字段,每次执行初始化逻辑之前,校验判断下字典中的这个值,确认是否已经更新,如果已经更新,就不需要再执行更新操作了。
2. Spring Boot 动态控制数据源的加载
2.1 背景
期望通过在application.yml
文件中,添加一个开关来控制是否加载数据库。
2.2 实现
启动类上添加注解 @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }),代表禁止 Spring Boot 自动注入数据源。
新建 DataSourceConfig
配置类,用于初始化数据源。
在DataSourceConfig配置类上添加条件注解 @ConditionalOnProperty(name = "spring.datasource.enabled", havingValue = "true",代表只有当 spring.datasource.enabled 为 true时,加载数据库,其余情况不加载数据库。
仓库类 XxxRepository 的注入,需要使用注解 @Autowired(required = false)
详细可见文章:
Spring Boot 如何动态配置数据库的加载
3. Spring Boot 根据不同环境加载配置文件
3.1 背景
实际开发工作中,针对同一个项目,可能会存在开发环境、测试环境、正式环境等,不同环境的配置内容可能会不一致,如:数据库、Redis等等。期望在项目在启动时能够针对不同的环境来加载不同的配置文件。
3.2 实现
Spring 提供 Profiles 特性,通过启动时设置指令-Dspring.profiles.active
指定加载的配置文件,同一个配置文件中不同的配置使用---
来区分。
启动 jar 包时执行命令:
java -jar test.jar -Dspring.profiles.active=dev
-Dspring.profiles.active=dev
代表激活 profiles 为 dev 的相关配置。
## 用---区分环境,不同环境获取不同配置
---
# 开发环境
spring:
profiles: dev
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
# 命名空间为默认,所以不需要写命名空间
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
extension-configs[0]:
data-id: database-base.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[1]:
# 本地单机Redis
data-id: redis-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[2]:
data-id: master-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
---
#测试环境
spring:
profiles: test
cloud:
nacos:
discovery:
server-addr: 192.168.0.111:8904
# 测试环境注册的命名空间
namespace: b80b921d-cd74-4f22-8025-333d9b3d0e1d
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
extension-configs[0]:
data-id: database-base-test.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[1]:
data-id: redis-base-test.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[2]:
data-id: master-auth-test.yaml
group: DEFAULT_GR0UP
refresh: true
---
# 生产环境
spring:
profiles: prod
cloud:
nacos:
discovery:
server-addr: 192.168.0.112:8848
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
extension-configs[0]:
# 生产环境
data-id: database-auth.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[1]:
# 生产环境
data-id: redis-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[2]:
data-id: master-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
也可以定义多个配置文件,如在application.yml中定义和环境无关的配置,而application-{profile}.yml
则根据环境做不同区分,如在 application-dev.yml 中定义开发环境相关配置、application-test.yml 中定义测试环境相关配置。
启动时指定环境命令同上,仍为:
java -jar test.jar -Dspring.profiles.active=dev
4. Spring Boot 配置文件加密
4.1 背景
配置文件中包含的敏感信息(如数据库密码)都会以明文的形式存储,这种情况可能会存在安全风险,期望通过加密配置文件,确保应用程序的安全。
4.2 实现
- pom.xml 文件中引入依赖。
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>2.1.2</version>
</dependency>
如果遇到 Unresolved dependency: 'com.github.ulisesbocchio:jasypt-spring-boot-starter:jar:2.1.2' 的错误,可执行
mvn clean install -U
强制更新依赖。
- application.yml 文件中增加配置如下:
jasypt:
encryptor:
password: G0C3D17o2n6
algorithm: PBEWithMD5AndDES
- 执行测试用例,获取加密后的内容。
@RunWith(SpringRunner.class)
@SpringBootTest
public class DatabaseTest {
@Autowired
private StringEncryptor encryptor;
@Test
public void getPass() {
String url = encryptor.encrypt("jdbc:mysql://localhost:3306/demo");
String name = encryptor.encrypt("root");
String password = encryptor.encrypt("123456");
System.out.println("database url: " + url);
System.out.println("database name: " + name);
System.out.println("database password: " + password);
Assert.assertTrue(url.length() > 0);
Assert.assertTrue(name.length() > 0);
Assert.assertTrue(password.length() > 0);
}
}
根据测试用例获取的结果,将加密后的字符串替换明文。
- 启动程序,验证数据库能否正常连接。
为了防止 jasypt.encryptor.password 泄露,反解出密码,有两种方案:
- 将 jasypt.encryptor.password 设置为环境变量,如:
vim /etc/profile
export jasypt.encryptor.password=YOUR_SECRET_KEY
- 将 jasypt.encryptor.password 作为启动程序的参数,如:
java -jar xxx.jar -Djasypt.encryptor.password=YOUR_SECRET_KEY
5. Spring Boot对打包好的jar包瘦身
5.1 背景
Sprng Boot项目的 jar 包动辄几百MB,如果有小的需求更新或者是Bug修复,就需要重新打包部署,改了一行代码,却上传几百MB的文件,这样会很浪费时间。
期望通过给 jar 包瘦身,从而节省部署的时间。
5.2 实现
pom.xml
文件中添加如下配置:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
<layout>ZIP</layout>
<!--这里是填写需要包含进去的jar,
必须项目中的某些模块,会经常变动,那么就应该将其坐标写进来
如果没有则nothing ,表示不打包依赖 -->
<includes>
<include>
<groupId>nothing</groupId>
<artifactId>nothing</artifactId>
</include>
</includes>
</configuration>
</plugin>
<!--拷贝依赖到jar外面的lib目录-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<!--指定的依赖路径-->
<outputDirectory>
${project.build.directory}/lib
</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
- 执行
mvn clean package
得到 jar 包,在项目启动时,需要通过 -Dloader.path指定lib的路径,如:
java -Dloader.path=./lib -jar testProject-0.0.1-SNAPSHOT.jar
效果如下:
通过分析 jar 包的结构可以得知,jar 包的 “大” 实际上是因为在打包时,会将项目所依赖的 jar 包放在 lib 夹文件中。而这部分依赖在版本迭代稳定后,基本是不会变化的。
上述这种给 jar 包瘦身的方案,实际上是在打包的时候忽略 lib 文件夹中的这些依赖,将这部分不变的依赖提前放到服务器上,打出来的 jar 包就变小了,从而提升发版效率。
参考资料
zhuanlan.zhihu.com/p/646593227
来源:juejin.cn/post/7424906244215193636
go的生态真的一言难尽
前言
标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 Maven 或 Gradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设计和利用现有的工具与库,可以实现一个功能强大且灵活的工具。
正文分为两部分:项目本身和如何使用
一、项目本身
1. 项目需求分析
核心需求
- 依赖管理:
- 解析和下载 Go 项目的依赖。
- 支持依赖版本控制和冲突解决。
- 构建管理:
- 支持编译 Go 项目。
- 支持跨平台编译。
- 支持自定义构建选项。
- 发布管理:
- 打包构建结果。
- 支持发布到不同的平台(如 Docker Hub、GitHub Releases)。
- 任务管理:
- 支持定义和执行自定义任务(如运行测试、生成文档)。
- 插件系统:
- 支持扩展工具的功能。
可选需求
- 缓存机制:缓存依赖和构建结果以提升速度。
- 并行执行:支持并行下载和编译。
2. 技术选型
2.1 编程语言
- Go 语言:由于我们要构建的是 Go 项目的构建工具,选择 Go 语言本身作为开发语言是合理的。
2.2 依赖管理
- Go Modules:Go 自带的依赖管理工具已经很好地解决了依赖管理的问题,可以直接利用 Go Modules 来解析和管理依赖。
2.3 构建工具
- Go 标准库:Go 的标准库提供了强大的编译和构建功能(如
go build
,go install
等命令),可以通过调用这些命令或直接使用相关库来进行构建。
2.4 发布工具
- Docker:对于发布管理,可能需要集成 Docker 来构建和发布 Docker 镜像。
- upx:用于压缩可执行文件。
2.5 配置文件格式
- YAML 或 TOML:选择一种易于阅读和编写的配置文件格式,如 YAML 或 TOML。
3. 系统架构设计
3.1 模块划分
- 依赖管理模块:
- 负责解析和下载项目的依赖。
- 构建管理模块:
- 负责编译 Go 项目,支持跨平台编译和自定义构建选项。
- 发布管理模块:
- 负责将构建结果打包和发布到不同平台。
- 任务管理模块:
- 负责定义和执行自定义任务。
- 插件系统:
- 提供扩展点,允许用户编写插件来扩展工具的功能。
3.2 系统流程
- 初始化项目:读取配置文件,初始化项目环境。
- 依赖管理:解析依赖并下载。
- 构建项目:根据配置文件进行项目构建。
- 执行任务:执行用户定义的任务(如测试)。
- 发布项目:打包构建结果并发布到指定平台。
4. 模块详细设计与实现
4.1 依赖管理模块
4.1.1 设计
利用 Go Modules 现有的功能来管理依赖。可以通过 go list
命令来获取项目的依赖:
4.1.2 实现
package dependency
import (
"fmt"
"os/exec"
)
// ListDependencies 列出项目所有依赖
func ListDependencies() ([]byte, error) {
cmd := exec.Command("go", "list", "-m", "all")
return cmd.Output()
}
// DownloadDependencies 下载项目所有依赖
func DownloadDependencies() error {
cmd := exec.Command("go", "mod", "download")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("download failed: %s", output)
}
return nil
}
4.2 构建管理模块
4.2.1 设计
调用 Go 编译器进行构建,支持跨平台编译和自定义构建选项。
4.2.2 实现
package build
import (
"fmt"
"os/exec"
"runtime"
"path/filepath"
)
// BuildProject 构建项目
func BuildProject(outputDir string) error {
// 设置跨平台编译参数
var goos, goarch string
switch runtime.GOOS {
case "windows":
goos = "windows"
case "linux":
goos = "linux"
default:
goos = runtime.GOOS
}
goarch = "amd64"
output := filepath.Join(outputDir, "myapp")
cmd := exec.Command("go", "build", "-o", output, "-ldflags", "-X main.version=1.0.0")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build failed: %s", output)
}
fmt.Println("Build successful")
return nil
}
4.3 发布管理模块
4.3.1 设计
打包构建结果并发布到不同平台。例如,构建 Docker 镜像并发布到 Docker Hub。
4.3.2 实现
package release
import (
"fmt"
"os/exec"
)
// BuildDockerImage 构建 Docker 镜像
func BuildDockerImage(tag string) error {
cmd := exec.Command("docker", "build", "-t", tag, ".")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker build failed: %s", output)
}
fmt.Println("Docker image built successfully")
return nil
}
// PushDockerImage 推送 Docker 镜像
func PushDockerImage(tag string) error {
cmd := exec.Command("docker", "push", tag)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker push failed: %s", output)
}
fmt.Println("Docker image pushed successfully")
return nil
}
5. 任务管理模块
允许用户定义和执行自定义任务:
package task
import (
"fmt"
"os/exec"
)
type Task func() error
func RunTask(name string, task Task) {
fmt.Println("Running task:", name)
err := task()
if err != nil {
fmt.Println("Task failed:", err)
return
}
fmt.Println("Task completed:", name)
}
func TestTask() error {
cmd := exec.Command("go", "test", "./...")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tests failed: %s", output)
}
fmt.Println("Tests passed")
return nil
}
6. 插件系统
可以通过动态加载外部插件或使用 Go 插件机制来实现插件系统:
package plugin
import (
"fmt"
"plugin"
)
type Plugin interface {
Run() error
}
func LoadPlugin(path string) (Plugin, error) {
p, err := plugin.Open(path)
if err != nil {
return nil, err
}
symbol, err := p.Lookup("PluginImpl")
if err != nil {
return nil, err
}
impl, ok := symbol.(Plugin)
if !ok {
return nil, fmt.Errorf("unexpected type from module symbol")
}
return impl, nil
}
5. 示例配置文件
使用 YAML 作为配置文件格式,定义项目的构建和发布选项:
name: myapp
version: 1.0.0
build:
options:
- -ldflags
- "-X main.version=1.0.0"
release:
docker:
image: myapp:latest
tag: v1.0.0
tasks:
- name: test
command: go test ./...
6. 持续改进
后续我将持续改进工具的功能和性能,例如:
- 增加更多的构建和发布选项。
- 优化依赖管理和冲突解决算法。
- 提供更丰富的插件。
二、如何使用
1. 安装构建工具
我已经将构建工具发布到 GitHub 并提供了可执行文件,用户可以通过以下方式安装该工具。
1.1 使用安装脚本安装
我将提供一个简单的安装脚本,开发者可以通过 curl
或 wget
安装构建工具。
使用 curl
安装
curl -L https://github.com/yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
使用 wget
安装
wget -qO- https://github.com//yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
1.2 手动下载并安装
如果你不想使用自动安装脚本,可以直接从 GitHub Releases 页面手动下载适合你操作系统的二进制文件。
- 访问 GitHub Releases 页面。
- 下载适合你操作系统的二进制文件:
- Linux:
GoForge-linux-amd64
- macOS:
GoForge-darwin-amd64
- Windows:
GoForge-windows-amd64.exe
- Linux:
- 将下载的二进制文件移动到系统的 PATH 路径(如
/usr/local/bin/
),并确保文件有执行权限。
# 以 Linux 系统为例
mv GoForge-linux-amd64 /usr/local/bin/GoForge
chmod +x /usr/local/bin/GoForge
2. 创建 Go 项目并配置构建工具
2.1 初始化 Go 项目
假设你已经有一个 Go 项目或你想创建一个新的 Go 项目。首先,初始化 Go 模块:
mkdir my-go-project
cd my-go-project
go mod init github.com/myuser/my-go-project
2.2 创建 build.yaml
文件
在项目根目录下创建 build.yaml
文件,这个文件类似于 Maven 的 pom.xml
或 Gradle 的 build.gradle
,用于配置项目的依赖、构建任务和发布任务。
示例 build.yaml
project:
name: my-go-project
version: 1.0.0
dependencies:
- name: github.com/gin-gonic/gin
version: v1.7.7
- name: github.com/stretchr/testify
version: v1.7.0
build:
output: bin/my-go-app
commands:
- go build -o bin/my-go-app main.go
tasks:
clean:
command: rm -rf bin/
test:
command: go test ./...
build:
dependsOn:
- test
command: go build -o bin/my-go-app main.go
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
配置说明:
- project: 定义项目名称和版本。
- dependencies: 列出项目的依赖包及其版本号。
- build: 定义构建输出路径和构建命令。
- tasks: 用户可以定义自定义任务(如
clean
、test
、build
等),并可以配置任务依赖关系。 - publish: 定义发布到 GitHub 的配置,包括发布的仓库和需要发布的二进制文件。
3. 执行构建任务
构建工具允许你通过命令行执行各种任务,如构建、测试、清理、发布等。以下是一些常用的命令。
3.1 构建项目
执行以下命令来构建项目。该命令会根据 build.yaml
文件中定义的 build
任务进行构建,并生成二进制文件到指定的 output
目录。
GoForge build
构建过程会自动执行依赖任务(例如 test
任务),确保在构建之前所有测试通过。
3.2 运行测试
如果你想单独运行测试,可以使用以下命令:
GoForge test
这将执行 go test ./...
,并运行所有测试文件。
3.3 清理构建产物
如果你想删除构建生成的二进制文件等产物,可以运行 clean
任务:
GoForge clean
这会执行 rm -rf bin/
,清理 bin/
目录下的所有文件。
3.4 列出所有可用任务
如果你想查看所有可用的任务,可以运行:
GoForge tasks
这会列出 build.yaml
文件中定义的所有任务,并显示它们的依赖关系。
4. 依赖管理
构建工具会根据 build.yaml
中的 dependencies
部分来处理 Go 项目的依赖。
4.1 安装依赖
当执行构建任务时,工具会自动解析依赖并安装指定的第三方库(类似于 go mod tidy
)。
你也可以单独运行以下命令来手动处理依赖:
GoForge deps
4.2 更新依赖
如果你需要更新依赖版本,可以在 build.yaml
中手动更改依赖的版本号,然后运行 mybuild deps
来更新依赖。
5. 发布项目
构建工具提供了发布项目到 GitHub 等平台的功能。根据 build.yaml
中的 publish
配置,你可以将项目的构建产物发布到 GitHub Releases。
5.1 配置发布相关信息
确保你在 build.yaml
中正确配置了发布信息:
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
- type: 发布的目标平台(GitHub 等)。
- repo: GitHub 仓库路径。
- token: 需要设置环境变量
GITHUB_TOKEN
,用于认证 GitHub API。 - assets: 指定发布时需要上传的二进制文件。
5.2 发布项目
确保你已经完成构建,并且生成了二进制文件。然后,你可以执行以下命令来发布项目:
GoForge publish
这会将 bin/my-go-app
上传到 GitHub Releases,并生成一个新的发布版本。
5.3 测试发布(Dry Run)
如果你想在发布之前测试发布流程(不上传文件),可以使用 --dry-run
选项:
GoForge publish --dry-run
这会模拟发布过程,但不会实际上传文件。
6. 高级功能
6.1 增量构建
构建工具支持增量构建,如果你在 build.yaml
中启用了增量构建功能,工具会根据文件的修改时间戳或内容哈希来判断是否需要重新构建未被修改的部分。
build:
output: bin/my-go-app
incremental: true
commands:
- go build -o bin/my-go-app main.go
6.2 插件机制
你可以通过插件机制来扩展构建工具的功能。例如,你可以为工具增加自定义的任务逻辑,或在构建生命周期的不同阶段插入钩子。
在 build.yaml
中定义插件:
plugins:
- name: custom-task
path: plugins/custom-task.go
编写 custom-task.go
,并实现你需要的功能。
7. 调试和日志
如果你在使用时遇到了问题,可以通过以下方式启用调试模式,查看详细的日志输出:
GoForge --debug build
这会输出工具在执行任务时的每一步详细日志,帮助你定位问题。
总结
通过这个构建工具,你可以轻松管理 Go 项目的依赖、构建过程和发布任务。以下是使用步骤的简要总结:
- 安装构建工具:使用安装脚本或手动下载二进制文件。
- 配置项目:创建
build.yaml
文件,定义依赖、构建任务和发布任务。 - 执行任务:通过命令行执行构建、测试、清理等任务。
- 发布项目:将项目的构建产物发布到 GitHub 或其他平台。
来源:juejin.cn/post/7431545806085423158
不是,哥们,谁教你这样处理生产问题的?
你好呀,我是歪歪。
最近遇到一个生产问题,我负责的一个服务触发了内存使用率预警,收到预警的时候我去看了内存使用率已经到了 80%,看了一眼 GC 又发现还没有触发 FullGC,一次都没有。
基于这个现象,当时推测有两种可能,一种是内存溢出,一种是内存泄漏。
好,假设现在是面试,面试官目前就给了这点信息,他问你到底是溢出还是泄漏,你怎么回答?
在回答之前,我们得现明确啥是溢出,啥情况又是泄漏。
- 内存溢出(OutOfMemoryError):内存溢出指的是程序请求的内存超出了 JVM 当前允许的最大内存容量。当 JVM 试图为一个对象分配内存时,如果当前可用的堆内存不足以满足需求,就会抛出 java.lang.OutOfMemoryError 异常。这通常是因为堆空间太小或者由于某些原因导致堆空间被占满。
- 内存泄漏 (Memory Leak):内存泄漏是指不再使用的内存空间没有被释放,导致这部分内存无法再次被使用。虽然内存泄漏不会立即导致程序崩溃,但它会逐渐消耗可用内存,最终可能导致内存溢出。
虽然都与内存相关,但它们发生的时机和影响有所不同。内存溢出通常发生在程序运行时,当数据结构的大小超过预设限制时,常见的情况是你要分配一个大对象,比如一次从数据中查到了过多的数据。
而内存泄漏和“过多”关系不大,是一个细水长流的过程,一次内存泄漏的影响可能微乎其微,但随着时间推移,多次内存泄漏累积起来,最终可能导致内存溢出。
概念就是这个概念,这两个玩意经常被大家搞混,所以多嘴提一下。
概念明确了,回到最开始这个问题,你怎么回答?
你回答不了。
因为这些信息太不完整了,所以你回答不了。
面试的时候面试官就喜欢出这种全是错误选项的题目来迷惑你,摸摸你的底子到底怎么样。
首先,为什么不能判断,是因为前面说了:一次 FullGC 都没有。
虽然现在内存使用率已经到 80% 了,万一一次 FullGC 之后,内存使用率又下去了呢,说明程序没有任何问题。
如果没有下去,说明大概率是内存溢出了,需要去代码里面找哪里分配了大对象了。
那如果下去了,能说明一定没有内存泄漏吗?
也不能,因为前面又说了:内存泄漏是一个细水长流的过程。
关于内存溢出,如果监控手段齐全到位的话,你就记住左边这个走势图:
一个缓慢的持续上升的内存趋势图, 最后疯狂触发 GC,但是并没有内存被回收,最后程序直接崩掉。
内存泄漏,一眼定真假。
这个图来自我去年写的这篇文章:《虽然是我遇到的一个棘手的生产问题,但是我写出来之后,就是你的了。》
里面就是描述了一个内存泄漏的问题,通过分析 Dump 文件的方式,最终成功定位到泄漏点,修复代码。
一个不论多么复杂的内存泄漏问题,处理起来都是有方法论的。
不过就是 Dump 文件分析、工具的使用以及足够的耐心和些许的运气罢了。
所以我不打算赘述这些东西了,我想要分享的是我这次是怎么对应文章开始说的内存预警的。
我的处理方式就是:重启服务。
是的,常规来说都是会保留现场,然后重启服务。但是我的处理方式是:直接执行重启服务的预案。没有后续动作了。
我当时脑子里面的考虑大概是这样的。
首先,这个服务是一个边缘服务,它所承载的数据量不多,其业务已经超过一年多没有新增,存量数据正在慢慢的消亡。代码近一两年没啥改动,只有一些升级 jar 包,日志埋点这类的横向改造。
其次,我看了一下这个服务已经有超过四个月没有重启过了,这期间没有任何突发流量,每天处理的数据呈递减趋势,内存走势确实是一个缓慢上升的过程,我初步怀疑是有内存泄漏。
然后,这个服务是我从别的团队那边接手的一个服务,基于前一点,业务正在消亡这个因素,我也只是知道大概的功能,并不知道内部的细节,所以由于对系统的熟悉度不够,如果要定位问题,会较为困难。
最后,基于公司制度,虽然我知道应该怎么去排查问题,命令和工具我都会使用,但是我作为开发人员是没有权限使用运维人员的各类排查工具和排查命令的,所以如果要定位问题,我必须请求协调一个运维同事帮忙。
于是,在心里默默的盘算了一下投入产出比,我决定直接重启服务,不去定位问题。
按照目前的频率,程序正常运行四五个月后可能会触发内存预警,那么大不了就每隔三个月重启一次服务嘛,重启一次只需要 30s。一年按照重启 4 次算,也就是才 2 分钟。
这个业务我们就算它要五年后才彻底消亡,那么也就才 10 分钟而已。
如果我要去定位到底是不是内存泄露,到底在哪儿泄露的,结合我对于系统的熟悉程度和公司必须有的流程,这一波时间消耗,少说点,加起来得三五个工作日吧。
10 分钟和三五个工作日,这投入产出比,该选哪个,一目了然了吧?
我分享这个事情的目的,其实就是想说明我在这个事情上领悟到的一个点:在工作中,你遇到的问题,不是每一个都必须被解决的,也可以选择绕过问题,只要最终结果是好的就行。
如果我们抛开其他因素,只是从程序员的本职工作来看,那么遇到诸如内存泄漏的问题的时候,就是应该去定位问题、解决问题。
但是在职场中,其实还需要结合实际情况,进行分析。
什么是实际情况呢?
我前面列出来的那个“首先,其次,然后,最后”,就是我这个问题在技术之外的实际情况。
这些实际情况,让我决定不用去定位这个问题。
这也不是逃避问题,这是权衡利弊之后的最佳选择。
同样是一天的时间,我可以去定位这个“重启就能解决”的问题,也可以去做其他的更有价值事情,敲一些业务价值更大的代码。
这个是需要去权衡的,一个重要的衡量标准就是前面说的:投入产出比。
关于“不是所有的问题都必须被解决的,也可以选择绕过问题”这个事情,我再给你举一个我遇到的真实的例子。
几年前,我们团队遇到一个问题,我们使用的 RPC 框架是 Dubbo,有几个核心服务在投产期间滚动发布的时候,流量老是弄不干净,导致服务已经下线了,上游系统还在调用。
当时安排我去调研一下解决方案。
其实这就是一个优雅下线的问题,但是当时资历尚浅,我认真研究了一段时间,确实没研究出问题的根本解决方案。
后来我们给出的解决方案就是做一个容错机制,如果投产期间有因为流量不干净的问题导致请求处理失败的,我们把这些数据记录下来,然后等到投产完成后再进行重发。
没有解决根本问题,选择绕过了问题,但是从最终结果上看,问题是被解决了。
再后来,我们搭建了双中心。投产之前,A,B 中心都有流量,每次投产的时候,先把所有流量从 A 中心切到 B 中心去,在 A 中心没有任何流量的情况下,进行服务投产。B 中心反之。
这样,从投产流程上就规避了“流量老是弄不干净”的问题,因为投产的时候对应的服务已经没有在途流量了,不需要考虑优雅的问题了,从而规避了优雅下线的问题。
问题还是没有被解决,但是问题被彻底绕过。
最后,再举一个我在知乎上看到的一个回答,和我想要表达的观点,有异曲同工之妙:
http://www.zhihu.com/question/63…
这个回答下面的评论也很有意思,有兴趣的可以去翻一下,我截取两个我觉得有意思的:
在职场上,甚至在生活中,一个虽然没有解决方案但是可以被绕过的问题,我认为不是问题。
但是这个也得分情况,不是所有问题都能绕开的,假如是一个关键服务,那肯定不能置之不理,硬着头皮也得上。
关键是,我在职场上和生活中遇到过好多人,遇到问题的时候,似乎只会硬着头皮往上冲。
只会硬着头皮往上冲和知道什么时候应该硬着头皮往上冲,是两种截然不同的职场阶段。
所以有时候,遇到问题的时候,不要硬上,也让头皮休息一下,看看能不能绕过去。
来源:juejin.cn/post/7417842116506058771
三行五行的 SQL 只存在于教科书和培训班
教科书中 SQL 例句通常都很简单易懂,甚至可以当英语来读,这就给人造成 SQL 简单易学的印象。
但实际上,这种三行五行的 SQL 只存在于教科书和培训班,我们在现实业务中写的 SQL 不会论行,而是以 K 计的,一条 SQL 几百行 N 层嵌套,写出 3K5K 是常事,这种 SQL,完全谈不上简单易学,对专业程序员都是恶梦。
以 K 计本身倒不是大问题,需求真地复杂时,也只能写得长,Python/Java 代码可能会更长。但 SQL 的长和其它语言的长不一样,SQL 的长常常会意味着难写难懂,而且这个难写难懂和任务复杂度不成比例。除了一些最简单情况外,稍复杂些的任务,SQL 的难度就会陡增,对程序员的智商要求很高,所以经常用作应聘考题。
这是为什么呢?
其中一个原因是我们之前讲过的,SQL 像英语而缺乏过程性,要把很多动作搅合在一句中,凭空地增大思维难度。
但是我们会发现,即使 SQL 增加了步骤化的 CTE 语法,面对稍复杂的任务时,仍然会写的非常难懂。
这是因为,SQL 的描述能力还有不少重要的缺失,这导致程序员不能按自然思维写代码,要换着方法绕。
我们通过一个简单的例子来看一下。
简化的销售业绩表 T 有三个字段:sales 销售员,product 产品,amount 销售额。我们想知道空调和电视销售额都在前 10 名的销售员名单。
这个问题并不难,可以很自然地设计出计算过程:
1.按空调销售额排序,找出前 10 名;
2.按电视销售额排序,找出前 10 名;
3.对 1、2 的结果取交集,得到我们想要的
用 CTE 语法后 SQL 可以写成这样:
with A as (select top 10 sales from T where product='AC' order by amount desc),
B as (select top 10 sales from T where product='TV' order by amount desc)
select * from A intersect B
这个句子不太短,但思路还是清晰的。
现在,我们把问题复杂化一点,改为计算所有产品销售额都在前 10 名的销售员,延用上述的思路很容易想到:
1. 列出所有产品;
2. 算出每种产品销售额的前 10 名,分别保存;
3. 针对这些前 10 名取交集;
遗憾开始出现,CTE 语法只能写出确定个数的中间结果。而我们事先不知道总共有多个产品,也就是说 WITH 子句的个数是不确定的,这就写不出来了。
好吧,换一种思路:
1.将数据按产品分组,将每组排序,计算出每组前 10 名;
2.针对这些前 10 名取交集;
这需要把第一步的分组结果保存起来,而这个中间结果是一个表,其中有个字段要存储对应的分组成员的前 10 名,也就是字段的取值将是个集合,SQL 不支持这种数据类型,还是写不出来。
我们可以再转换思路。按产品分组后,计算每个销售员在所有分组的前 10 名中出现的次数,若与产品总数相同,则表示该销售员在所有产品销售额中均在前 10 名内。
select sales from (
select sales from (
select sales, rank() over (partition by product order by amount desc ) ranking
from T ) where ranking <=10 )
group by sales having count(*)=(select count(distinct product) from T)
在窗口函数支持下,终于能写出来了。但是,这样的思路,绕不绕呢,有多少人想到并写出来呢?
前两种简单的思路无法用 SQL 实现,只能采用第三种迂回的思路。这里的原因在于 SQL 的一个重要缺失:集合化不彻底。
SQL 有集合概念,但并未把集合作为一种基础数据类型提供,不允许字段取值是集合,除了表之外也没有其它集合形式的数据类型,这使得大量集合运算在思维和书写时都非常绕。
我们刚才用了关键字 top,事实上关系代数理论中没有这个东西,这不是 SQL 的标准写法。
没有 top 如何找前 10 名呢?
大体思路是这样:找出比自己大的成员个数作为是名次,然后取出名次不超过 10 的成员
select sales from (
select A.sales sales, A.product product,
(select count(*)+1 from T
where A.product=product and A.amount<=amount) ranking
from T A )where product='AC' and ranking<=10
注意,这里的子查询没办法用 CTE 语法分步写,因为它用到了主查询中的信息作为参数。
或可以用连接来写,这样子查询倒是可以用 CTE 语法分步了:
select sales from (
select A.sales sales, A.product product, count(*)+1 ranking from T A, T B
where A.sales=B.sales and A.product=B.product and A.amount<=B.amount
gr0up by A.sales,A.product )
where product='AC' and ranking<=10
无论如何,这种东西都太绕了,专业程序员也要想一阵子,仅仅是计算了一个前 10 名。
造成这个现象的原因就是 SQL 的另一个缺失:缺乏有序支持。SQL 继承了数学上的无序集合,与次序有关的计算相当困难,而可想而知,与次序有关的计算会有多么普遍(诸如比上月、比去年同期、前 20%、排名等)。
SQL2003 标准中增加的窗口函数提供了一些与次序有关的计算能力,这在一定程度上缓解 SQL 有序计算的困难,前 10 名可以这样写:
select sales from (
select sales, rank() over (partition by product order by amount desc ) ranking
from T )
where ranking <=10
还是要用子查询。
窗口函数并没有根本改变 SQL 无序集合的基础,还是会有许多有序运算难以解决。比如我们经常用来举例的,计算一支股票最长连续上涨了多少天:
select max(ContinuousDays) from (
select count(*) ContinuousDays from (
select sum(UpDownTag) over (order by TradeDate) NoRisingDays from (
select TradeDate,case when Price>lag(price) over ( order by TradeDate) then 0 else 1 end UpDownTag from Stock ))
group by NoRisingDays )
自然思维是这样,按日期排序后开始计数,碰到涨了就加 1,跌了就清 0,看计数器最大计到几。但这个思路写不出 SQL,只能绕成这样多层嵌套的。
这个问题真地是当作应聘考题的,通过率不到 20%。
这么一个简单的例子就能暴露出 SQL 缺失的能力,SQL 缺失的内容还有更多,限于篇幅,这里就不再深入讨论了。
反正结果就是,SQL 实现查询时无法应用自然思路,经常需要绕路迂回,写得又长又难懂。
现实任务要远远比这些例子复杂,过程中会面临诸多大大小小的困难。这个问题绕一下,那个问题多几行,一个稍复杂的任务写出几百行多层嵌套的 SQL 也就不奇怪了,过两月自己也看不懂也不奇怪了。
事实上 SQL 一点也不容易。
下面是广告时间。
SQL 很难写怎么办?用 esProc SPL!
esProc SPL 是个 Java 写的开源软件,在这里github.com/SPLWare/esP…
SPL 在 SQL 已有的集合化基础上增加了离散性,从而获得了彻底的集合化和有序能力,上面的例子就 SPL 就可以延用自然思路写出来:
所有产品销售额都在前 10 名的销售员,按产品分组,取每个组的前 10 名再算交集;
T.group(product).(~.top(10;-amount)).isect()
SPL 支持集合的集合,top 也只是常规的聚合计算,有了这些基础,实现自然思路很容易。
一支股票最长连续上涨了多少天,只要按自然思路写就行了
cnt=0
Stock.sort(TradeDate).max(cnt=if(Price>Price[-1],cnt+1,0))
SPL 有强大的有序计算能力,即使实现和上面 SQL 同样的逻辑也非常轻松:
Stock.sort(TradeDate).group@i(Price<Price[-1]).max(~.len())
来源:juejin.cn/post/7441756894094491689
✨Try-Catch✨竟然会影响性能
前言
一朋友问我Try-Catch写多了会不会让程序变慢,我不加思索的回答肯定不会,毕竟曾经研究过Java异常相关的字节码指令,只要被Try-Catch的代码不抛出异常,那么代码执行链路是不会加深的。
可事后我反复思考这个看似简单实则也不复杂的问题,我觉得顺着这个问题往下,还有一些东西可以思考,如果你感兴趣,那就跟随本文的视角一起来看下吧。
正文
首先郑重声明,单纯的针对一段代码添加Try-Catch,是 不会
影响性能的,我们可以通过下面的示例代码并结合字节码指令来看下。
示例代码如下所示。
public class TryCatchPerformance {
public Response execute(String state) {
return innerHandle(state);
}
public Response innerHandle(String state) {
// todo 暂无逻辑
return null;
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
我们依次执行如下语句为上述代码生成字节码指令。
# 编译Java文件
javac .\TryCatchPerformance.java
# 反汇编字节码文件
javap -c .\TryCatchPerformance.class
可以得到execute() 方法的字节码指令如下。
public com.lee.learn.exception.TryCatchPerformance$Response execute(java.lang.String);
Code:
0: aload_0
1: aload_1
2: invokevirtual #2 // Method innerHandle:(Ljava/lang/String;)Lcom/lee/learn/exception/TryCatchPerformance$Response;
5: areturn
现在对execute() 方法添加Try-Catch,如下所示。
public class TryCatchPerformance {
public Response execute(String state) {
try {
return innerHandle(state);
} catch (Exception e) {
return new Response(500);
}
}
public Response innerHandle(String state) {
// todo 暂无逻辑
return null;
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
查看execute() 方法的字节码指令如下所示。
public com.lee.learn.exception.TryCatchPerformance$Response execute(java.lang.String);
Code:
0: aload_0
1: aload_1
2: invokevirtual #2 // Method innerHandle:(Ljava/lang/String;)Lcom/lee/learn/exception/TryCatchPerformance$Response;
5: areturn
6: astore_2
7: new #4 // class com/lee/learn/exception/TryCatchPerformance$Response
10: dup
11: sipush 500
14: invokespecial #5 // Method com/lee/learn/exception/TryCatchPerformance$Response."":(I)V
17: areturn
Exception table:
from to target type
0 5 6 Class java/lang/Exception
虽然添加Try-Catch后,字节码指令增加了很多条,但是通过Exception table(异常表)我们可知,只有指令0到5在执行过程中抛出了Exception,才会跳转到指令6开始执行,换言之只要不抛出异常,那么在执行完指令5后方法就结束了,此时和没添加Try-Catch时的代码执行链路是一样的,也就是不抛出异常时,Try-Catch不会影响程序性能。
我们添加Try-Catch,其实就是为了做异常处理,也就是我们天然的认为被Try-Catch的代码就是会抛出异常的,而异常一旦发生,此时程序性能就会受到一定程度的影响,表现在如下两个方面。
- 异常对象创建有性能开销。具体表现在异常对象创建时会去爬栈得到方法调用链路信息;
- Try-Catch捕获到异常后会让代码执行链路变深。
由此可见Try-Catch其实不会影响程序性能,但是异常的出现的的确确会影响,无论是JVM创建的异常,还是我们在代码中new出来的异常,都是会影响性能的。
所以现在我们来看看如下代码有什么可以优化的地方。
public class TryCatchPerformance {
public Response execute(String state) {
try {
return innerHandle(state);
} catch (Exception e) {
return new Response(500);
}
}
public Response innerHandle(String state) {
if (state == null || state.isEmpty()) {
// 通过异常中断执行
throw new IllegalStateException();
} else if ("success".equals(state)) {
return new Response(200);
} else {
return new Response(400);
}
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
上述代码的问题出现在innerHandle() ,仗着调用方有Try-Catch做异常处理,就在入参非法时通过创建异常来中断执行,我相信在实际的工程开发中,很多时候大家都是这么干的,因为有统一异常处理,那么通过抛出异常来中断执行并在统一异常处理的地方返回响应,是一件再平常不过的事情了,但是通过前面的分析我们知道,创建异常有性能开销,捕获异常并处理也有性能开销,这些性能开销其实是可以避免的,例如下面这样。
public class TryCatchPerformance {
public Response execute(String state) {
try {
return innerHandle(state);
} catch (Exception e) {
return new Response(500);
}
}
public Response innerHandle(String state) {
if (state == null || state.isEmpty()) {
// 通过提前返回响应的方式中断执行
return new Response(500);
} else if ("success".equals(state)) {
return new Response(200);
} else {
return new Response(400);
}
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
如果当某个分支执行到了,我们也确切的知道该分支下的响应是什么,此时直接返回响应,相较于抛出异常后在统一异常处理那里返回响应,性能会更好。
总结
Try-Catch其实不会影响程序性能,因为在没有异常发生时,代码执行链路不会加深。
但是如果出现异常,那么程序性能就会受到影响,表现在如下两个方面。
- 异常对象创建有性能开销。具体表现在异常对象创建时会去爬栈得到方法调用链路信息;
- Try-Catch捕获到异常后会让代码执行链路变深。
因此在日常开发中,可以适当增加防御性编程来防止JVM抛出异常,也建议尽量将主动的异常抛出替换为提前返回响应,总之就是尽量减少非必要的异常出现。
来源:juejin.cn/post/7458929387784077349
又整新活,新版 IntelliJ IDEA 2024.1 有点东西!
就在上周,Jetbrains 又迎来了一波大版本更新,这也是 JetBrains 2024首个大动作!
JetBrains 为其多款 IDE 发布了 2024 年度首个大版本更新 (2024.1)。
作为旗下重要的产品之一,IntelliJ IDEA当然也不例外。这不,现如今 IntelliJ IDEA 也来到了 2024.1 大版本了!
据官方介绍,这次 2024.1 新版本进行了数十项改进。
下面就针对本次新版 IntelliJ IDEA 的一些主要更新和特性做一个梳理和介绍,希望能对大家有所帮助。
全行代码补全
IntelliJ IDEA Ultimate 2024.1 带有针对 Java 和 Kotlin 的全行代码补全。
该项功能由无缝集成到 IDE 中的高级深度学习模型来提供支持。它可以基于上下文分析预测和建议整行代码,以助于提高编码效率。
对 Java 22 的支持
IntelliJ IDEA 2024.1 提供了对 2024 年 3 月刚发布的 JDK 22 中的功能集的支持。
支持覆盖未命名变量与模式的最终迭代、字符串模板与隐式声明的类的第二个预览版,以及实例main方法。 此外,这次更新还引入了对super(...)
之前预览状态下的 new 语句支持。
新终端加持
IntelliJ IDEA 2024.1推出了重构后的新终端,具有可视化和功能增强,有助于简化命令行任务。
此更新为既有工具带来了全新的外观,命令被分为不同的块,扩展的功能集包括块间丝滑导航、命令补全和命令历史记录的轻松访问等。
编辑器中的粘性行
此次新版本更新在编辑器中引入了粘性行,旨在简化大文件的处理和新代码库的探索。滚动时,此功能会将类或方法的开头等关键结构元素固定到编辑器顶部。
这样一来作用域将始终保持在视野中,用户可以点击固定的行快速浏览代码。
AI Assistant 改进
在本次新版中,AI Assistant 获得了多项有价值的更新,包括改进的测试生成和云代码补全、提交消息的自定义提示语、从代码段创建文件的功能,以及更新的编辑器内代码生成。
不过需要注意的事,在这次 2024.1 版中,AI Assistant 已解绑,现在作为独立插件提供。这一改动是为了在使用 AI 赋能的技术方面提供更多的决策灵活度,让用户能够在工作环境中更好地控制偏好设置和要求。
索引编制期间 IDE 功能对 Java 和 Kotlin 的可用
这次新版本中,代码高亮显示和补全等基本 IDE 功能可在项目索引编制期间用于 Java 和 Kotlin,这将会增强用户项目的启动体验。
此外,用户可以在项目仍在加载时即使用 Go to class(转到类)和 Go to symbol(转到符号)来浏览代码。
更新的 New Project(新建项目)向导
为了减轻用户在配置新项目时的认知负担,新版微调了 New Project(新建项目)向导的布局。语言列表现在位于左上角,使最常用的选项更加醒目。
用于缩小整个 IDE 的选项
新版支持可以将 IDE 缩小到 90%、80% 或 70%,从而可以灵活地调整 IDE 元素的大小。
对Java支持的更新
- 字符串模板中的语言注入
IntelliJ IDEA 2024.1 引入了将语言注入字符串模板的功能。
用户既可以使用注解(注解会自动选择所需语言),也可以使用 Inject language or reference(注入语言或引用)来从列表中手动选择语言。
- 改进的日志工作流
由于日志记录是日常开发的重要环节,新版本引入了一系列更新来增强 IntelliJ IDEA 在日志方面的用户体验。
比如现在用户可以从控制台中的日志消息中轻松导航到生成它们的代码。
此外,IDE会在有需要的位置建议添加记录器,并简化插入记录器语句的操作,即便记录器实例不在作用域内。
- 新检查与快速修复
新版本为 Java 实现了新的检查和快速修复,帮助用户保持代码整洁无误。
比如,IDE 现在会检测可被替换为对 Long.hashCode() 或 Double.hashCode() 方法的调用的按位操作。
此外,新的快速修复也可以根据代码库的要求简化隐式和显式类声明之间的切换。
另一项新检查为匹配代码段建议使用现有 static 方法,使代码可以轻松重用,而无需引入额外 API。此外,IDE现在可以检测并报告永远不会执行的无法访问的代码。
- 重构的 Conflicts Detected(检测到冲突)对话框
这次版本 2024.1 重构了 Conflicts Detected(检测到冲突)对话框以提高可读性。
现在,对话框中的代码反映了编辑器中的内容,使用户可以更清楚地了解冲突,并且 IDE 会自动保存窗口大小调整以供将来使用。
另外,这次还更新了按钮及其行为以简化重构工作流,对话框现在可以完全通过键盘访问,用户可以使用快捷键和箭头键进行无缝交互。
- Rename(重命名)重构嵌入提示
为了使重命名流程更简单、更直观,新版推出了一个新的嵌入提示,在更改的代码元素上显示。要将代码库中的所有引用更新为新版本,点击此提示并确认更改即可。
版本控制系统改进
- 编辑器内的代码审查
IntelliJ IDEA 2024.1 为 GitHub 和 GitLab 用户引入了增强的代码审查体验。
该功能与编辑器集成,以促进作者与审查者直接互动。在检查拉取/合并请求分支时,审查模式会自动激活,并在装订区域中显示粉色标记,表明代码更改可供审查。
点击这些标记会弹出一个显示原始代码的弹出窗口,这样用户就能快速识别哪些代码已被更改。
装订区域图标可以帮助用户迅速发起新讨论,以及查看和隐藏现有讨论。另外这些图标还可以让用户更方便地访问评论,从而更轻松地完成查看、回复等功能。
- Log(日志)标签页中显示审查分支更改的选项
新版通过提供分支相关更改的集中视图来简化了代码审查工作流。
对于 GitHub、GitLab 和 Space,用户现在可以在 Git 工具窗口中的单独 Log(日志)标签页中查看具体分支中的更改。用户可以点击 Pull Requests(拉取请求)工具窗口中的分支名称,然后从菜单中选择 Show in Git Log(在 Git 日志中显示)。
- 对代码审查评论回应的支持
新版开始支持对 GitHub 拉取请求和 GitLab 合并请求的审查评论发表回复,目前已有一组表情符号可供选择。
- 从推送通知创建拉取/合并请求
成功将更改推送到版本控制系统后,新版IDE将会发布一条通知,提醒用户已成功推送并建议创建拉取/合并请求的操作。
- 防止大文件提交到仓库
为了帮助用户避免由于文件过大而导致版本控制拒绝,新版IDE现在包含预提交检查,以防止用户提交此类文件并通知用户该限制。
构建工具改进
- 针对 Maven 项目的打开速度提升
新版 IDEA 现在通过解析 pom.xml 文件构建项目模型。这使得有效项目结构可以在几秒钟内获得,具有所有依赖项的完整项目模型则同时在后台构建,这样一来用户就无需等待完全同步即可开始处理项目。
- 从快速文档弹出窗口直接访问源文件
快速文档弹出窗口现在提供了一种下载源代码的简单方式。
现在当用户需要查看库或依赖项的文档并需要访问其源代码时,按 F1 即可。
更新后的弹出窗口将提供一个直接链接,用户可以使用它来下载所需的源文件,以简化工作流。
- Maven 工具窗口中的 Maven 仓库
Maven 仓库列表及其索引编制状态现在直接显示在 Maven 工具窗口中,而不是以前 Maven 设置中的位置。
- Gradle 版本支持更新
从这个新版本开始,IntelliJ IDEA 将不再支持使用低于 Gradle 版本 4.5 的项目,并且 IDE 不会对带有不支持的 Gradle 版本的项目执行 Gradle 同步。
运行/调试更新
- 多语句的内联断点
新版IDEA为在包含 lambda 函数或 return 语句的行中的断点设置提供了更方便的工作流。
点击装订区域设置断点后,IDE会自动显示可在其中设置额外断点的内联标记。每个断点都可以独立配置,释放高级调试功能。
- 条件语句覆盖
2024.1 新版使 IntelliJ IDEA 距离实现全面测试覆盖又近了一步。该项更新的重点是确定测试未完全覆盖代码中的哪些条件语句。
现在,IntelliJ IDEA 既显示哪一行具有未覆盖的条件,还会指定未覆盖的条件分支或变量值。 这项功能默认启用。
框架和技术
- 针对 Spring 的改进 Bean 补全和自动装配
IntelliJ IDEA Ultimate 现在为应用程序上下文中的所有 Bean 提供自动补全,并自动装配 Bean。
如果 Bean 通过构造函数自动装配依赖项,则相关字段也会通过构造函数自动装配。 同样,如果依赖项是通过字段或 Lombok 的 @RequiredArgsConstructor 注解注入,则新 Bean 会自动通过字段装配。
- 增强的 Spring 图表
新版的 Spring 模型图表更易访问。用户可以使用 Bean 行标记或对 Spring 类使用意图操作 (⌥⏎) 进行调用。
同时新版为 Spring 图表引入了新的图标,增强了 Spring 原型(如组件、控制器、仓库和配置 Bean)的可视化。 此外,用户现在可以方便地切换库中 Bean 的可见性(默认隐藏)。
除此之外,其他包括像数据库工具、其他框架、语言和技术的支持等方面的更新和说明,大家也可参阅jetbrains.com/zh-cn/idea/whatsnew。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7355389990531907636
身份认证的尽头竟然是无密码 ?
概述
几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。
HTTP 认证
HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。
基本认证
常见的叫法是 HTTP Basic,是一种对于安全性不高,以演示为目的的简单的认证机制(例如你家路由器的登录界面),客户端用户名和密码进行 Base64 编码(注意是编码,不是加密)后,放入 HTTP 请求的头中。服务器在接收到请求后,解码这个字段来验证用户的身份。示例:
GET /some-protected-resource HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==
虽然这种方式简单,但并不安全,因为 base64
编码很容易被解码。建议仅在 HTTPS 协议下使用,以确保安全性。
摘要认证
主要是为了解决 HTTP Basic 的安全问题,但是相对也更复杂一些,摘要认证使用 MD5 哈希函数对用户的密码进行加密,并结合一些盐值(可选)生成一个摘要值,然后将这个值放入请求头中。即使在传输过程中被截获,攻击者也无法直接从摘要中还原出用户的密码。示例:
GET /dir/index.html HTTP/1.1
Host: example.com
Authorization: Digest username="user", realm="example.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"
**补充:**另在 RFC 7235 规范中还定义当用户没有认证访问服务资源时应返回 401 Unauthorized
状态码,示例:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted Area"
这一规范目前应用在所有的身份认证流程中,并且沿用至今。
Web 认证
表单认证
虽然 HTTP 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:
- 前端通过表单收集用户的账号和密码
- 通过协商的方式发送服务端进行验证的方式。
常见的表单认证页面通常如下:
html>
<html>
<head>
<title>Login Pagetitle>
head>
<body>
<h2>Login Formh2>
<form action="/perform_login" method="post">
<div class="container">
<label for="username"><b>Usernameb>label>
<input type="text" placeholder="Enter Username" name="username" required>
<label for="password"><b>Passwordb>label>
<input type="password" placeholder="Enter Password" name="password" required>
<button type="submit">Loginbutton>
div>
form>
body>
html>
为什么表单认证会成为主流 ?主要有以下几点原因:
- 界面美化:开发者可以创建定制化的登录界面,可以与应用的整体设计风格保持一致。而 HTTP 认证通常会弹出一个很丑的模态对话框让用户输入凭证。
- 灵活性:可以在表单里面自定义更多的逻辑和流程,比如多因素认证、密码重置、记住我功能等。这些功能对于提高应用的安全性和便利性非常重要。
- 安全性:表单认证可以更容易地结合现代的安全实践,背后也有 OAuth 2 、Spring Security 等框架的主持。
表单认证传输内容和格式基本都是自定义本没啥规范可言。但是在 2019 年之后 web 认证开始发布标准的认证协议。
WebAuthn
WebAuthn 是一种彻底抛弃传统密码的认证,完全基于生物识别技术和实体密钥作为身份识别的凭证(有兴趣的小伙伴可以在 github 开启 Webauhtn 的 2FA 认证体验一下)。在 2019 年 3 月,W3C 正式发布了 WebAuthn 的第一版规范。
相比于传统的密码,WebAuthn 具有以下优势:
- 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。
- 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。
- 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。
总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。
实现效果
当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:
实现原理
WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:
登录流程大致可以分为以下步骤:
- 用户访问登录页面,填入用户名后即可点击登录按钮。
- 服务器返回随机字符串 Challenge、用户 UserID。
- 浏览器将 Challenge 和 UserID 转发给验证器。
- 验证器提示用户进行认证操作。
- 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。
WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;
备注:你可以通过访问 webauthn.me 了解到更多消息的信息
文章不适合加入过多的演示代码,想要手上体验的可以参考 okta 官方给出基于 Java 17 和 Maven 构建的 webauthn 示例程序,如下:
来源:juejin.cn/post/7354632375446061083
太惨了,凌晨4 点替别人修复bug……
差点翻车
前两个月的某天凌晨,我司全新的一个营销工具,在全国如期上线。然而整个发布过程并非一帆风顺,在线上环境全量发布后,有同事观测到他所负责模块的监控曲线有异常!监控曲线在发布的时刻近乎于直线下跌。
经过初步排查,故障影响是:一部分新用户无法使用营销优惠~ 影响面非常大,所幸在凌晨的业务低峰期,实际影响有限,但是需要快速修复!不然等天亮用户请求量上来了,故障影响和定级就更大了!
目前接近凌晨4 点,时间很紧张!虽然这部分内容并非我负责,但我是当天的现场值班人,必须上!肝!
屎海无涯
我喝了一口红牛,打开电脑就扎进了陌生代码的汪洋大海中……
看着看着,我察觉到味道不对劲。我觉得这部分代码不是汪洋大海,而是一片屎海…… 代码堆砌如屎山,单个方法竟超过500行;嵌套的if else结构深不可测;日志更是完全缺失;职责不但不单一,反而极度混乱。总之,整个代码简直如同一团乱麻,排查难度极大。
四五个同事一起在排查代码,虽然他们负责过这部分代码,但是大家都十分挠头,找不到 bug 在哪。
当局者迷,旁观者清。经过了30分钟的细致分析,终于,我率先找到了 bug 原因。激动地心颤抖的手,我开了 5 分钟的 bug 发布会,通报了 bug 根因和修复方案。
破案了!
确定 bug 根因后,其他人默默去休息了……
接下来我负责修 bug、测试、打包、发版、验证…… 不知不觉,天空破晓,一直搞到早上 8 点多…… 在线上完成验证,监控曲线恢复正常!bug 修复完成!
bug根因
由于公司代码保密,所以我使用伪代码解释。
业务逻辑是遍历所有的优惠活动,若任意一个优惠活动需要限制新用户使用,那么就需要去查询当前用户是否新用户。
bug 代码如下! (实际的屎山代码,比这部分代码要复杂得多!)
boolean newUserCheckEnabled = false;
for ( Activity activity : activityList ) {
newUserCheckEnabled = activity.isLimitNewUser();
}
想必大家一眼就能看出问题所在!这样写代码, newUserCheckEnabled 等于最后一个活动的值,如果最后一个活动不限制新用户使用,那么 newUserCheckEnabled 就是 false,然而中间的活动可能需要限制新用户,于是 bug 产生了!
老板亲自指导写代码
正确的代码应该这样写,我按照如下方式修复了 bug,但是老板对代码不满意!
boolean newUserCheckEnabled = false;
for ( Activity activity : activityList ) {
if (activity.isLimitNewUser()) {
newUserCheckEnabled = true;
}
}
”一行代码就能解决的事,不需要使用 if “ ,老板看完我的代码后,说道。
他给出的代码示例如下,使用 || 表达式
boolean newUserCheckEnabled = false;
for ( Activity activity : activityList ) {
newUserCheckEnabled = newUserCheckEnabled || activity.isLimitNewUser();
}
if 代码被替换如下!
newUserCheckEnabled = newUserCheckEnabled || activity.isLimitNewUser();
"这能行吗”? 我的大脑飞速运转…… 这两段代码等价吗?似乎等价,但不是十分确定……
老板面前,不能暴露自己没跟上节奏,否则暴露智商。
我假装立刻明白,于是吹了一句,“卧槽,牛逼,这样写确实更加简洁吖!👍🏻”。(大家觉得应该怎么拍马屁,更合适?)
私底下,我还在心里嘀咕,两者真的等价吗?
现在我可以肯定:确实是等价的!
来源:juejin.cn/post/7425875126527918130
旧Android手机改为个人服务器,不需要root
一、前言
随着手机更新换代的加速,每个人都有一些功能正常,但是闲置的手机,其实现在的手机都是ARM架构的,大多数手机内存还不小,相对于现在各大厂商提供的云服务器来讲,配置已经很不错了,所以这么好的资源能利用起来还是非常不错的~
二、工具介绍
目前能用的工具有很多,比如BusyBox、Linux Deploy、juice ssh、termux,但是很多都是需要手机能够root的,但是root并不是所有手机都能够简单获取到的,所以我这里选取Termux进行操作。
三、什么是Termux
Termux 是一款运行于 Android 系统的开源终端模拟器。提供了 Linux 环境,即使设备不具备 root 权限也可使用。通过自带的包管理器(Pacman、 APT),Termux 可以安装许多现代化的开发和系统维护工具,例如 zsh、Python、Ruby、NodeJS、MySQL 等软件。
四、开始改造
4.1 Termux安装
Termux下载:github.com/termux/term…
安装完成后,可以执行以下命令更新一下各软件包:
pkg update && pkg upgrade
4.2 安装openSSH
成功安装Termux之后,虽然手机是可以像服务器一样执行一些操作,但是毕竟手机管理配置起来没有PC方便,所以可以安装SSH服务,方便PC来远程操作。
# 安装openssh
pkg install openssh
# 默认端口为8022,修改端口
sshd -p 8888
# 启动ssh服务
sshd
4.3 远程连接SSH
要远程连接可以使用终端或者SSH客户端(如:PuTTY、Termius、XShell、MobaXterm等),使用以下命令连接到Termux服务。
ssh -p 8022 <username>@<device_ip>
username
在Android手机上使用Termux搭建服务器,并通过SSH让PC进行登录和操作时,**默认的用户名通常是u0_aXXX
,**可以通过以下方式获取到你的用户名是什么:
# 查询termux服务用户名
whoami
device_ip
通过以下命令获取手机的IP,这里的IP是局域网IP。
# 获取设备IP
ifconfig wlan0
连接时需要密码,由于termux服务默认密码为空,所以需要设置一个密码,具体方式如下:
# 切换管理员账户(如果有)
su
# 设置密码
passwd
五、注意点
5.1 保持服务在线
由于Termux是直接运行到Android手机上的,也是一个APP程序,所以需要注意Termux程序不要退出了。
5.2 内网服务
虽然经过上述方式已经实现了服务器的常规基础配置和操作功能,但是毕竟是在手机上的一个服务,也是受到网络环境限制的,因此如果要保证服务可用,需要保证手机和使用端在同意局域网内。
六、扩展
如果对手机作为网站服务器以及移动无线硬盘相关的内容,欢迎关注,后续会尽快分享相关方法。
来源:juejin.cn/post/7459816593230397494
小米正式官宣开源!杀疯了!
最近,和往常一样在刷 GitHub Trending 热榜时,突然看到又一个开源项目冲上了 Trending 榜单。
一天之内就狂揽数千 star,仅仅用两三天时间,star 数就迅速破万,增长曲线都快干垂直了!
出于好奇,点进去看了看。
好家伙,这居然还是小米开源的项目,相信不少小伙伴也刷到了。
这个项目名为:ha_xiaomi_home。
全称:Xiaomi Home Integration for Home Assistant。
原来这就是小米开源的 Home Assistant 米家集成,一个由小米官方提供支持的 Home Assistant 集成组件,它可以让用户在 Home Assistant 平台中使用和管理小米 IoT 智能设备。
Home Assistant 大家知道,这是一款开源的家庭自动化智能家居平台,以其开放性和兼容性著称,其允许用户将家中的智能设备集成到一个统一的系统中进行管理和控制,同时支持多种协议和平台。
通过 Home Assistant,用户可以轻松地实现智能家居的自动化控制,如智能灯光、智能安防、智能温控等,所以是不少智能家居爱好者的选择。
另外通过安装集成(Integration),用户可以在 Home Assistant 上实现家居设备的自动化场景创建,并且还提供了丰富的自定义功能,所以一直比较受 DIY 爱好者们的喜爱。
大家知道,小米在智能家居领域的战略布局一直还挺大的,IoT 平台的连接设备更是数以亿记,大到各种家电、电器,小到各种摄像头、灯光、开关、传感器,产品面铺得非常广。
那这次小米开源的这个所谓的米家集成组件,讲白了就是给 Home Assistant 提供官方角度的支持。
而这对于很多喜欢折腾智能家居或者 IoT 物联网设备的小伙伴来说,无疑也算是一个不错的消息。
ha_xiaomi_home 的安装方法有好几种,包括直接 clone 安装,借助 HACS 安装,或者通过 Samba 或 FTPS 来手动安装等。
但是官方是推荐直接使用 git clone 命令来下载并安装。
cd config
git clone https://github.com/XiaoMi/ha_xiaomi_home.git
cd ha_xiaomi_home
./install.sh /config
原因是,这样一来当用户想要更新至特定版本时,只需要切换相应 Tag 即可,这样会比较方便。
比如,想要更新米家集成版本至 v1.0.0,只需要如下操作即可。
cd config/ha_xiaomi_home
git checkout v1.0.0
./install.sh /config
安装完成之后就可以去 Home Assistant 的设置里面去添加集成了,然后使用小米账号登录即可。
其实在这次小米官方推出 Home Assistant 米家集成之前,市面上也有一些第三方的米家设备集成,但是多多少少会有一些不完美的地方,典型的比如设备状态响应延时,所以导致体验并不是最佳。
与这些第三方集成相比,小米这次新推出的官方米家集成无论是性能还是安全性都可以更期待一下。
如官方所言,Home Assistant 米家集成提供了官方的 OAuth 2.0 登录方式,并不会在 Home Assistant 中保存用户的账号密码,同时账号密码也不再需提供给第三方,因此也就避免了账号密码泄露的风险。
但是这里面仍然有一个问题需要注意,项目官方也说得很明确:虽说 Home Assistant 米家集成提供了 OAuth 的登录方式,但由于 Home Assistant 平台的限制,登录成功后,用户的小米用户信息(包括设备信息、证书、 token 等)会明文保存在 Home Assistant 的配置文件中。因此用户需要保管好自己的 Home Assistant 配置文件,确保不要泄露。
这个项目开源之后,在网上还是相当受欢迎的,当然讨论的声音也有很多。
小米作为一家商业公司,既然专门搞了这样一个开源项目来做 HA 米家集成,这对于他们来说不管是商业还是产品,肯定都是有利的。
不过话说回来,有了这样一个由官方推出的开源集成组件,不论是用户体验还是可玩性都会有所提升,这对于用户来说也未尝不是一件好事。
那关于这次小米官方开源的 Home Assistant 米家集成项目,大家怎么看呢?
来源:juejin.cn/post/7454170332712386572
悲惨!刚入职没几天,无意间把数据库删了,很尴尬,原因很奇葩
1. offer收割机,就职新公司
5年前的就业环境非常好,当时面试了很多家公司,收到了很多 offer。最终我决定入职一家互联网教育公司,新的公司福利非常好,各种零食随便吃,据说还能正点下班,一切都超出我的期望,“可算让我找着神仙公司了”,我的心里一阵窃喜。
在熟悉环境之后,我趁着上厕所的时候,顺便去旁边的零食摊挑了点零食。接下来的一天里,我专注地配置开发环境、阅读新人文档,当然我也不忘兼顾手边的零食。
入职几天后 ,领导给安排了一个小需求,我和同事沟通完技术方案后,就开始开发了。
2. 单元测试有点奇怪
完成开发后,我决定写个单元测试验证下,在研究单元测试代码后,我发现这种单测写法和我之前的写法不太一样。
这家公司的单测好像没有启动整个项目,仅加载了部分类,而且不能访问测试环境数据库~ 于是我决定按照前东家写单测的方式重新写单元测试。
于是我新增了一个单测基类,在单测中启动整个SpringBoot,直接访问测试环境数据库。然而也并不是很顺利,启动阶段总是会遇到各种异常报错,需要一个一个排查…… 所幸项目排期不紧张,还有充足时间。
我做梦也没有想到,此刻,已经铸成大错。
3. 故障现场
我身边的工位旁慢慢地聚集了越来越多的人,本来我还在安安静静的调试单元测试,注意力不自觉的被吸引了过去。
“测试环境为什么这么多异常,访问不通啊。到处都是 500 报错”,不知道谁在说话。
“嗯,我们还在排查,稍等一下”,我旁边的同事一边认真排查日志,一边轻声回复道。
“为什么数据库报的异常是, 查不到数据呢?” ,同事在小声嘀咕,然后打开 命令行,立即登上 MySQL。
我亲眼看着他在操作,奇怪的是数据库表里的数据全部被删掉了,其他的几个表数据也都被删除了。
简直太奇怪了,此刻的我还处于吃瓜心态。
有一个瞬间我在考虑,是否和我执行的单元测试有关系? 但我很快就否决掉了这个想法,因为我只是在调试单元测试,我没有删数据库啊,单测里也不可能删库啊。 我还在笑话自己 胡思乱想……
很快 DBA 就抱着电脑过来,指着电脑说,你们看这些日志,确实有人把这些表删除了。
"有 IP 吗,定位下是谁删除的, 另外线上环境有问题吗?”,旁边的大组长过来和 DBA 说。
“嗯,我找到ip 了,我找运维看下,这个ip是谁的”。DBA 回复道。
4. 庭审现场
当 DBA 找到我的时候,我感到无辜和无助,我懵逼了,我寻思我啥也没干啊,我怎么可能删库呢。 (他们知道我刚入职,我现在怀疑:那一刻他们可能会怀疑 我是友商派过来的卧底、间谍,执行删库的秘密任务)
经过一系列的掰扯和分析,最终定位 确实是我新增的单元测试把数据库删了。
5. 故障原因
需要明确的是,原单元测试执行时不会删除数据库;测试环境启动时也不会删除数据库。
只要在单元测试中连接测试数据库,就会删除掉数据库的所有数据。为什么呢?
5.1 为什么单元测试删除了所有数据?
原单元测试 使用的是 H2 内存数据库,即Java 开发的嵌入式(内存级别)数据库,它本身只是一个类库,也就是只有一个 jar 文件,可以直接嵌入到项目中。H2数据库又被称为内存数据库,因为它支持在内存中创建数据库和表。所以如果我们使用H2数据库的内存模式,那么我们创建的数据库和表都只是保存在内存中,一旦应用重启,那么内存中的数据库和表就不存在了。 所以非常适合用来做单元测试。
H2 数据库在启动阶段,需要执行用户指定的 SQL 脚本,脚本中一般包含表创建语句,用来构建需要使用的表。
但是我司的 SQL 脚本除了创建表语句,还包含了删除表语句。即在创建表之前先删除表。 为什么呢? 据他们说,是因为这个 SQL 脚本可能会重复执行,当重复执行时创建表语句 会报错。所以他们在创建表之前,先尝试删除表。这样确保 SQL 脚本可重复执行。( 其实可以用 Create if not exists )
故障的原因就是:测试数据库执行了这个删表再建表的 SQL 脚本,导致所有数据都被清除了。
5.2 为什么测试数据库会执行这条 SQL 脚本呢?
1) 我新建的单元测试把H2 内存数据库换成了测试数据库。
2) spring.data.initialize=默认值为 true; 默认情况下,会自动执行 sql 脚本。
所以测试数据库 执行了 SQL 脚本。
5.3 为什么在测试环境正常启动时,没有问题,不会删除所有数据呢?
只有单测引入测试数据库才会出问题,在测试环境正常启动项目是没问题的。
当编译项目时,测试目录下的文件、代码和正式代码编译后的结果不会放到一起。因为 SQL脚本被放在了 测试目录下, 所以正式代码在测试环境启动时,不会执行到这个 SQL脚本,自然不会有问题。
6. 深刻教训
最终数据被修复了,DBA有测试数据库的备份,然而快照并非实时的,不可避免地还是丢失了一部分数据。
所幸的是出问题的是测试环境,并非线上环境。 否则,我会不会被起诉,也未可知。
后续的改进措施包括
- 收回了数据库账户的部分权限,只有管理账户才可以修改数据库表结构。代码中执行 DML语句的账户不允许执行 DDL 语句。
- DBA 盘点测试数据库的快照能力,确保快照间隔足够短,另外新增一个调研课题:删库后如何快速恢复,参照下其他公司的方案。
- 所有的项目 spring.data.initialize 全部声明为 false。不自动执行 SQL 脚本
- SQL脚本一律不许出现 删除表的语句。SQL不能重复执行的问题,想其他办法解决。
- 另外的一个项目急需人手,把新来的那谁 调到其他项目上
这可能是程序员们在技术上越来越保守的原因……不经意的一个调整可能引发无法承受的滔天巨浪
来源:juejin.cn/post/7412490391935893541
用java做物品识别和姿态识别
前言
之前搞得语音识别突然发现浏览器就有接口可以直接用,而且识别又快又准,参考:使用 JavaScript 的 SpeechRecognition API 实现语音识别_speechrecognition js-CSDN博客
进入正题
这个功能首先要感谢一下作者常康,仓库地址(gitee.com/agriculture… 这个项目很早之前就关注了,最近这段时间正好要用才真正实践了一下,只是初步测试了一下,在性能方面还需要进一步测试,本人电脑就很拉识别就很卡。
先看效果
改动
主要对姿态识别做了一些小改动,将原图片识别改成视频视频识别,如果要调用摄像头将video.open(0);的代码注释放开即可
package cn.ck;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession;
import cn.ck.config.PEConfig;
import cn.ck.domain.KeyPoint;
import cn.ck.domain.PEResult;
import cn.ck.utils.Letterbox;
import nu.pattern.OpenCV;
import org.opencv.core.Mat;
import org.opencv.core.Point;
import org.opencv.core.Scalar;
import org.opencv.core.Size;
import org.opencv.highgui.HighGui;
import org.opencv.imgproc.Imgproc;
import org.opencv.videoio.VideoCapture;
import org.opencv.videoio.Videoio;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/*
* 姿态识别,可以识别动作等等.,比如跳绳技术
*/
public class PoseEstimation {
static {
// 加载opencv动态库
//System.load(ClassLoader.getSystemResource("lib/opencv_java470-无用.dll").getPath());
OpenCV.loadLocally();
}
public static void main(String[] args) throws OrtException {
String model_path = "src\main\resources\model\yolov7-w6-pose-nms.onnx";
// 加载ONNX模型
OrtEnvironment environment = OrtEnvironment.getEnvironment();
OrtSession.SessionOptions sessionOptions = new OrtSession.SessionOptions();
OrtSession session = environment.createSession(model_path, sessionOptions);
// 输出基本信息
session.getInputInfo().keySet().forEach(x -> {
try {
System.out.println("input name = " + x);
System.out.println(session.getInputInfo().get(x).getInfo().toString());
} catch (OrtException e) {
throw new RuntimeException(e);
}
});
VideoCapture video = new VideoCapture();
// video.open(0); //获取电脑上第0个摄像头
//可以把识别后的视频在通过rtmp转发到其他流媒体服务器,就可以远程预览视频后视频,需要使用ffmpeg将连续图片合成flv 等等,很简单。
if (!video.isOpened()) {
System.err.println("打开视频流失败,未检测到监控,请先用vlc软件测试链接是否可以播放!,下面试用默认测试视频进行预览效果!");
video.open("video/test2.mp4");
}
// 跳帧检测,一般设置为3,毫秒内视频画面变化是不大的,快了无意义,反而浪费性能
int detect_skip = 4;
// 跳帧计数
int detect_skip_index = 1;
// 最新一帧也就是上一帧推理结果
float[][] outputData = null;
//当前最新一帧。上一帧也可以暂存一下
Mat img = new Mat();
// 在这里先定义下线的粗细、关键的半径(按比例设置大小粗细比较好一些)
int minDwDh = Math.min((int)video.get(Videoio.CAP_PROP_FRAME_WIDTH), (int)video.get(Videoio.CAP_PROP_FRAME_HEIGHT));
int thickness = minDwDh / PEConfig.lineThicknessRatio;
int radius = minDwDh / PEConfig.dotRadiusRatio;
// 转换颜色空间
Mat image = new Mat();
// 图像预处理
Letterbox letterbox = new Letterbox();
letterbox.setNewShape(new Size(960, 960));
letterbox.setStride(64);
// 使用多线程和GPU可以提升帧率,线上项目必须多线程!!!,一个线程拉流,将图像存到[定长]队列或数组或者集合,一个线程模型推理,中间通过变量或者队列交换数据,代码示例仅仅使用单线程
while (video.read(img)) {
if ((detect_skip_index % detect_skip == 0) || outputData == null) {
Imgproc.cvtColor(img, image, Imgproc.COLOR_BGR2RGB);
image = letterbox.letterbox(image);
int rows = letterbox.getHeight();
int cols = letterbox.getWidth();
int channels = image.channels();
// 将图像转换为模型输入格式
float[] pixels = new float[channels * rows * cols];
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
double[] pixel = image.get(j, i);
for (int k = 0; k < channels; k++) {
pixels[rows * cols * k + j * cols + i] = (float) pixel[k] / 255.0f;
}
}
}
detect_skip_index = 1;
OnnxTensor tensor = OnnxTensor.createTensor(environment, FloatBuffer.wrap(pixels), new long[]{1L, (long) channels, (long) rows, (long) cols});
OrtSession.Result output = session.run(Collections.singletonMap(session.getInputInfo().keySet().iterator().next(), tensor));
// 处理输出结果并绘制
outputData = ((float[][]) output.get(0).getValue());
}else{
detect_skip_index = detect_skip_index + 1;
}
double ratio = letterbox.getRatio();
double dw =letterbox.getDw();
double dh = letterbox.getDh();
List<PEResult> peResults = new ArrayList<>();
for (float[] outputDatum : outputData) {
PEResult result = new PEResult(outputDatum);
if (result.getScore() > PEConfig.personScoreThreshold) {
peResults.add(result);
}
}
// 对结果进行非极大值抑制
peResults = nms(peResults, PEConfig.IoUThreshold);
for (PEResult peResult: peResults) {
System.out.println(peResult);
// 画框
Point topLeft = new Point((peResult.getX0()-dw)/ratio, (peResult.getY0()-dh)/ratio);
Point bottomRight = new Point((peResult.getX1()-dw)/ratio, (peResult.getY1()-dh)/ratio);
// Imgproc.rectangle(img, topLeft, bottomRight, new Scalar(255,0,0), thickness);
List<KeyPoint> keyPoints = peResult.getKeyPointList();
// 画点
keyPoints.forEach(keyPoint->{
if (keyPoint.getScore()>PEConfig.keyPointScoreThreshold) {
Point center = new Point((keyPoint.getX()-dw)/ratio, (keyPoint.getY()-dh)/ratio);
Scalar color = PEConfig.poseKptColor.get(keyPoint.getId());
Imgproc.circle(img, center, radius, color, -1); //-1表示实心
}
});
// 画线
for (int i = 0; i< PEConfig.skeleton.length; i++){
int indexPoint1 = PEConfig.skeleton[i][0]-1;
int indexPoint2 = PEConfig.skeleton[i][1]-1;
if ( keyPoints.get(indexPoint1).getScore()>PEConfig.keyPointScoreThreshold &&
keyPoints.get(indexPoint2).getScore()>PEConfig.keyPointScoreThreshold ) {
Scalar coler = PEConfig.poseLimbColor.get(i);
Point point1 = new Point(
(keyPoints.get(indexPoint1).getX()-dw)/ratio,
(keyPoints.get(indexPoint1).getY()-dh)/ratio
);
Point point2 = new Point(
(keyPoints.get(indexPoint2).getX()-dw)/ratio,
(keyPoints.get(indexPoint2).getY()-dh)/ratio
);
Imgproc.line(img, point1, point2, coler, thickness);
}
}
}
//服务器部署:由于服务器没有桌面,所以无法弹出画面预览,主要注释一下代码
HighGui.imshow("result", img);
// 多次按任意按键关闭弹窗画面,结束程序
if(HighGui.waitKey(1) != -1){
break;
}
}
HighGui.destroyAllWindows();
video.release();
System.exit(0);
}
public static List<PEResult> nms(List<PEResult> boxes, float iouThreshold) {
// 根据score从大到小对List进行排序
boxes.sort((b1, b2) -> Float.compare(b2.getScore(), b1.getScore()));
List<PEResult> resultList = new ArrayList<>();
for (int i = 0; i < boxes.size(); i++) {
PEResult box = boxes.get(i);
boolean keep = true;
// 从i+1开始,遍历之后的所有boxes,移除与box的IOU大于阈值的元素
for (int j = i + 1; j < boxes.size(); j++) {
PEResult otherBox = boxes.get(j);
float iou = getIntersectionOverUnion(box, otherBox);
if (iou > iouThreshold) {
keep = false;
break;
}
}
if (keep) {
resultList.add(box);
}
}
return resultList;
}
private static float getIntersectionOverUnion(PEResult box1, PEResult box2) {
float x1 = Math.max(box1.getX0(), box2.getX0());
float y1 = Math.max(box1.getY0(), box2.getY0());
float x2 = Math.min(box1.getX1(), box2.getX1());
float y2 = Math.min(box1.getY1(), box2.getY1());
float intersectionArea = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
float box1Area = (box1.getX1() - box1.getX0()) * (box1.getY1() - box1.getY0());
float box2Area = (box2.getX1() - box2.getX0()) * (box2.getY1() - box2.getY0());
float unionArea = box1Area + box2Area - intersectionArea;
return intersectionArea / unionArea;
}
}
姿态识别模型提取链接,
通过网盘分享的文件:yolov7-w6-pose-nms.onnx
链接: pan.baidu.com/s/1UdAUPWr1… 提取码: du6y
后言
就像原作者说的,不是每个同学都会python,不是每个项目都是python语言开发,不是每个岗位都会深度学习。
希望java在AI领域能有更好的发展
来源:juejin.cn/post/7413234304278970404
当我入手一台 MacBookPro 之后
从 13 年实习开始,开发环境从 Ubuntu 转战 MacOS,中间换了好几次电脑,每次都是直接用 Mac 自带的 Time Machine 来迁移数据,仅需一块移动硬盘或者一根 type c 线,经过一个晚上的数据迁移,第二天就可以用新电脑工作了,除了配置升级了,几乎感受不到换电脑的乐趣,并且升级过程中,也积累了不少系统升级的旧疾,这次从Intel芯片到 M3 Max 芯片,我打算从零开始,重新蒸腾一番,顺带更新一下工具库,说干就干,Go!
先介绍下新电脑的配置
- 太空黑:从经典的银色、到太空灰,这次体验一下太空黑
- 14 寸:用了大概 3 年的 14 寸,就一直用 15/16寸,因为这台不是用于办公,考虑携带方便,所以入手 14 寸(大屏幕肯定爽,但是在家主要也是外接显示器)
- M3 Max:想要体验一下本地大模型,直接入手 Max(找个借口🤐)
- 64G 内存:一直有在 macbook 上装虚拟机(Parallels Desktop)运行 Windows的习惯,升级了一下内存
- 2TB SSD:以前 512 的时候,由于各种 npm 包、docker 镜像,还是隔一段时间就要重启一下、硬盘清理等方式来释放空间,一步到位
后面还换过几台,从最开始的 touchbar ,蝶式键盘,再到取消 touchbar,这时候更多的是工作工具的更换,连拍照的激情都没有🥱🥱
开发工具
科学上网工具
作为开发,第一件事情是需要一个趁手的科学上网工具,不然类似下载 Google Chrome、安装 Homebrew 等基础的工具都会十分麻烦
我的科学上网工具,支持按照规则配置自动代理,同时也支持终端代理,以下是终端代理,这里不方便推荐具体工具
# 防止终端命令无法确定是否需要科学上网,不建议把这个命令持久化到 bashrc/zshrc,在需要时打开终端输入即可
export https_proxy=http://127.0.0.1:1235 http_proxy=http://127.0.0.1:1235 all_proxy=socks5://127.0.0.1:1234
Xcode
Xcode 命令行工具,许多开发工具和依赖所需的基础,运行一下命令,选择安装,稍等一会即可
xcode-select --install
Homebrew
通过 homebrew 来管理一些开发工具包,如 git、node等等,由于需要下载 github 地址,这里需要借助你的翻墙工具
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
然后按照提示,把命令加到 PATH
(echo; echo 'eval "$(/opt/homebrew/bin/brew shellenv)"') >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
Git/Tig
brew install git
# brew install git-gui
# brew install tig 个人是 vim 用户,偏向这种终端 gui
# 这里会自动安装 zsh 的自动补全工具,后续安装 zsh 可用
# /opt/homebrew/share/zsh/site-functions
安装 git 和 tig 都会默认新增 zsh 的补全方法,好吧,这是提醒我要立马安装 zsh
tigrc 可以用来自定义 tig 的一些配置和快捷键绑定
A sample of the default configuration has been installed to:
/opt/homebrew/opt/tig/share/tig/examples/tigrc
to override the system-wide default configuration, copy the sample to:
/opt/homebrew/etc/tigrc
zsh completions and functions have been installed to:
/opt/homebrew/share/zsh/site-functions
在任意已经初始化 git 的项目,打入 tig ,然后你就可以使用 vim 的方式来操作了 jk 等等
另外,使用 git 我还会额外安装两个 git 相关的小插件
一个是 tj 大神开发的 git-extras
brew install git-extras
# 添加到 ~/.zshrc
source /opt/homebrew/opt/git-extras/share/git-extras/git-extras-completion.zsh
详细的命令可查看文档,我比较常用了是 git summary、git undo、git setup
然后通过git 的 alias 来实现一个自定义的命令,git up 来实现每次切换到一个仓库时,有意思的更新一下最新代码
git config --global alias.up '!git remote update -p && git pull --rebase && git submodule update --init --recursive'
iTerm
实现通过 command + ecs 键,快速切换显示/隐藏 iTerm
- 设置默认终端
- 安装 shell integration
- 选配色:Solarized
- 安装 oh-my-zsh
sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
- 修改主题,配置插件等等
brew install zsh-syntax-highlighting
brew install zsh-autosuggestions
brew install autojump
brew install fzf
#ZSH_THEME="robbyrussell"
#ZSH_THEME="agnoster"
#ZSH_THEME="miloshadzic"
#ZSH_THEME="sunrise"
# ZSH_THEME="ys"
ZSH_THEME="gnzh"
plugins=(git ruby autojump osx rake rails lighthouse sublime)
plugins=(bgnotify)
plugins=(web-search)
plugins=(node)
source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
[ -f /opt/homebrew/etc/profile.d/autojump.sh ] && . /opt/homebrew/etc/profile.d/autojump.sh
source <(fzf --zsh)
这就起飞了!看看效果
Docker Desktop
作为开发,docker 技能必不可少,mac 下直接使用 docker desktop,可以省掉很多事情,特别是当你如果需要在本地跑 k8s 环境时,直接勾上 Enable Kubernetes 即可;另外新版本查看镜像,也可以扫描镜像每一层是否有安全漏洞,十分方便
VSCode
vscode 不仅适合前端开发,对于 Go、Rust 等开发者,整体体验都不错。安装后的第一件事,就是把 code 命令加到 Terminal
然后第二件事就是安装 github 的 Copilot 插件,开发、看源码现在是少不了它了
第三件事就是在 vscode 开启 vim 模式(安装 vim 插件)从 vim - sublime - vscode,一直保留 vim 的使用习惯,改不掉了😂
其他的就是各种高亮、开发辅助插件,大家按需安装即可
其他
- 前端
- NVM:node 版本管理
- pnpm:上一台电脑只有 512G,在动不动就几个 G 的前端项目,硬盘一直告警,至此只用 pnpm
- Go
- GVM: go 版本管理
- GoLand:虽然 vscode 可以开发 go,但是整体体验还是比不上收费的 goland
环境搭建可参考 Go + React 指北
效率工具
ChatGPT
说到效率工具,ChatGPT 绝对是提高效率神器,从日常问题到开发、图片生成、画图等等,哦,还可以帮忙挑西瓜🍉
对了,用了苹果芯片,ChatGPT app 直接通过 option + space 即可随时调出,支持实时对话、支持截屏问问题等等,好用程度大幅上升⬆️⬆️⬆️
Bartender($)
吐血推荐,让你状态栏更加一目了然
支持自定义显示哪些 icon,配置哪些 icon 始终不显示,哪些第二状态栏显示
快捷键切换第二状态栏,一下子清爽颜值高
iStat Menus($)
拥有时刻关注着网速、CPU使用率、内存使用率的强迫症,绝对不少
Yoink
当你要把一个文件从一个地方移到另外一个地方时,当你想快速复制一张图片时,剪切板记录、跨设备文件接力等等,这个小小的工具都能帮助你
有时候通过截屏软件截图,可以一次性把需要的截屏操作完,然后在剪贴板直接拖下来使用,十分方便!
BettersnapTool
一款小而美的工具,用来快速调整你的窗口,比如当前窗口在两个显示屏直接切换;全屏,左右分屏等等
iShot
本来我一直使用 Skitch 的,但是这次切换新电脑之后,发现它下架了,之前有朋友推荐了,也使用了一段时间,感觉很不错,除了普通的截图,还有长截图、带壳截图;还有其他的小工具,官方宣传是
截图、长截图、全屏带壳截图、贴图、标注、取色、录屏、录音、OCR、翻译一个顶十个,样样皆优秀!
Draw.io
好吧,这个绝对画图神器,日常写文档几乎离不开他,在线版直接打开即可使用,也可以安装 vscode 插件,不过我还是习惯下载一个 app,这样本地的文件,直接打开即可使用
距离成为架构师,你只差一个 draw.io
Parallels Desktop
如果你有使用 Windows 的诉求,那么我建议你花点钱买个 pd,融合模式一开,原来我的 16 年的机器,玩个魔兽、英雄联盟完全没问题
安装直接点击下载 Windows 11,网速好的话,10 来分钟就安装成功了
融合模式,应用和直接使用 mac 的应用没任何差别
全屏模式,可以看到截图的时候有一部分黑色,应该是没有兼容刘海屏的原因
Markdown 编辑器
Quiver,原来所有的笔记、文档基本都靠它来记录,21 年的时候作者停止维护了,再加上使用纯 markdown 工具,还需要自己找图床,最后都转到语雀、飞书等在线文档
中间还用过其他的、Mweb、Typora 等等,如果自己搭建图床,推荐使用 Typora

Mweb 包含PC 和 移动端,通过 iCloud 同步,也是十分方便!
图床工具
原来写 markdown 的时候,使用的是微博免费的图床,2 年后,然后发现图片都失效了!失效了!
所以,图床还是自己维护比较靠谱!
PicList,免费开源,我自己是购买了阿里云按量付费的 OSS,简单配置一下 aks,即可上传图片,配合 Typora,轻松完成写作
配置好之后,图片拖到 markdown 编辑框,即可实现自动上传
BreakTime 定时提醒工具
为了你的健康,你可以让电脑提醒你,每隔30分钟休息一下,倒杯水,看看风景
DaisyDisk (付费)磁盘空间,文件大小分析工具
作为只能买 256G 的屌丝,每天困扰我的一件事就是磁盘空间不足
现在我是 2T 了,可以不用了
也可以使用 腾讯柠檬用过一段时间,也很好用
微信输入法
搜狗输入法、RIME、百度输入法(作恶多端,还用)
上一次推荐,我还是使用搜狗输入法,有朋友推荐微信输入法,体验了一把,简洁、功能齐全,所以手机、PC 全部改用微信输入法
推荐跨设备复制黏贴,速度比苹果自带的快了许多
思维导图:Xmind, MindNode
脑图应用,一般在项目开发过程中用于 需求分解,Model Design 等等。
其他小应用
Chrome 插件推荐
- Vimium, 通过键盘快捷键操作网页,比如打开,关闭,查找书签等等
- FeHelper(前端助手):JSON自动格式化、手动格式化,支持排序、解码、下载等,更多功能可在配置页按需安装
- Axure RP Extension for Chrome
- Grammarly for Chrome,语法检查
- Octotree,github源码查看神器
- OneTab,节省高达95%的内存,并减轻标签页混乱现象
- Postman Interceptor
- React Developer Tools
- Redux DevTools
- Yet Another Drag and Go:超级拖拽.向四个方向拖拽文字即可进行相应的搜索.拖拽链接可在前台/后台,左侧/右侧打开
- 掘金
- Sider: ChatGPT 侧边栏 + GPT-4o, Claude 3.5, Gemini 1.5 & AI工具”的产品徽标图片 Sider: ChatGPT 侧边栏 + GPT-4o, Claude 3.5, Gemini 1.5 & AI工具
- xSwitch:前端开发代理神器,在线 debug 问题,把线上资源代理到本地,方便复现问题
来源:juejin.cn/post/7398351048777842729
MyBatis里面写模糊查询,like怎么用才对呢?
深入浅出:MyBatis中的模糊查询技巧
在数据库操作的世界里,模糊查询堪称是一项既基本又极其强大的功能。特别是在处理大量数据,需要根据某些不完全匹配的条件进行搜索时,模糊查询的价值就显得尤为重要。🔍 MyBatis作为一个广泛使用的持久层框架,为实现这一功能提供了便捷的途径。但不少开发者对其模糊查询的实现方式仍然感到困惑。本文将试图消除这种困惑,通过一步步的解析,带领大家正确使用MyBatis进行模糊查询。
引言
简述模糊查询在数据处理中的重要性
模糊查询是数据库操作中不可或缺的一部分,尤其在处理文本数据时,它能够根据不完全或模糊的条件,帮助开发者快速定位并检索出所需的数据行。例如,在一个拥有成千上万用户信息的系统中,通过模糊查询姓名或地址,能够高效地筛选出符合条件的信息。🚀
为什么要掌握MyBatis中的模犹如查询技术
掌握MyBatis中的模糊查询,可以使数据库操作更加灵活高效。对于已经选择MyBatis作为数据层框架的项目,能准确运用模糊查询,意味着能在保持代码的可维护性和清晰结构的同时,实现强大的数据检索功能。
模犹如查询基础
SQL中的LIKE语句
在SQL中,LIKE
语句是实现模糊查询的关键。它通常与%
(表示任意多个字符)和_
(表示一个任意字符)这两个通配符一起使用。例如:
%apple%
:匹配任何包含"apple"的字符串。_apple%
:匹配以任意字符开头,后面跟着"apple"的字符串。
LIKE语句的常见使用模式
基于LIKE
语句的模糊查询可以有多种不同的用法,选择合适的模式可以大幅提升查询的效率和准确度。
MyBatis简介
MyBatis的核心功能
MyBatis是一种半ORM(对象关系映射)框架,它桥接了Java对象和数据库之间的映射,通过XML或注解的方式,将SQL语句与Java方法关联起来,从而简化了数据操作层的代码。
如何在MyBatis中配置和使用Mapper
在MyBatis中,Mapper的配置主要通过Mapper.xml文件进行。每一个Mapper.xml文件都对应一个Mapper接口,文件中定义了与接口方法相对应的SQL语句。使用Mapper非常简单,只需在相关的Service层中引入Mapper接口,MyBatis框架会自动代理这些接口,使得调用数据库操作像调用Java方法一样简单。
MyBatis中的模犹如查询实现
MyBatis中LIKE语句的基本用法
在Mapper.xml中使用#{variable}占位符
<select id="findUserByName" resultType="com.example.User">
SELECT * FROM users WHERE name LIKE CONCAT('%', #{name}, '%')
</select>
此处使用了CONCAT
函数和#{variable}
占位符,动态地将输入的变量与%
通配符结合起来,实现了基本的模犹如查询功能。
使用${variable}拼接SQL
虽然使用${variable}
进行SQL拼接能提供更灵活的查询方法,但需要谨慎使用,以避免SQL注入风险。
<select id="findUserByName" resultType="com.example.User">
SELECT * FROM users WHERE name LIKE '%${name}%'
</select>
动态SQL与模犹如查询
<if>
标签的使用
<select id="findUserByCondition" parameterType="map" resultType="com.example.User">
SELECT * FROM users
<where>
<if test="name != null">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
... // 更多条件
</where>
</select>
<choose>
、<when>
、<otherwise>
的结合使用
<select id="findUserByDynamicCondition" parameterType="map" resultType="com.example.User">
SELECT * FROM users
<where>
<choose>
<when test="name != null">
AND name LIKE CONCAT('%', #{name}, '%')
</when>
<when test="email != null">
AND email LIKE CONCAT('%', #{email}, '%')
</when>
<otherwise>
AND id > 0 // 默认条件
</otherwise>
</choose>
</where>
</select>
实践案例
假设我们有一个用户管理系统,需要根据用户的姓名进行模糊查询。
场景描述
在用户管理系统中,后台需要根据前端传来的姓名关键字,模糊匹配数据库中的用户姓名,返回匹配的用户列表。
代码实现
Mapper接口定义
public interface UserMapper {
List<User> findUserByName(String name);
}
Mapper.xml配合LIKE的具体写法
<select id="findUserByName" resultType="com.example.User">
SELECT * FROM users WHERE name LIKE CONCAT('%', #{name}, '%')
</select>
结果验证
调用findUserByName
方法,传入关键字,即可得到所有姓名中包含该关键字的用户数据。
高级技巧与最佳实践
使用trim
标签优化LIKE查询
<select id="findUserByName" parameterType="string" resultType="com.example.User">
SELECT * FROM users
WHERE name LIKE
<trim prefix="%" suffix="%" prefixOverrides="%" suffixOverrides="%">
#{name}
</trim>
</select>
小技巧:避免模糊查询带来的性能问题
尽量避免以%
开头的模糊查询,因为这会使数据库全表扫描,极大地影响查询性能。
安全性考虑:防止SQL注入
在使用${}
进行SQL拼接时,一定要确保变量来源可控或已做过适当校验,防止SQL注入攻击。
总结与展望
虽然模糊查询在数据库操作中极其有用,但它也不是万能的。在使用MyBatis实现模糊查询时,既要考虑到其便捷性和灵活性,也不能忽视潜在的性能和安全风险。我们希望通过本文,你能更准确、更高效地使用MyBatis进行模糊查询。
未来随着技术的发展,MyBatis和相关的数据库技术仍将不断进化,但基本的原则和最佳实践应该是不变的。掌握这些,将能使你在使用MyBatis进行数据库操作时更加得心应手。
附录
欲了解更多MyBatis的高级功能和最佳实践,可以参考:
- MyBatis官方文档
- 相关技术社区和论坛
Q&A环节:如果你有任何关于MyBatis模糊查询的问题,欢迎在评论区留言交流。📢
希望本文能帮助你更好地理解和使用MyBatis进行模糊查询,欢迎分享和交流你的经验!🚀
来源:juejin.cn/post/7343225969237671972
面试官:limit 100w,10为什么慢?如何优化?
在 MySQL 中,limit X,Y 的查询中,X 值越大,那么查询速度也就越慢,例如以下示例:
- limit 0,10:查询时间大概在 20 毫秒左右。
- limit 1000000,10:查询时间可能是 15 秒左右(1秒等于 1000 毫秒),甚至更长时间。
所以,可以看出,limit 中 X 值越大,那么查询速度都越慢。
这个问题呢其实就是 MySQL 中典型的深度分页问题。那问题来了,为什么 limit 越往后查询越慢?如何优化查询速度呢?
为什么limit越来越慢?
在数据库查询中,当使用 LIMIT x, y 分页查询时,如果 x 值越大,查询速度可能会变慢。这主要是因为数据库需要扫描和跳过 x 条记录才能返回 y 条结果。随着 x 的增加,需要扫描和跳过的记录数也增加,从而导致性能下降。
例如 limit 1000000,10 需要扫描 1000010 行数据,然后丢掉前面的 1000000 行记录,所以查询速度就会很慢。
优化手段
对于 MySQL 深度分页比较典型的优化手段有以下两种:
- 起始 ID 定位法:使用最后查询的 ID 作为起始查询的 ID。
- 索引覆盖+子查询。
1.起始ID定位法
起始 ID 定位法指的是 limit 查询时,指定起始 ID。而这个起始 ID 是上一次查询的最后一条 ID。例如上一次查询的最后一条数据的 ID 为 6800000,那我们就从 6800001 开始扫描表,直接跳过前面的 6800000 条数据,这样查询的效率就高了,具体实现 SQL 如下:
select name, age, gender
from person
where id > 6800000 -- 核心实现 SQL
order by id limit 10;
其中 id 字段为表的主键字段。
为什么起始ID查询效率高呢?
因此这种查询是以上一次查询的最后 ID 作为起始 ID 进行查询的,而上次的 ID 已经定位到具体的位置了,所以只需要遍历 B+ 树叶子节点的双向链表(主键索引的底层数据结构)就可以查询到后面的数据了,所以查询效率就比较高,如下图所示:
如果上次查询结果为 9,之后再查询时,只需要从 9 之后再遍历 N 条数据就能查询出结果了,所以效率就很高。
优缺点分析
这种查询方式,只适合一页一页的数据查询,例如手机 APP 中刷新闻时那种瀑布流方式。
但如果用户是跳着分页的,例如查询完第 1 页之后,直接查询第 250 页,那么这种实现方式就不行了。
2.索引覆盖+子查询
此时我们为了查询效率,可以使用索引覆盖加子查询的方式,具体实现如下。
假设,我们未优化前的 SQL 如下:
select name, age, gender
from person
order by createtime desc
limit 1000000,10;
在以上 SQL 中,createtime 字段创建了索引,但查询效率依然很慢,因为它要取出 100w 完整的数据,并需要读取大量的索引页,和进行频繁的回表查询,所以执行效率会很低。
此时,我们可以做以下优化:
SELECT p1.name, p1.age, p1.gender
FROM person p1
JOIN (
SELECT id FROM person ORDER BY createtime desc LIMIT 1000000, 10
) AS p2 ON p1.id = p2.id;
相比于优化前的 SQL,优化后的 SQL 将不需要频繁回表查询了,因为子查询中只查询主键 ID,这时可以使用索引覆盖来实现。那么子查询就可以先查询出一小部分主键 ID,再进行查询,这样就可以大大提升查询的效率了。
索引覆盖(Index Coverage)是一种数据库查询优化技术,它指的是在执行查询时,数据库引擎可以直接从索引中获取所有需要的数据,而不需要再回表(访问主键索引或者表中的实际数据行)来获取额外的信息。这种方式可以减少磁盘 I/O 操作,从而提高查询性能。
课后思考
你还知道哪些深度分页的优化手段呢?欢迎评论区留下你的答案。
本文已收录到我的面试小站 http://www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。
来源:juejin.cn/post/7410987368343765046
SpringBoot引入Flyway
1 缘起与目的
最近遇到一个项目要部署到很多不同的地方,在每个地方升级时如何管理数据库升级脚本就成了一个叩待解决的问题。本文引入flyway工具来解决这个问题。
2 依赖
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>7.15.0</version>
</dependency>
此处笔者MySQL版本为5.7,上述版本依赖可生效。此处踩坑过程见踩坑记录。
3 yml
spring:
# flyway 配置
flyway:
# 启用或禁用 flyway
enabled: false
# flyway 的 clean 命令会删除指定 schema 下的所有 table, 生产务必禁掉。这个默认值是 false 理论上作为默认配置是不科学的。
clean-disabled: true
# SQL 脚本的目录,多个路径使用逗号分隔 默认值 classpath:db/migration {vendor}对应数据库类型,可选值 https://github.com/spring-projects/spring-boot/blob/v2.3.3.RELEASE/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DatabaseDriver.java
locations: classpath:sql/{vendor}
# metadata 版本控制信息表 默认 flyway_schema_history
table: flyway_schema_history
# 如果没有 flyway_schema_history 这个 metadata 表, 在执行 flyway migrate 命令之前, 必须先执行 flyway baseline 命令
# 设置为 true 后 flyway 将在需要 baseline 的时候, 自动执行一次 baseline。
baseline-on-migrate: false
# 指定 baseline 的版本号,默认值为 1, 低于该版本号的 SQL 文件, migrate 时会被忽略
baseline-version: 1
# 字符编码 默认 UTF-8
encoding: UTF-8
# 是否允许不按顺序迁移 开发建议 true 生产建议 false
out-of-order: false
# 执行迁移时是否自动调用验证 当你的 版本不符合逻辑 比如 你先执行了 DML 而没有 对应的DDL 会抛出异常
validate-on-migrate: true
4 表结构
配好后依赖和yml直接启动项目会自动创建表结构。
值得一说的是checksum。可以理解为校验字符串,每次执行完sql脚本后会针对脚本生成checknum,后续如果之前执行过的脚本出现改动与前面的checknum不一致会直接报错。
4 脚本命名
命名规则如下:
V版本号__版本名.sql
例如: V2.1.5__create_user_ddl.sql
、V4.1_2__add_user_dml.sql
因为配置的baseline-version=1,所以只有1以上版本才会被执行,上图V0.0.1__base.sql是不会被执行的。上图只是为了展示命名规则。
5 踩坑记录
5.1 Unsupported Database: MySQL 5.7
笔者最开始的依赖如下:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
报错如下:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'asyncBeanPriorityLoadPostProcessor' defined in class path resource [io/github/linyimin0812/async/AsyncBeanAutoConfiguration.class]: Initialization of bean failed; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'efpxInitQuartzJob': Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'sysJobServiceImpl' defined in file [E:\java\project\pm2\pm_modularity\efp-plugins\target\classes\com\sdecloud\modules\quartz\service\impl\SysJobServiceImpl.class]: Unsatisfied dependency expressed through constructor parameter 1; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'flywayInitializer' defined in class path resource [org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration$FlywayConfiguration.class]: Invocation of init method failed; nested exception is org.flywaydb.core.api.FlywayException: Unsupported Database: MySQL 5.7
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:628)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:542)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:335)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
此处笔者检索到了互联网文章 【原创】Flyway 8.2.1及以后版本不再支持MySQL?!_unsupported database: mysql 8.0-CSDN博客,阅读后笔者表示???还是去官网一探究竟吧。
- 通过官网(documentation.red-gate.com/flyway/flyw… 对MySQL支持的说明,修改依赖如下:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
结果依然报错如下
MySQL 5.7 is no longer supported by Flyway Community Edition, but still supported by Flyway Teams Edition.
- 通过stack overflow(stackoverflow.com/questions/7… )查询发现:
(1)Flyway Community Edition 8.0.0-beta1放弃了对5年以上数据库的支持,包括MySQL 5.7
在这次提交中,MySQL的最低支持版本从5.7增加到8.0,这是在Flyway 8.0.0-beta1中引入的。目前,支持MySQL 5.7的最新社区版本是Flyway 7.15.0。
(2)从Flyway第10版(2023年10月)起,此限制不再有效。我们已经更新了Flyway,使其适用于所有支持的数据库版本,因此如果您升级到版本10,您可以访问所有支持的MySQL版本。
笔者回退到7.15.0后再无报错。即最终依赖为标题1所示。
来源:juejin.cn/post/7330463614954209334
比Spring参数校验更优雅!使用函数式编程把参数检验玩出花来!
比Spring参数校验更优雅!使用函数式编程把参数检验玩出花来!
未经允许禁止转载!
使用 Vavr 验证库来替代标准的 Java Bean Validation(如 @NotBlank
, @Size
等注解)可以通过函数式的方式来处理验证逻辑。Vavr 是一个支持不可变数据结构和函数式编程的库,可以让代码更加简洁和函数式。
要使用 Vavr 的验证器,我们可以利用 Vavr 下Validation
类,它提供了一种函数式的方式来处理验证,允许收集多个错误,而不仅仅是遇到第一个错误就终止。
1. BeanValidator 实现的问题
以下是使用BeanValidator实现参数校验的代码:
@Data
public class User {
// bean validator 使用注解实现参数校验
@NotBlank(message = "用户姓名不能为空")
private String name;
@NotBlank(message = "密码不能为空")
@Size(min = 6, message = "密码长度不能少于6位")
private String password;
@Min(value = 0, message = "年龄不能小于0岁")
@Max(value = 150, message = "年龄不应超过150岁")
private Integer age;
@Pattern(regexp = "^((13[0-9])|(15[^4])|(18[0-9])|(17[0-9])|(147))\d{8}$", message = "手机号格式不正确")
private String phone;
}
Spring 提供了对 BeanValidator 的支持,可以在不同的层级(controller、service、repository)使用。
缺点:
- 要求被验证的对象是可变的 JavaBean(具有getter,setter方法),JavaBean是一种常见的反模式。
- 校验逻辑的复杂应用有很大的学习成本,比如自定义验证注解、分组校验等。
- 异常处理逻辑一般需要配合Spring全局异常处理。
最佳实践:
PlanA: 实践中建议仅在 controller 层面校验前端传入的 json 参数,不使用自定义注解,分组校验等复杂功能。
PlanB: 直接使用函数式验证。
2. 使用 Vavr 重新设计 User
类的验证逻辑
2.1 使用到的函数式思想:
- 校验结果视为值,返回结果为和类型,即异常结果或正常结果。这里的异常结果指的是校验失败的参数列表,正常结果指的是新创建的对象。
- 复用函数,这里具体指校验逻辑和构造器方法(或者静态方法创建对象)
- Applicative functor,本文不想讨论难以理解的函数式概念。这里可以简单理解成封装函数、同时支持 apply(map)的容器。
- 收集所有校验异常结果,此处的处理和提前返回(卫模式、短路操作)不同。
以下是使用 Vavr 中参数校验的代码:
PersonValidator personValidator = new PersonValidator();
// Valid(Person(John Doe, 30))
Validation<Seq<String>, Person> valid = personValidator.validatePerson("John Doe", 30);
// Invalid(List(Name contains invalid characters: '!4?', Age must be greater than 0))
Validation<Seq<String>, Person> invalid = personValidator.validatePerson("John? Doe!4", -1);
首先,需要定义一个验证器类,而不是直接在 User
类上使用注解。这个验证器类会对 User
的字段进行验证,并返回一个 Validation
对象。
2.2 验证器实现
// 使用实体类,这个类是无状态的
public class UserValidator {
// 验证用户
public Validation<Seq<String>, User> validateUser(String name, String password, Integer age, String phone) {
return Validation.combine(
validateName(name),
validatePassword(password),
validateAge(age),
validatePhone(phone))
.ap(User::new);
}
// 验证用户名
private Validation<String, String> validateName(String name) {
return (name == null || name.trim().isEmpty())
? Invalid("用户姓名不能为空")
: Valid(name);
}
// 验证密码
private Validation<String, String> validatePassword(String password) {
if (password == null || password.isEmpty()) {
return Invalid("密码不能为空");
}
if (password.length() < 6) {
return Invalid("密码长度不能少于6位");
}
return Valid(password);
}
// 验证年龄
private Validation<String, Integer> validateAge(Integer age) {
if (age == null) {
return Invalid("年龄不能为空");
}
if (age < 0) {
return Invalid("年龄不能小于0岁");
}
if (age > 150) {
return Invalid("年龄不应超过150岁");
}
return Valid(age);
}
// 验证手机号
private Validation<String, String> validatePhone(String phone) {
String phoneRegex = "^((13[0-9])|(15[^4])|(18[0-9])|(17[0-9])|(147))\\d{8}$";
if (phone == null || !phone.matches(phoneRegex)) {
return Invalid("手机号格式不正确");
}
return Valid(phone);
}
}
2.3 使用
public class UserValidationExample {
public static void main(String[] args) {
UserValidator validator = new UserValidator();
// 示例:测试一个有效用户
Validation<Seq<String>, User> validUser = validator.validateUser("Alice", "password123", 25, "13912345678");
if (validUser.isValid()) {
System.out.println("Valid user: " + validUser.get());
} else {
System.out.println("Validation errors: " + validUser.getError());
}
// 示例:测试一个无效用户
Validation<Seq<String>, User> invalidUser = validator.validateUser("", "123", -5, "12345");
if (invalidUser.isValid()) {
System.out.println("Valid user: " + invalidUser.get());
} else {
System.out.println("Validation errors: " + invalidUser.getError());
}
}
}
Validation.combine()
:将多个验证结果组合起来。每个验证返回的是Validation<String, T>
,其中String
是错误消息,T
是验证成功时的值。User::new
:这是一个方法引用,表示如果所有的字段都验证成功,就调用User
的构造函数创建一个新的User
对象。- 验证错误的收集:Vavr 的验证机制允许收集多个错误,而不是像传统 Java Bean Validation 那样一旦遇到错误就停止。这样,你可以返回所有的验证错误,让用户一次性修复。
2.4 结果示例
- 对于一个有效的用户:
Valid user: User(name=Alice, password=password123, age=25, phone=13912345678)
- 对于一个无效的用户:
Validation errors: List(用户姓名不能为空, 密码长度不能少于6位, 年龄不能小于0岁, 手机号格式不正确)
3. 源码解析
如果你仅关注使用的话,此段内容可以跳过。
此处仅分析其核心代码:
// Validation#combine 返回 Builder 类型
final class Builder<E, T1, T2> {
private Validation<E, T1> v1;
private Validation<E, T2> v2;
public <R> Validation<Seq<E>, R> ap(Function2<T1, T2, R> f) {
// 注意这里的执行顺序: v1#ap -> v2#ap
return v2.ap(v1.ap(Validation.valid(f.curried())));
}
}
f.curried
返回结果为 T1 => T2 => R,valid 方法使用 Validation 容器封装了函数:
// validation 为和类型,有且仅有两种实现
public interface Validation<E, T> extends Value<T>, Serializable {
static <E, T> Validation<E, T> valid(T value) {
return new Valid<>(value);
}
static <E, T> Validation<E, T> invalid(E error) {
Objects.requireNonNull(error, "error is null");
return new Invalid<>(error);
}
}
最关键的代码为 ap(apply的缩写):
default <U> Validation<Seq<E>, U> ap(Validation<Seq<E>, ? extends Function<? super T, ? extends U>> validation) {
Objects.requireNonNull(validation, "validation is null");
if (isValid()) {
if (validation.isValid()) {
// 正常处理逻辑
final Function<? super T, ? extends U> f = validation.get();
final U u = f.apply(this.get());
return valid(u);
} else {
// 保留原有的失败结果
final Seq<E> errors = validation.getError();
return invalid(errors);
}
} else {
if (validation.isValid()) {
// 初始化失败结果
final E error = this.getError();
return invalid(List.of(error));
} else {
// 校验失败,收集失败结果
final Seq<E> errors = validation.getError();
final E error = this.getError();
return invalid(errors.append(error));
}
}
}
这里的实现非常巧妙,柯里化的函数在正常处理逻辑中不断执行,最后调用成功,返回正确的函数结果。执行流程中有异常结果后,分成三中情况进行处理,分别是初始化,保留结果,进一步收集结果。
4. 总结与最佳实践
- 这种方式使用 Vavr 提供的函数式验证工具,使得验证逻辑更加简洁、灵活,并且可以收集多个错误进行统一处理,避免散弹枪问题。
- 对于需要返回单一错误的情况(实际上不多),也可以使用这种方法,然后取用任意一条结果。
- Validation支持多条无关参数的校验。当涉及到多参数的校验时,建议进行手动编码。
record Person(name, age) {}
static final String ADULT_CONTENT = "adult";
static final int ADULT_AGE = 18;
public Validation<Seq<String>, Person> validatePerson2(String name, int age) {
return Validation.combine(validateName(name), validateAge(age)).ap(Person::new)
.flatMap(this::validateAdult);
}
private Validation<Seq<String>, Person> validateAdult(Person p) {
return p.age < ADULT_AGE && p.name.contains(ADULT_CONTENT)
? Validation.invalid(API.List("Illegal name"))
: Validation.valid(p);
}
此外,对于某些参数传参,建议使用对象组合,比如range参数有两种做法,第一种可以传入 from, to, 校验条件为 from < to, 校验后对象包含属性Range,之后在额外校验中校验 Range;第二种可以限制传入参数为 Range。
来源:juejin.cn/post/7416605082688962610
shardingjdbc有点坑,数据库优化别再无脑回答分库分表了
故事背景
在八股文中,说到如何进行数据库的优化,除了基本的索引优化,经常会提到分库分表,说是如果业务量剧增,数据库性能会到达瓶颈,如果单表数据超过两千万,数据查询效率就会变低,就要引入分库分表巴拉巴拉。我同事也问我,我们数据表有些是上亿数据的,为什么不用分库分表,如果我没接触过分库分表我也会觉得大数据表就要分库分表呀,这是八股文一直以来教导的东西。但是我就跟他说,分库分表很坑爹,最近才让我遇到一个BUG......
系统复杂度upup
业务中有个设备表数据量很大,到现在为止已经有5、6亿数据了。在4年前,前人们已经尝试了分库分表技术,分了4个库,5个表,我只是负责维护这个业务发现他们用了分库分表。但是在查询表数据的时候看到是查询ES的,我就问为什么要用ES?同事回答查询分库分表一定要带分片才能走到路由,否则会查询全部库和全部表,意思是不查分片字段,单表只用一个SQL,但是分库分表要用20个SQL.....所以引入了ES进行数据查询。但是引入ES之后又引入一个新的问题,就是ES和数据库的数据同步问题。他们使用了logstash做数据同步,但不是实时的,在logstash设置了每20秒同步一次。

因为要使用分库分表,引入了shardingjdbc,因为查询方便引入了es,因为要处理数据同步问题引入了logstash......所以系统复杂度不是高了一点半点,之前发现有个字段长度设置小了,还要改20张表。
分页问题
最近遇到一个奇怪的bug,在一个设备的单表查询翻页失败,怎么翻都只显示第一页的数据,一开始我以为是分页代码有问题,看了半天跟其他表是一样的,其他表分页没问题,见鬼了。后面再细看发现这个单表的数据源是设备数据源,用的是shardingjdbc的配置。

之前就看过shardingjdbc有一些sql是不支持的,怀疑就是这个原因,百度了一下果然是有bug。

想了一下有两个解决办法,第一个是升级shardingjdbc的版本,据说是4.1之后修复了该问题,但是还没有尝试。
第二个办法是把分库分表业务的数据源跟单表区分开,单表业务使用普通的数据源后分页数据正常显示。
关于数据库优化
一般来说数据库优化,可以从几个角度进行优化:
1、硬件优化
(1) 提升存储性能
- 使用SSD:替换传统机械硬盘(HDD),SSD能提供更快的随机读写速度。
- 增加存储带宽:采用RAID(推荐RAID 10)提高数据存储的读写速度和冗余。
- 内存扩展:尽量让数据库缓存更多的数据,减少IO操作。
(2) 增强CPU性能
- 使用多核高频率CPU,支持更高并发。
- 分析数据库对CPU的利用情况,确保不被CPU性能瓶颈限制。
(3) 提高网络带宽
- 优化服务器与客户端之间的网络延迟和带宽,尤其是分布式数据库的场景中。
- 使用高速网络接口(如10GbE网卡)。
2、软件层面优化
(1) 数据库配置
- 调整数据库缓冲池(Buffer Pool)的大小,确保能缓存大部分热数据。
- 优化日志文件的写入(如MySQL中调整
innodb_log_buffer_size
)。 - 使用内存数据库或缓存技术(如Redis、Memcached)加速访问速度。
(2) 分布式架构
- 对于高并发需求,采用分布式数据库(如TiDB、MongoDB)进行读写分离或数据分片。
(3) 数据库索引
- 选择合适的索引类型:如B+树索引、哈希索引等,根据查询特点选择适配的索引。
- 避免冗余索引,定期清理无用索引。
(4) 数据库版本升级
- 保持数据库版本为最新的稳定版本,利用最新的优化特性和Bug修复。
3. SQL层面优化
(1) 查询优化
- 减少不必要的字段:只查询需要的列,避免使用
SELECT *
。 - 加速排序和分组:在
ORDER BY
和GR0UP BY
字段上建立索引。 - 拆分复杂查询:将复杂的SQL分解为多个简单查询或视图。
- 分页查询优化:如避免大OFFSET分页,可以使用索引条件替代(如
WHERE id > last_seen_id
)。
(2) 合理使用索引
- 对频繁用于WHERE、JOIN、GR0UP BY等的字段建立索引。
- 避免在索引列上使用函数或隐式转换。
(3) 减少锁定
- 尽量使用小事务,减少锁定范围。
- 使用合适的事务隔离级别,避免不必要的资源等待。
(4) SQL调优工具
- 使用数据库自带的分析工具(如MySQL的
EXPLAIN
、SQL Server的性能监控工具)来分析查询计划并优化执行路径。
4. 综合优化
- 定期进行性能分析:定期查看慢查询日志,优化慢查询。
- 清理历史数据:对于不再使用的历史数据,可存储到冷数据仓库,减少主数据库的负载。
- 使用连接池:通过数据库连接池(如HikariCP)管理和复用连接,降低创建和销毁连接的开销。
tips:
现网的数据库是64核128G内存,测试环境是32核64G,加上现网数据库配置的优化,现网数据库查询大表的速度是测试环境的3倍!所以服务器硬件配置和数据库配置都很重要。下面是数据库的配置文件,仅供参考
[universe]
bakupdir = /data/mysql/backup/7360
iops = 0
mem_limit_mb = 0
cpu_quota_percentage = 0
quota_limit_mb = 0
scsi_pr_level = 0
mycnf = /opt/mysql/etc/7360/my.cnf
run_user = actiontech-mysql
umask_dir = 0750
umask = 0640
id = mysql-mt1cbg
group_id = mysql-test
[mysql]
no-auto-rehash
prompt = '\\u@\\h:\\p\\R:\\m:\\s[\\d]> '
#default-character-set = utf8mb4
#tee = /data/mysql_tmp/mysql_operation.log
[mysqld]
super_read_only = 1
# DO NOT MODIFY, Universe will generate this part
port = 7360
server_id = 123
basedir = /opt/mysql/base/5.7.40
datadir = /data/mysql/data/7360
log_bin = /opt/mysql/log/binlog/7360/mysql-bin
tmpdir = /opt/mysql/tmp/7360
relay_log = /opt/mysql/log/relaylog/7360/mysql-relay
innodb_log_group_home_dir = /opt/mysql/log/redolog/7360
log_error = /data/mysql/data/7360/mysql-error.log
# 数据库ip
report_host = xxx
# BINLOG
binlog_error_action = ABORT_SERVER
binlog_format = row
binlog_rows_query_log_events = 1
log_slave_updates = 1
master_info_repository = TABLE
max_binlog_size = 250M
relay_log_info_repository = TABLE
relay_log_recovery = 1
sync_binlog = 1
# GTID #
gtid_mode = ON
enforce_gtid_consistency = 1
binlog_gtid_simple_recovery = 1
# ENGINE
default_storage_engine = InnoDB
innodb_buffer_pool_size = 64G
innodb_data_file_path = ibdata1:1G:autoextend
innodb_file_per_table = 1
innodb_flush_log_at_trx_commit = 1
innodb_flush_method = O_DIRECT
innodb_io_capacity = 1000
innodb_log_buffer_size = 64M
innodb_log_file_size = 2G
innodb_log_files_in_group = 2
innodb_max_dirty_pages_pct = 60
innodb_print_all_deadlocks = 1
#innodb_stats_on_metadata = 0
innodb_strict_mode = 1
#innodb_undo_logs = 128 #Deprecated In 5.7.19
#innodb_undo_tablespaces=3 #Deprecated In 5.7.21
innodb_max_undo_log_size = 4G
innodb_undo_log_truncate = 1
innodb_read_io_threads = 8
innodb_write_io_threads = 8
innodb_purge_threads = 4
innodb_buffer_pool_load_at_startup = 1
innodb_buffer_pool_dump_at_shutdown = 1
innodb_buffer_pool_dump_pct = 25
innodb_sort_buffer_size = 8M
#innodb_page_cleaners = 8
innodb_buffer_pool_instances = 8
innodb_lock_wait_timeout = 10
innodb_io_capacity_max = 2000
innodb_flush_neighbors = 1
#innodb_large_prefix = 1
innodb_thread_concurrency = 64
innodb_stats_persistent_sample_pages = 64
innodb_autoinc_lock_mode = 2
innodb_online_alter_log_max_size = 1G
innodb_open_files = 4096
innodb_temp_data_file_path = ibtmp1:12M:autoextend:max:50G
innodb_rollback_segments = 128
#innodb_numa_interleave = 1
# CACHE
key_buffer_size = 16M
tmp_table_size = 64M
max_heap_table_size = 64M
table_open_cache = 2000
query_cache_type = 0
query_cache_size = 0
max_connections = 3000
thread_cache_size = 200
open_files_limit = 65535
binlog_cache_size = 1M
join_buffer_size = 8M
sort_buffer_size = 2M
read_buffer_size = 8M
read_rnd_buffer_size = 8M
table_definition_cache = 2000
table_open_cache_instances = 8
# SLOW LOG
slow_query_log = 1
slow_query_log_file = /data/mysql/data/7360/mysql-slow.log
log_slow_admin_statements = 1
log_slow_slave_statements = 1
long_query_time = 1
# SEMISYNC #
plugin_load = "rpl_semi_sync_master=semisync_master.so;rpl_semi_sync_slave=semisync_slave.so"
rpl_semi_sync_master_enabled = 1
rpl_semi_sync_slave_enabled = 0
rpl_semi_sync_master_wait_for_slave_count = 1
rpl_semi_sync_master_wait_no_slave = 0
rpl_semi_sync_master_timeout = 30000
# CLIENT_DEPRECATE_EOF
session_track_schema = 1
session_track_state_change = 1
session_track_system_variables = '*'
# MISC
log_timestamps = SYSTEM
lower_case_table_names = 1
max_allowed_packet = 64M
read_only = 1
skip_external_locking = 1
skip_name_resolve = 1
skip_slave_start = 1
socket = /data/mysql/data/7360/mysqld.sock
pid_file = /data/mysql/data/7360/mysqld.pid
disabled_storage_engines = ARCHIVE,BLACKHOLE,EXAMPLE,FEDERATED,MEMORY,MERGE,NDB
log-output = TABLE,FILE
character_set_server = utf8mb4
secure_file_priv = ""
performance-schema-instrument = 'wait/lock/metadata/sql/mdl=ON'
performance-schema-instrument = 'memory/% = COUNTED'
expire_logs_days = 7
max_connect_errors = 1000000
interactive_timeout = 1800
wait_timeout = 1800
log_bin_trust_function_creators = 1
# MTS
slave-parallel-type = LOGICAL_CLOCK
slave_parallel_workers = 16
slave_preserve_commit_order = ON
slave_rows_search_algorithms = 'INDEX_SCAN,HASH_SCAN'
##BaseConfig
collation_server = utf8mb4_bin
explicit_defaults_for_timestamp = 1
transaction_isolation = READ-COMMITTED
##Unused
#plugin-load-add = validate_password.so
#validate_password_policy = MEDIUM
总结
如果我没用过分库分表,面试官问我数据库优化,我可能也会回答分库分表。但是踩过几个坑之后可能会推荐其他的方式。
1、按业务分表,比如用户表放在用户库,订单表放在订单库,用微服务的思想切割数据库减少数据库压力。
2、如果数据量超过10E,可以考虑上分布式数据库,融合了OLAP和OLTP的优点,毕竟mysql其实不适合做大数据量的查询统计。评论区也可以推荐一下有哪些好的数据库。
3、按时间归档数据表,每天或者每个月把历史数据存入历史数据表,适用于大数据量且历史数据查询较少的业务。
每个技术都有它的利弊,比如微服务、分库分表、分布式数据库等。按需选择技术类型,切勿过度设计!
来源:juejin.cn/post/7444014749321461811
Mybatis-Plus的insert执行之后,id是怎么获取的?
在日常开发中,会经常使用Mybatis-Plus
当简单的插入一条记录时,使用mapper的insert是比较简洁的写法
@Data
public class NoEo {
Long id;
String no;
}
NoEo noEo = new NoEo();
noEo.setNo("321");
noMapper.insert(noEo);
System.out.println(noEo);
这里可以注意到一个细节,就是不管我们使用的是什么类型的id,好像都不需要去setId,也能执行insert语句
不仅不需要setId,在insert语句执行完毕之后,我们还能通过实体类获取到这条insert的记录的id是什么
这背后的原理是什么呢?
自增类型ID
刚学Java的时候,插入了一条记录还要再select一次来获取这条记录的id,比较青涩
后面误打误撞才发现可以直接从insert的实体类中拿到这个id
难道框架是自己帮我查了一次嘛
先来看看自增id的情况
首先要先把yml中的mp的id类型设置为auto
mybatis-plus:
global-config:
db-config:
id-type: auto
然后从insert语句开始一直往下跟进
noMapper.insert(noEo);
后面会来到这个方法
// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}
在执行了下面这个方法之后
handler.update(stmt)
实体类的id就赋值上了
继续往下跟
// org.apache.ibatis.executor.statement.PreparedStatementHandler#update
@Override
public int update(Statement statement) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
int rows = ps.getUpdateCount();
Object parameterObject = boundSql.getParameterObject();
KeyGenerator keyGenerator = mappedStatement.getKeyGenerator();
keyGenerator.processAfter(executor, mappedStatement, ps, parameterObject);
return rows;
}
最后的赋值在这一行
keyGenerator.processAfter
可以看到会有一个KeyGenerator做一个后置增强,它具体的实现类是Jdbc3KeyGenerator
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processAfter
@Override
public void processAfter(Executor executor, MappedStatement ms, Statement stmt, Object parameter) {
processBatch(ms, stmt, parameter);
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processBatch
public void processBatch(MappedStatement ms, Statement stmt, Object parameter) {
final String[] keyProperties = ms.getKeyProperties();
if (keyProperties == null || keyProperties.length == 0) {
return;
}
try (ResultSet rs = stmt.getGeneratedKeys()) {
final ResultSetMetaData rsmd = rs.getMetaData();
final Configuration configuration = ms.getConfiguration();
if (rsmd.getColumnCount() < keyProperties.length) {
// Error?
} else {
assignKeys(configuration, rs, rsmd, keyProperties, parameter);
}
} catch (Exception e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e, e);
}
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeys
private void assignKeys(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd, String[] keyProperties,
Object parameter) throws SQLException {
if (parameter instanceof ParamMap || parameter instanceof StrictMap) {
// Multi-param or single param with @Param
assignKeysToParamMap(configuration, rs, rsmd, keyProperties, (Map<String, ?>) parameter);
} else if (parameter instanceof ArrayList && !((ArrayList<?>) parameter).isEmpty()
&& ((ArrayList<?>) parameter).get(0) instanceof ParamMap) {
// Multi-param or single param with @Param in batch operation
assignKeysToParamMapList(configuration, rs, rsmd, keyProperties, (ArrayList<ParamMap<?>>) parameter);
} else {
// Single param without @Param
// 当前case会走这里
assignKeysToParam(configuration, rs, rsmd, keyProperties, parameter);
}
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeysToParam
private void assignKeysToParam(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd,
String[] keyProperties, Object parameter) throws SQLException {
Collection<?> params = collectionize(parameter);
if (params.isEmpty()) {
return;
}
List<KeyAssigner> assignerList = new ArrayList<>();
for (int i = 0; i < keyProperties.length; i++) {
assignerList.add(new KeyAssigner(configuration, rsmd, i + 1, null, keyProperties[i]));
}
Iterator<?> iterator = params.iterator();
while (rs.next()) {
if (!iterator.hasNext()) {
throw new ExecutorException(String.format(MSG_TOO_MANY_KEYS, params.size()));
}
Object param = iterator.next();
assignerList.forEach(x -> x.assign(rs, param));
}
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator.KeyAssigner#assign
protected void assign(ResultSet rs, Object param) {
if (paramName != null) {
// If paramName is set, param is ParamMap
param = ((ParamMap<?>) param).get(paramName);
}
MetaObject metaParam = configuration.newMetaObject(param);
try {
if (typeHandler == null) {
if (metaParam.hasSetter(propertyName)) {
// 获取主键的类型
Class<?> propertyType = metaParam.getSetterType(propertyName);
// 获取主键类型处理器
typeHandler = typeHandlerRegistry.getTypeHandler(propertyType,
JdbcType.forCode(rsmd.getColumnType(columnPosition)));
} else {
throw new ExecutorException("No setter found for the keyProperty '" + propertyName + "' in '"
+ metaParam.getOriginalObject().getClass().getName() + "'.");
}
}
if (typeHandler == null) {
// Error?
} else {
// 获取主键的值
Object value = typeHandler.getResult(rs, columnPosition);
// 设置主键值
metaParam.setValue(propertyName, value);
}
} catch (SQLException e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e,
e);
}
}
// com.mysql.cj.jdbc.result.ResultSetImpl#getObject(int, java.lang.Class<T>)
@Override
public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
// ...
else if (type.equals(Long.class) || type.equals(Long.TYPE)) {
checkRowPos();
checkColumnBounds(columnIndex);
return (T) this.thisRow.getValue(columnIndex - 1, this.longValueFactory);
}
// ...
}
最后可以看到这个自增id是在ResultSet的thisRow里面
然后后面的流程就是去解析这个字节数据获取这个long的id
就不往下赘述了
雪花算法ID
yml切换回雪花算法
mybatis-plus:
global-config:
db-config:
id-type: assign_id
在使用雪花算法的时候,也是会走到这个方法
// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}
但是不同的是,执行完这一行之后,实体类的id字段就已经赋值上了
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
继续往下跟进
// org.apache.ibatis.session.Configuration#newStatementHandler
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
return statementHandler;
}
// org.apache.ibatis.executor.statement.RoutingStatementHandler#RoutingStatementHandler
public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
switch (ms.getStatementType()) {
// ...
case PREPARED:
delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
// ...
}
}
最后跟进到一个构造器,会有一个processParameter的方法
// com.baomidou.mybatisplus.core.MybatisParameterHandler#MybatisParameterHandler
public MybatisParameterHandler(MappedStatement mappedStatement, Object parameter, BoundSql boundSql) {
this.typeHandlerRegistry = mappedStatement.getConfiguration().getTypeHandlerRegistry();
this.mappedStatement = mappedStatement;
this.boundSql = boundSql;
this.configuration = mappedStatement.getConfiguration();
this.sqlCommandType = mappedStatement.getSqlCommandType();
this.parameterObject = processParameter(parameter);
}
在这个方法里面会去增强参数
// com.baomidou.mybatisplus.core.MybatisParameterHandler#processParameter
public Object processParameter(Object parameter) {
/* 只处理插入或更新操作 */
if (parameter != null
&& (SqlCommandType.INSERT == this.sqlCommandType || SqlCommandType.UPDATE == this.sqlCommandType)) {
//检查 parameterObject
if (ReflectionKit.isPrimitiveOrWrapper(parameter.getClass())
|| parameter.getClass() == String.class) {
return parameter;
}
Collection<Object> parameters = getParameters(parameter);
if (null != parameters) {
parameters.forEach(this::process);
} else {
process(parameter);
}
}
return parameter;
}
// com.baomidou.mybatisplus.core.MybatisParameterHandler#process
private void process(Object parameter) {
if (parameter != null) {
TableInfo tableInfo = null;
Object entity = parameter;
if (parameter instanceof Map) {
Map<?, ?> map = (Map<?, ?>) parameter;
if (map.containsKey(Constants.ENTITY)) {
Object et = map.get(Constants.ENTITY);
if (et != null) {
entity = et;
tableInfo = TableInfoHelper.getTableInfo(entity.getClass());
}
}
} else {
tableInfo = TableInfoHelper.getTableInfo(parameter.getClass());
}
if (tableInfo != null) {
//到这里就应该转换到实体参数对象了,因为填充和ID处理都是争对实体对象处理的,不用传递原参数对象下去.
MetaObject metaObject = this.configuration.newMetaObject(entity);
if (SqlCommandType.INSERT == this.sqlCommandType) {
populateKeys(tableInfo, metaObject, entity);
insertFill(metaObject, tableInfo);
} else {
updateFill(metaObject, tableInfo);
}
}
}
}
最终生成id并赋值的操作是在populateKeys中
// com.baomidou.mybatisplus.core.MybatisParameterHandler#populateKeys
protected void populateKeys(TableInfo tableInfo, MetaObject metaObject, Object entity) {
final IdType idType = tableInfo.getIdType();
final String keyProperty = tableInfo.getKeyProperty();
if (StringUtils.isNotBlank(keyProperty) && null != idType && idType.getKey() >= 3) {
final IdentifierGenerator identifierGenerator = GlobalConfigUtils.getGlobalConfig(this.configuration).getIdentifierGenerator();
Object idValue = metaObject.getValue(keyProperty);
if (StringUtils.checkValNull(idValue)) {
if (idType.getKey() == IdType.ASSIGN_ID.getKey()) {
if (Number.class.isAssignableFrom(tableInfo.getKeyType())) {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity));
} else {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity).toString());
}
} else if (idType.getKey() == IdType.ASSIGN_UUID.getKey()) {
metaObject.setValue(keyProperty, identifierGenerator.nextUUID(entity));
}
}
}
}
在tableInfo中可以得知Id的类型
如果是雪花算法类型,那么生成雪花id;UUID同理
总结
insert之后,id被赋值到实体类的时机要根据具体情况具体讨论:
如果是自增类型的id,那么要在插入数据库完成之后,在ResultSet的ByteArrayRow中获取到这个id
如果是雪花算法id,那么在在插入数据库之前,会通过参数增强的方式,提前生成一个雪花id,然后赋值给实体类
来源:juejin.cn/post/7319541656399102002
第一次排查 Java 内存泄漏,别人觉得惊险为什么我觉得脸红害羞呢
今天前端一直在群里说,服务是不是又挂了?一直返回 503。我一听这不对劲,赶紧看了一眼 K8S 的 pod 状态,居然重启了4次。测试环境只有一个副本,所以赶紧把副本数给上调到了3个。
堵住前端的嘴,免得破坏我在老板心目中的形象,我害怕下次加薪名单没有我,而优化名单有我。
暂时安抚好前端之后我得立马看看哪里出问题了,先看看 K8S 为什么让这个容器领盒饭了。
Last State: Terminated
Reason: OOMKilled
看起来是 JVM 胃口太大,被 K8S 嫌弃从而被赶走了。看看最近谁提交部署了,把人拉过来拷问一番。
代码摆出来分析,发现这小子每次使用http调用都会 new 一个连接池对象。一次业务请求使用了 6 次 http 调用,也就是会 new 6 个连接池对象。有可能是这里的问题,抓紧改了发上去测试看看。
不出意外的话又出意外了,上去之后也没缓解,那就不是这个问题了。要找到具体的原因还是不能瞎猜,得有专业的工具来进行分析才行。之前为了省点镜像空间,所以使用了 jre
的基础镜像。
总所周知,jre
只有一个运行环境,是没有开发工具的。所以我们得使用 jdk
。你说我为省那点空间干什么?都想抽自己了。我们应该以 "让打靶老板花钱"为荣,以 "为打靶老板省钱"为耻。
把JDK准备好之后,就要开始我的第一次了。开始之前总是需要洗白白的,把一些影响心情的东西全部处理掉,就像这个 Skywalking,之前一直跟着我。但现在影响到我了,我得暂时把它放一边。不然他会在进行的过程中一直蹦出来烦人。
使用 Skywalking
需要设置此环境变量,每一次执行Java相关的命令都会执行 Skywalking
的一些操作,可以使用 unset
命令把环境变量临时置空。因为等我做完还是需要他来继续给我工作的。
unset JAVA_TOOL_OPTIONS
琐碎事处理完了之后,就得挑个技师才行。这行命令一把梭就会打印出所有 java
进程信息,这主要是为了获取到 vmid
,也就是技师的编号。
jps -lv
root@xxx-ext-6464577d8-vvz2n:/app# jps -lv
608 sun.tools.jps.Jps -Denv.class.path=.:/usr/local/java/lib/rt.jar:/usr/local/java/lib/dt.jar:/usr/local/java/lib/tools.jar -Dapplication.home=/usr/local/openjdk-8 -Xms8m
7 /root/app/xxx-ext.jar -javaagent:/skywalking/agent/skywalking-agent.jar -Dfile.encoding=UTF-8 -Xms1024m -Xmx2048m
568 sun.tools.jstat.Jstat -javaagent:/skywalking/agent/skywalking-agent.jar -Denv.class.path=.:/usr/local/java/lib/rt.jar:/usr/local/java/lib/dt.jar:/usr/local/java/lib/tools.jar -Dapplication.home=/usr/local/openjdk-8 -Xms8m
这里总共查到3个Java进程,608 jps
、7 xxx-ext
和 568 jstat
。中间这个 7 号技师 xxx-ext
就是我相中的,我将会把第一次交给他。
选完技师就正式开始了,过程中要时刻关心对方的身体状态。隔几秒钟就问一下状态怎么样?为了方便时刻了解对方的身体状态,可以用这个命令每隔5s就问一下。如果你对自己的能力有信心可以把间隔设置短一些。
# jstat -gcutil {vmid} {间隔毫秒}
jstat -gcutil 7 5000
root@xxx-ext-6464577d8-vvz2n:/app# jstat -gcutil 7 5000
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
99.96 0.00 100.00 100.00 95.85 94.74 178 8.047 8 3.966 12.012
99.97 0.00 100.00 100.00 95.50 94.33 178 8.047 11 8.072 16.118
99.99 0.00 100.00 100.00 95.51 94.33 178 8.047 14 12.408 20.455
100.00 0.00 100.00 100.00 95.48 94.30 178 8.047 18 17.140 25.187
100.00 0.00 100.00 100.00 95.48 94.30 178 8.047 23 22.730 30.776
100.00 0.00 100.00 100.00 95.48 94.30 178 8.047 27 27.035 35.082
100.00 0.00 100.00 100.00 95.49 94.30 178 8.047 32 32.614 40.661
虽然是第一次,但对方给回来的信息务必要了然于胸。知己知彼胜券在握,所以要把下面的心法记住。这会影响我们下一步的动作。
S0/S1 是Survivor区空间使用率
E 是新生代空间使用率
O 是老年代空间使用率
YGC 是 Young GC 次数
YGCT 是 Young GC 总耗时
FGC 是 Full GC 次数
FGCT 是 Full GC 总耗时
当对方的状态到达一个关键点的时候,一般是老年代满,或者是新生代满,这就表示对方快溢出来了。像我提供的这个示例,E 和 O 的使用率都是100,就说明对方不仅满了,还快噶了。我们得赶紧把这个关键时刻详细探究一下,看看是哪个对象让对方感觉到满的。
用这个命令查询对方体内对象占用排名,不用贪多,前10个就绰绰有余了。你能把前10个全部弄清楚就够牛了。
jmap -histo:live 7 | head -n 10
root@xxx-ext-6464577d8-vvz2n:/app# jmap -histo:live 7 | head -n 10
num #instances #bytes class name
----------------------------------------------
1: 454962 1852234368 [C
2: 1773671 56757472 java.util.HashMap$Node
3: 881987 30188352 [B
4: 55036 19781352 [Ljava.util.HashMap$Node;
5: 857235 13715760 java.lang.Integer
6: 852094 13633504 com.knuddels.jtokkit.ByteArrayWrapper
7: 454195 10900680 java.lang.String
8: 104386 6436624 [Ljava.lang.Object;
9: 191593 6130976 java.util.concurrent.ConcurrentHashMap$Node
10: 63278 5568464 java.lang.reflect.Method
可以看到对方已经在边缘了,我们要抓紧分析了。我提供的这个示例,排名前三分别是 [C
、java.util.HashMap$Node
和 [B
,[C
表示字符数组,[B
表示字节数组。看来对方偏爱 [C
,占用差不多1.7G,需要重点分析它。
这一步就到了十字路口,关键点在于我们能不能从这里分析得到对方偏爱的对象,从而定位到代码中的问题点。一旦我们定位到代码中的问题点,那就证明对方已经被我们拿捏了,流程结束。
那就开始分析吧,先看看最近哪个瘪犊子提交了代码,把他拉过来。然后看最近改动的代码哪里和 [C
相关,一般是 List<String>
、StringBuffer
这类对象。
我没想到小丑竟是我自己🤡,有一个接口入参是一个 List<ID>
,当这个 list 传了空的时候,就会把库里的所有数据都查出来。
破案了,这次把对方完全拿捏了,流程结束。
如果上一步无法拿捏,那就不要讲武德了。把对方的一举一动dump下来,最终导出成堆快照来分析。
dump 时间取决于数据量
jmap -dump:live,format=b,file=heap.hprof 7
root@xxx-ext-6464577d8-vvz2n:/app# jmap -dump:live,format=b,file=heap.hprof 7
Dumping heap to /app/heap.hprof ...
Heap dump file created
将dump文件从pod中复制出来
kubectl cp <ns>/<pod>:/app/heap.hprof ./heap.hprof
kubectl cp test/xxx-ext-6464577d8-vvz2n:/app/heap.hprof ./heap.hprof
我摊牌了,这一步我压根没做。
当我想从pod中把对快照复制出来的时候磁盘空间不够,然后pod就被 K8S 这个暴脾气干了,只剩下我颤抖的手无力地放在键盘上。
Ref
来源:juejin.cn/post/7426189830562906149
SpringBoot 实战:文件上传之秒传、断点续传、分片上传
文件上传功能几乎是每个 Web 应用不可或缺的一部分。无论是个人博客中的图片上传,还是企业级应用中的文档管理,文件上传都扮演着至关重要的角色。今天,松哥和大家来聊聊文件上传中的几个高级玩法——秒传、断点续传和分片上传。
一 文件上传的常见场景
在日常开发中,文件上传的场景多种多样。比如,在线教育平台上的视频资源上传,社交平台上的图片分享,以及企业内部的知识文档管理等。这些场景对文件上传的要求也各不相同,有的追求速度,有的注重稳定性,还有的需要考虑文件大小和安全性。因此,针对不同需求,我们有了秒传、断点续传和分片上传等解决方案。
二 秒传、断点上传与分片上传
秒传
秒传,顾名思义,就是几乎瞬间完成文件上传的过程。其实现原理是通过计算文件的哈希值(如 MD5 或 SHA-1),然后将这个唯一的标识符发送给服务器。如果服务器上已经存在相同的文件,则直接返回成功信息,避免了重复上传。这种方式不仅节省了带宽,也大大提高了用户体验。
断点续传
断点续传是指在网络不稳定或者用户主动中断上传后,能够从上次中断的地方继续上传,而不需要重新开始整个过程。这对于大文件上传尤为重要,因为它可以有效防止因网络问题导致的上传失败,同时也能节约用户的流量和时间。
分片上传
分片上传则是将一个大文件分割成多个小块分别上传,最后再由服务器合并成完整的文件。这种做法的好处是可以并行处理多个小文件,提高上传效率;同时,如果某一部分上传失败,只需要重传这一部分,不影响其他部分。
三 秒传实战
后端实现
在 SpringBoot 项目中,我们可以使用 MessageDigest
类来计算文件的 MD5 值,然后检查数据库中是否存在该文件。
@RestController
@RequestMapping("/file")
public class FileController {
@Autowired
FileService fileService;
@PostMapping("/upload1")
public ResponseEntity<String> secondUpload(@RequestParam(value = "file",required = false) MultipartFile file,@RequestParam(required = false,value = "md5") String md5) {
try {
// 检查数据库中是否已存在该文件
if (fileService.existsByMd5(md5)) {
return ResponseEntity.ok("文件已存在");
}
// 保存文件到服务器
file.transferTo(new File("/path/to/save/" + file.getOriginalFilename()));
// 保存文件信息到数据库
fileService.save(new FileInfo(file.getOriginalFilename(), DigestUtils.md5DigestAsHex(file.getInputStream())));
return ResponseEntity.ok("上传成功");
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("上传失败");
}
}
}
前端调用
前端可以通过 JavaScript 的 FileReader API 读取文件内容,通过 spark-md5 计算 MD5 值,然后发送给后端进行校验。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>秒传</title>
<script src="spark-md5.js"></script>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="startUpload()">开始上传</button>
<hr>
<script>
async function startUpload() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert("请选择文件");
return;
}
const md5 = await calculateMd5(file);
const formData = new FormData();
formData.append('md5', md5);
const response = await fetch('/file/upload1', {
method: 'POST',
body: formData
});
const result = await response.text();
if (response.ok) {
if (result != "文件已存在") {
// 开始上传文件
}
} else {
console.error("上传失败: " + result);
}
}
function calculateMd5(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const spark = new SparkMD5.ArrayBuffer();
spark.append(reader.result);
resolve(spark.end());
};
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
</script>
</body>
</html>
前端分为两个步骤:
- 计算文件的 MD5 值,计算之后发送给服务端确定文件是否存在。
- 如果文件已经存在,则不需要继续上传文件;如果文件不存在,则开始上传文件,上传文件和 MD5 校验请求类似,上面的案例代码中我就没有重复演示了,松哥在书里和之前的课程里都多次讲过文件上传,这里不再啰嗦。
四 分片上传实战
分片上传关键是在前端对文件切片,比如一个 10MB 的文件切为 10 份,每份 1MB。每次上传的时候,需要多一个参数记录当前上传的文件切片的起始位置。
比如一个 10MB 的文件,切为 10 份,每份 1MB,那么:
- 第 0 片,从 0 开始,一共是
1024*1024
个字节。 - 第 1 片,从
1024*1024
开始,一共是1024*1024
个字节。 - 第 2 片...
把这个搞懂,后面的代码就好理解了。
后端实现
private static final String UPLOAD_DIR = System.getProperty("user.home") + "/uploads/";
/**
* 上传文件到指定位置
*
* @param file 上传的文件
* @param start 文件开始上传的位置
* @return ResponseEntity<String> 上传结果
*/
@PostMapping("/upload2")
public ResponseEntity<String> resumeUpload(@RequestParam("file") MultipartFile file, @RequestParam("start") long start,@RequestParam("fileName") String fileName) {
try {
File directory = new File(UPLOAD_DIR);
if (!directory.exists()) {
directory.mkdirs();
}
File targetFile = new File(UPLOAD_DIR + fileName);
RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "rw");
FileChannel channel = randomAccessFile.getChannel();
channel.position(start);
channel.transferFrom(file.getResource().readableChannel(), start, file.getSize());
channel.close();
randomAccessFile.close();
return ResponseEntity.ok("上传成功");
} catch (Exception e) {
System.out.println("上传失败: "+e.getMessage());
return ResponseEntity.status(500).body("上传失败");
}
}
后端每次处理的时候,需要先设置文件的起始位置。
前端调用
前端需要将文件切分成多个小块,然后依次上传。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分片示例</title>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="startUpload()">开始上传</button>
<script>
async function startUpload() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert("请选择文件");
return;
}
const filename = file.name;
let start = 0;
uploadFile(file, start);
}
async function uploadFile(file, start) {
const chunkSize = 1024 * 1024; // 每个分片1MB
const total = Math.ceil(file.size / chunkSize);
for (let i = 0; i < total; i++) {
const chunkStart = start + i * chunkSize;
const chunkEnd = Math.min(chunkStart + chunkSize, file.size);
const chunk = file.slice(chunkStart, chunkEnd);
const formData = new FormData();
formData.append('file', chunk);
formData.append('start', chunkStart);
formData.append('fileName', file.name);
const response = await fetch('/file/upload2', {
method: 'POST',
body: formData
});
const result = await response.text();
if (response.ok) {
console.log(`分片 ${i + 1}/${total} 上传成功`);
} else {
console.error(`分片 ${i + 1}/${total} 上传失败: ${result}`);
break;
}
}
}
</script>
</body>
</html>
五 断点续传实战
断点续传的技术原理类似于分片上传。
当文件已经上传了一部分之后,断了需要重新开始上传。
那么我们的思路是这样的:
- 前端先发送一个请求,检查要上传的文件在服务端是否已经存在,如果存在,目前大小是多少。
- 前端根据已经存在的大小,继续上传文件即可。
后端案例
先来看后端检查的接口,如下:
@GetMapping("/check")
public ResponseEntity<Long> checkFile(@RequestParam("filename") String filename) {
File file = new File(UPLOAD_DIR + filename);
if (file.exists()) {
return ResponseEntity.ok(file.length());
} else {
return ResponseEntity.ok(0L);
}
}
如果文件存在,则返回已经存在的文件大小。
如果文件不存在,则返回 0,表示前端从头开始上传该文件。
前端调用
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>断点续传示例</title>
</head>
<body>
<input type="file" id="fileInput"/>
<button onclick="startUpload()">开始上传</button>
<script>
async function startUpload() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert("请选择文件");
return;
}
const filename = file.name;
let start = await checkFile(filename);
uploadFile(file, start);
}
async function checkFile(filename) {
const response = await fetch(`/file/check?filename=${filename}`);
const start = await response.json();
return start;
}
async function uploadFile(file, start) {
const chunkSize = 1024 * 1024; // 每个分片1MB
const total = Math.ceil((file.size - start) / chunkSize);
for (let i = 0; i < total; i++) {
const chunkStart = start + i * chunkSize;
const chunkEnd = Math.min(chunkStart + chunkSize, file.size);
const chunk = file.slice(chunkStart, chunkEnd);
const formData = new FormData();
formData.append('file', chunk);
formData.append('start', chunkStart);
formData.append('fileName', file.name);
const response = await fetch('/file/upload2', {
method: 'POST',
body: formData
});
const result = await response.text();
if (response.ok) {
console.log(`分片 ${i + 1}/${total} 上传成功`);
} else {
console.error(`分片 ${i + 1}/${total} 上传失败: ${result}`);
break;
}
}
}
</script>
</body>
</html>
这个案例实际上是一个断点续传+分片上传的案例,相关知识点并不难,小伙伴们可以自行体会下。
六 总结
好了,以上就是关于文件上传中秒传、断点续传和分片上传的实战分享。通过这些技术的应用,我们可以极大地提升文件上传的效率和稳定性,改善用户体验。希望各位小伙伴在自己的项目中也能灵活运用这些技巧,解决实际问题。
本文完整案例:github.com/lenve/sprin…
来源:juejin.cn/post/7436026758438453274
MyBatis-Plus 效能提升秘籍:掌握这些注解,事半功倍!
MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。
一、@Tablename注解
这个注解用于指定实体类对应的数据库表名。如果你的表名和实体类名不一致,就需要用到它:
@TableName("user_info")
public class UserInfo {
// 类的属性和方法
}
在上述代码中,即使实体类名为UserInfo,但通过@TableName注解,我们知道它对应数据库中的"user_info"表。
二、@Tableld注解
每个数据库表都有主键,@TableId注解用于标识实体类中的主键属性。通常与@TableName配合使用,确保主键映射正确。
AUTO(0),
NONE(1),
INPUT(2),
ASSIGN_ID(3),
ASSIGN_UUID(4),
/** @deprecated */
@Deprecated
ID_WORKER(3),
/** @deprecated */
@Deprecated
ID_WORKER_STR(3),
/** @deprecated */
@Deprecated
UUID(4);
- INPUT 如果开发者没有手动赋值,则数据库通过自增的方式给主键赋值,如果开发者手动赋值,则存入该值。
- AUTO 默认就是数据库自增,开发者无需赋值。
- ASSIGN_ID MP 自动赋值,雪花算法。
- ASSIGN_UUID 主键的数据类型必须是 String,自动生成 UUID 进行赋值。
// 自己赋值
//@TableId(type = IdType.INPUT)
// 默认使用的雪花算法,长度比较长,所以使用Long类型,不用自己赋值
@TableId
private Long id;
测试
@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("天明");
student.setAge(18);
mapper.insert(student);
}
雪花算法
雪花算法是由Twitter公布的分布式主键生成算法,它能够保证不同表的主键的不重复性,以及相同表的主键的有序性。
核心思想:
- 长度共64bit(一个long型)。
- 首先是一个符号位,1bit标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。
- 41bit时间截(毫秒级),存储的是时间截的差值(当前时间截 - 开始时间截),结果约等于69.73年。
- 10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID,可以部署在1024个节点)。
- 12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID)。
优点: 整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞,并且效率较高。
三、@TableField注解
当你的实体类属性名与数据库字段名不一致时,@TableField注解可以帮助你建立二者之间的映射关系。
- 映射非主键字段,value 映射字段名;
- exist 表示是否为数据库字段 false,如果实体类中的成员变量在数据库中没有对应的字段,则可以使用 exist,VO、DTO;
- select 表示是否查询该字段;
- fill 表示是否自动填充,将对象存入数据库的时候,由 MyBatis Plus 自动给某些字段赋值,create_time、update_time。
自动填充
1)给表添加 create_time、update_time 字段。
2)实体类中添加成员变量。
package com.md.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;
@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
// 当该字段名称与数据库名字不一致
@TableField(value = "name")
private String name;
// 不查询该字段
@TableField(select = false)
private Integer age;
// 当数据库中没有该字段,就忽略
@TableField(exist = false)
private String gender;
// 第一次添加填充
@TableField(fill = FieldFill.INSERT)
private Date createTime;
// 第一次添加的时候填充,但之后每次更新也会进行填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
3)创建自动填充处理器。
注意:不要忘记添加 @Component 注解。
package com.md.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* @author md
* @Desc 对实体类中使用的自动填充注解进行编写
* @date 2020/10/26 17:29
*/
// 加入注解才能生效
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
}
@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}
4)测试
@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("韩立");
student.setAge(11);
// 时间自动填充
mapper.insert(student);
}
5)更新
当该字段发生变化的时候时间会自动更新。
@Test
void update(){
Student student = mapper.selectById(1001);
student.setName("韩信");
mapper.updateById(student);
}
四、@TableLogic注解
在很多应用中,数据并不是真的被删除,而是标记为已删除状态。@TableLogic注解用于标识逻辑删除字段,通常配合逻辑删除功能使用。
1、逻辑删除
物理删除: 真实删除,将对应数据从数据库中删除,之后查询不到此条被删除的数据。
逻辑删除: 假删除,将对应数据中代表是否被删除字段的状态修改为“被删除状态”,之后在数据库中仍旧能看到此条数据记录。
使用场景: 可以进行数据恢复。
2、实现逻辑删除
step1: 数据库中创建逻辑删除状态列。
step2: 实体类中添加逻辑删除属性。
@TableLogic
@TableField(value = "is_deleted")
private Integer deleted;
3、测试
测试删除: 删除功能被转变为更新功能。
-- 实际执行的SQL
update user set is_deleted=1 where id = 1 and is_deleted=0
测试查询: 被逻辑删除的数据默认不会被查询。
-- 实际执行的SQL
select id,name,is_deleted from user where is_deleted=0
你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可查看!
五、@Version注解
乐观锁是一种并发控制策略,@Version注解用于标识版本号字段,确保数据的一致性。
乐观锁
标记乐观锁,通过 version 字段来保证数据的安全性,当修改数据的时候,会以 version 作为条件,当条件成立的时候才会修改成功。
version = 2
- 线程1:update … set version = 2 where version = 1
- 线程2:update … set version = 2 where version = 1
1.数据库表添加 version 字段,默认值为 1。
2.实体类添加 version 成员变量,并且添加 @Version。
package com.md.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;
@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
@TableField(value = "name")
private String name;
@TableField(select = false)
private Integer age;
@TableField(exist = false)
private String gender;
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
@Version
private Integer version; //版本号
}
3.注册配置类
在 MybatisPlusConfig 中注册 Bean。
package com.md.config;
import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author md
* @Desc
* @date 2020/10/26 20:42
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 乐观锁
*/
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor(){
return new OptimisticLockerInterceptor();
}
}
六、@EnumValue注解
mp框架对枚举进行处理的一个注解。
使用场景: 创建枚举类,在需要存储数据库的属性上添加@EnumValue注解。
public enum SexEnum {
MAN(1, "男"),
WOMAN(2, "女");
@EnumValue
private Integer key;
}
MyBatis-Plus的注解是开发者的好帮手,它们简化了映射配置,提高了开发效率。希望以上的介绍能帮助新手朋友们快速理解和运用这些常用注解,让你们在MyBatis-Plus的世界里游刃有余!记得实践是最好的学习方式,快去动手试试吧!
来源:juejin.cn/post/7340471458949169215
Java 语法糖,你用过几个?
你好,我是猿java。
这篇文章,我们来聊聊 Java 语法糖。
什么是语法糖?
语法糖(Syntactic Sugar)是编程语言中的一种设计概念,它指的是在语法层面上对某些操作提供更简洁、更易读的表示方式。这种表示方式并不会新增语言的功能,而只是使代码更简洁、更直观,便于开发者理解和维护。
语法糖的作用:
- 提高代码可读性:语法糖可以使代码更加贴近自然语言或开发者的思维方式,从而更容易理解。
- 减少样板代码:语法糖可以减少重复的样板代码,使得开发者可以更专注于业务逻辑。
- 降低出错率:简化的语法可以减少代码量,从而降低出错的概率。
因此,语法糖不是 Java 语言特有的,它是很多编程语言设计中的一些语法特性,这些特性使代码更加简洁易读,但并不会引入新的功能或能力。
那么,Java中有哪些语法糖呢?
Java 语法糖
1. 自动装箱与拆箱
自动装箱和拆箱 (Autoboxing and Unboxing)是 Java 5 引入的特性,用于在基本数据类型和它们对应的包装类之间自动转换。
// 自动装箱
Integer num = 10; // 实际上是 Integer.valueOf(10)
// 自动拆箱
int n = num; // 实际上是 num.intValue()
2. 增强型 for 循环
增强型 for 循环(也称为 for-each 循环)用于遍历数组或集合。
int[] numbers = {1, 2, 3, 4, 5};
for (int number : numbers) {
System.out.println(number);
}
3. 泛型
泛型(Generics)使得类、接口和方法可以操作指定类型的对象,提供了类型安全的检查和消除了类型转换的需要。
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0); // 不需要类型转换
4. 可变参数
可变参数(Varargs)允许在方法中传递任意数量的参数。
public void printNumbers(int... numbers) {
for (int number : numbers) {
System.out.println(number);
}
}
printNumbers(1, 2, 3, 4, 5);
5. try-with-resources
try-with-resources 语句用于自动关闭资源,实现了 AutoCloseable
接口的资源会在语句结束时自动关闭。
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
System.out.println(br.readLine());
} catch (IOException e) {
e.printStackTrace();
}
6. Lambda 表达式
Lambda 表达式是 Java 8 引入的特性,使得可以使用更简洁的语法来实现函数式接口(只有一个抽象方法的接口)。
List<String> list = Arrays.asList("a", "b", "c");
list.forEach(s -> System.out.println(s));
7. 方法引用
方法引用(Method References)是 Lambda 表达式的一种简写形式,用于直接引用已有的方法。
list.forEach(System.out::println);
8. 字符串连接
从 Java 5 开始,Java 编译器会将字符串的连接优化为 StringBuilder
操作。
String message = "Hello, " + "world!"; // 实际上是 new StringBuilder().append("Hello, ").append("world!").toString();
9. Switch 表达式
Java 12 引入的 Switch 表达式使得 Switch 语句更加简洁和灵活。
int day = 5;
String dayName = switch (day) {
case 1 -> "Sunday";
case 2 -> "Monday";
case 3 -> "Tuesday";
case 4 -> "Wednesday";
case 5 -> "Thursday";
case 6 -> "Friday";
case 7 -> "Saturday";
default -> "Invalid day";
};
10. 类型推断 (Type Inference)
Java 10 引入了局部变量类型推断,通过 var
关键字来声明变量,编译器会自动推断变量的类型。
var list = new ArrayList<String>();
list.add("Hello");
这些语法糖使得 Java 代码更加简洁和易读,但需要注意的是,它们并不会增加语言本身的功能,只是对已有功能的一种简化和封装。
总结
本文,我们介绍了 Java 语言中的一些语法糖,从上面的例子可以看出,Java 语法糖只是一些简化的语法,可以使代码更简洁易读,而本身并不增加新的功能。
学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7412672643633791039
即时通讯 - 短轮询、长轮询、长连接、WebSocket
实现即时通讯主要有四种方式,它们分别是短轮询、长轮询、长连接、WebSocket
1. 短轮询
1.1 说明
传统的web通信模式。后台处理数据,需要一定时间,前端想要知道后端的处理结果,就要不定时的向后端发出请求以获得最新情况,得到想要的结果,或者超出规定的最长时间就终止再发请求。
1.2 优点:
前后端程序编写比较容易
1.3 缺点:
- 效率低:轮询的请求间隔时间一般是固定的,无论服务器是否有新的数据,都需要等待一段固定的时间。当数据更新的频率较低时,大部分请求都是无效的;
- 实时性差:如果数据在两次请求间发生了更新,那么用户只能在下一次轮询时才能得到最新数据;
- 浪费资源:高频率的操作功能,或者页面访问,导致的大量用户使用轮询时,会占用大量的网络资源,降低整体网络速度
1.4 基础实现:
每隔一段时间发送一个请求即可,得到想要的结果,或者超出规定的最长时间就终止再发请求。
let count = 0;
const timer = null;
// 超时时间
const MAX_TIME = 10 * 1000;
// 心跳间隙
const HEARTBEAT_INTERVAL = 1000;
/**
* @description: 模拟请求后端数据 (第6次时返回true)
*/
const fetchData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('fetch data...', count)
count += 1
if(count === 5) {
resolve(true);
}else {
resolve(false);
}
}, 1000)
});
};
/**
* @description: 异步轮询,当超时时或者接口返回true时,中断轮询
*/
const doSomething = async () => {
try {
let startTime = 0;
const timer = setInterval(async ()=>{
const res = await fetchData();
startTime += HEARTBEAT_INTERVAL;
if(res || startTime > MAX_TIME) {
clearInterval(timer)
}
}, HEARTBEAT_INTERVAL)
} catch (err) {
console.log(err);
}
};
doSomething();
2. 长轮询
2.1 说明
客户端向服务器发送Ajax请求,服务器接到请求后hold住连接
,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求
长轮询的实现原理与轮询类似,只是客户端的请求会保持打开状态,直到服务器返回响应或超时。在服务器端,可以使用阻塞方式处理长轮询请求,即服务器线程会一直等待直到有新的数据或事件,然后返回响应给客户端。客户端收到响应后,可以处理数据或事件,并随后发送下一个长轮询请求。
2.2 优点
长轮询相较于轮询技术来说,减少了不必要的网络流量和请求次数,降低了服务器和客户端的资源消耗
2.3 缺点
但是相对于传统的轮询技术,长轮询的实现更加复杂,并且需要服务器支持长时间保持连接的能力。
2.4 基础实现
超时和未得到想要的结果都需要重新执行原方法(递归实现)
async function subscribe() {
let response = await fetch("/subscribe");
if (response.status == 502) {
// 状态 502 是连接超时错误,
// 连接挂起时间过长时可能会发生,
// 远程服务器或代理会关闭它
// 让我们重新连接
await subscribe();
} else if (response.status != 200) {
// 一个 error —— 让我们显示它
showMessage(response.statusText);
// 一秒后重新连接
await new Promise(resolve => setTimeout(resolve, 1000));
await subscribe();
} else {
// 获取并显示消息
let message = await response.text();
showMessage(message);
// 再次调用 subscribe() 以获取下一条消息
await subscribe();
}
}
subscribe();
3. 长链接
3.1 说明
HTTP keep-alive 也称为 HTTP 长连接。它通过重用一个 TCP 连接来发送/接收多个 HTTP请求,来减少创建/关闭多个 TCP 连接的开销
3.1.1 为什么HTTP是短连接?
HTTP是短连接,客户端向服务器发送一个请求,得到响应后,连接就关闭。
例如,用户通过浏览器访问一个web站点上的某个网页,当网页内容加载完毕之后(已得到响应),用户可能需要花费几分钟甚至更多的时间来浏览网页内容,此时完全没有必要继续维持底层连。当用户需要访问其他网页时,再创建新的连接即可。
因此,HTTP连接的寿命通常都很短。这样做的好处是,可以极大的减轻服务端的压力。一般而言,一个站点能支撑的最大并发连接数也是有限的,
面对这么多客户端浏览器,不可能长期维持所有连接。每个客户端取得自己所需的内容后,即关闭连接,更加合理。
3.1.2 为什么要引入keep-alive(也称HTTP长连接)
通常一个网页可能会有很多组成部分,除了文本内容,还会有诸如:js、css、图片等静态资源,有时还会异步发起AJAX请求。
只有所有的资源都加载完毕后,我们看到网页完整的内容。然而,一个网页中,可能引入了几十个js、css文件,上百张图片,
如果每请求一个资源,就创建一个连接,然后关闭,代价实在太大了。
基于此背景,我们希望连接能够在短时间内得到复用,在加载同一个网页中的内容时,尽量的复用连接,这就是HTTP协议中keep-alive属性的作用。
- HTTP 1.0 中默认是关闭的,需要在http头加入"Connection: Keep-Alive",才能启用Keep-Alive;
- HTTP 1.1 中默认启用Keep-Alive,如果加入"Connection: close ",才关闭
注意:这里复用的是 TCP连接,并不是复用request
HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。
TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接
4. WebSocket
4.1 说明
Websocket是基于HTTP
协议的,在和服务端建立了链接后,服务端有数据有了变化后会主动推送给前端;
一般可以用于 股票交易行情分析、聊天室、在线游戏,替代轮询和长轮询。
4.2 优点
请求响应快,不浪费资源。(传统的http请求,其并发能力都是依赖同时发起多个TCP连接访问服务器实现的(因此并发数受限于浏览器允许的并发连接数),而websocket则允许我们在一条ws连接上同时并发多个请求,即在A请求发出后A响应还未到达,就可以继续发出B请求。由于TCP的慢启动特性(新连接速度上来是需要时间的),以及连接本身的握手损耗,都使得websocket协议的这一特性有很大的效率提升;http协议的头部太大,且每个请求携带的几百上千字节的头部大部分是重复的,websocket则因为复用长连接而没有这一问题。)
4.3 缺点
- 主流浏览器支持的Web Socket版本不一致;
- 服务端没有标准的API。
4.4 基础实现
这里使用了一个 网页和打印app的通信举例(部分敏感代码已省略)
const printConnect = () => {
try {
const host = 'ws://localhost:13888'
cloundPrintInfo.webSocket = new WebSocket(host)
// 通信
cloundPrintInfo.webSocket.onopen = () => {
// 获取打印机列表
cloundPrintInfo.webSocket.send(
JSON.stringify({
cmd: 'getPrinters',
version: '1.0',
})
)
}
// 通信返回
cloundPrintInfo.webSocket.onmessage = (msg: any) => {
const { data: returnData } = msg
// code 1000: 全部成功 1001: 部分失败 1002: 全部失败
const { cmd } = JSON.parse(`${returnData}`)
// 获取打印机数据
if (cmd === 'GETPRINTERS') {
printerInfoSet(returnData)
}
// 处理发送打印请求结果
if (cmd === 'PRINT') {
handlePrintResult(returnData)
}
// 批量推送打印结果
if (cmd === 'NOTIFYPRINTRESULT') {
cloudPrintTip(returnData)
}
}
// 通信失败
cloundPrintInfo.webSocket.onerror = () => {
printClose()
}
// 关闭通信
cloundPrintInfo.webSocket.onclose = () => {
printClose()
}
} catch (exception) {
console.log('建立连接失败', exception)
printClose()
}
}
在实际应用中,你可能需要处理更复杂的情况,比如重连逻辑、心跳机制来保持连接活跃、以及安全性问题等
重连逻辑:当WebSocket连接由于网络问题或其他原因断开时,客户端可能需要自动尝试重新连接
var socket;
var reconnectInterval = 5000; // 重连间隔时间,例如5秒
function connect() {
socket = new WebSocket('ws://localhost:3000');
socket.onopen = function(event) {
console.log('Connected to the WebSocket server');
};
socket.onclose = function(event) {
console.log('WebSocket connection closed. Reconnecting...');
setTimeout(connect, reconnectInterval); // 在指定时间后尝试重连
};
socket.onerror = function(error) {
console.error('WebSocket error:', error);
socket.close(); // 确保在错误后关闭连接,触发重连
};
}
connect(); // 初始连接
心跳机制:指定期发送消息以保持连接活跃的过程。这可以防止代理服务器或负载均衡器因为长时间的不活动而关闭连接
function heartbeat() {
if (socket.readyState === WebSocket.OPEN) {
socket.send('ping'); // 发送心跳消息,内容可以是'ping'
}
}
// 每30秒发送一次心跳
var heartbeatInterval = setInterval(heartbeat, 30000);
// 清除心跳定时器,通常在连接关闭时调用
function clearHeartbeat() {
clearInterval(heartbeatInterval);
}
socket.onclose = function(event) {
clearHeartbeat();
};
4种对比
从兼容性角度考虑,短轮询>长轮询>长连接SSE>WebSocket;
从性能方面考虑,WebSocket>长连接SSE>长轮询>短轮询。
参考文章:
来源:juejin.cn/post/7451612338408521743
面试官:MySQL单表过亿数据,如何优化count(*)全表的操作?
本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
最近有好几个同学跟我说,他在技术面试过程中被问到这个问题了,让我找时间系统地讲解一下。
其实从某种意义上来说,这并不是一个严谨的面试题,接下来 show me the SQL,我们一起来看一下。
如下图所示,一张有 3000多万行记录的 user 表,执行全表 count 操作需要 14.8 秒的时间。
接下来我们稍作调整再试一次,神奇的一幕出现了,执行全表 count 操作竟然连 1 毫秒的时间都用不上。
这是为什么呢?
其实原因很简单,第一次执行全表 count 操作的时候,我用的是 MySQL InnoDB 存储引擎,而第二次则是用的 MySQL MyISAM 存储引擎。
这两者的差别在于,前者在执行 count(*) 操作的时候,需要将表中每行数据读取出来进行累加计数,而后者已经将表的总行数存储下来了,只需要直接返回即可。
当然,InnoDB 存储引擎对 count(*) 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的 IO 次数少很多,也就意味着其执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
所以,这个技术面试题严谨的问法应该是 —— MySQL InnoDB 存储引擎单表过亿数据,如何优化 count(*) 全表的操作?
下面我们就来列举几个常见的技术解决方案,如下图所示:
(1)Redis 累加计数
这是一种最主流且简单直接的实现方式。
由于我们基本上不会对数据表执行 delete 操作,所以当有新的数据被写入表的时候,通过 Redis 的 incr 或 incrby 命令进行累加计数,并在用户查询汇总数据的时候直接返回结果即可。
如下图所示:
该实现方式在查询性能和数据准确性上两者兼得,Redis 需要同时负责累加计数和返回查询结果操作,缺点在于会引入缓存和数据库间的数据一致性的问题。
(2)MySQL 累加计数表 + 事务
这种实现方式跟“Redis 累加计数”大同小异,唯一的区别就是将计数的存储介质从 Redis 换成了 MySQL。
如下图所示:
但这么一换,就可以将写入表操作和累加计数操作放在一个数据库事务中,也就解决了缓存和数据库间的数据一致性的问题。
该实现方式在查询性能和数据准确性上两者兼得,但不如“Redis 累加计数”方式的性能高,在高并发场景下数据库会成为性能瓶颈。
(3)MySQL 累加计数表 + 触发器
这种实现方式跟“MySQL 累加计数表 + 事务”的表结构是一样的,如下图所示:
****
唯一的区别就是增加一个触发器,不用在工程代码中通过事务进行实现了。
CREATE TRIGGER `user_count_trigger` AFTER INSERT ON `user` FOR EACH ROW BEGIN UPDATE user_count SET count = count + 1 WHERE id = NEW.id;END
该实现方式在查询性能和数据准确性上两者兼得,与“MySQL 累加计数表 + 事务”方式相比,最大的好处就是不用污染工程代码了。
(4)MySQL 增加并行线程
在 MySQL 8.014 版本中,总算增加了并行查询的新特性,其通过参数 innodb_parallel_read_threads 进行设定,默认值为 4。
下面我们做个实验,将这个参数值调得大一些:
set local innodb_parallel_read_threads = 16;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间由之前的 14.8 秒,降低至现在的 6.1 秒,是可以看到效果的。
接下来,我们继续将参数值调整得大一些,看看是否还有优化空间:
set local innodb_parallel_read_threads = 32;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间竟然变长了,从原来的 6.1 秒变成了 6.8 秒,看样子优化空间已经达到上限了,再多增加执行线程数量只会适得其反。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要调整一个数据库参数,在工程代码上不会有任何改动。
不过,如果数据库此时的负载和 IOPS 已经很高了,那开启并行线程或者将并行线程数量调大,会加速消耗数据库资源。
(5)MySQL 增加二级索引
还记得我们在上文中说的内容吗?
InnoDB 存储引擎对 count() 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,*
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的IO次数少很多,也就意味着执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
为了验证这个说法,我们给 user 表中最小的 sex 字段加一个二级索引,然后通过 EXPLAIN 命令看一下 SQL 语句的执行计划:
果然,这个 SQL 语句的执行计划会使用新建的 sex 索引,接下来我们执行一次看看时长:
果不其然,执行全表 count 操作走了 sex 二级索引后,SQL 执行时间由之前的 14.8 秒降低至现在的 10.6 秒,还是可以看到效果的。
btw:大家可能会觉得效果并不明显,这是因为我们用来测试的 user 表中算上主键 ID 只有七个字段,而且没有一个大字段。
反之,user 表中的字段数量越多,且包含的大字段越多,其优化效果就会越明显。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要创建一个二级索引,在工程代码上不会有任何改动。
(6)SHOW TABLE STATUS
如下图所示,通过 SHOW TABLE STATUS 命令也可以查出来全表的行数:
我们常用于查看执行计划的 EXPLAIN 命令也能实现:
只不过,通过这两个命令得出来的表记录数是估算出来的,都不太准确。那到底有多不准确呢,我们来计算一下。
公式为:33554432 / 33216098 = 1.01
就这个 case 而言,误差率大概在百分之一左右。
该实现方式一样可以保证查询性能,无论表中有多大量级的数据都能毫秒级返回结果,且在工程代码方面不会有任何改动,但数据准确性上相差较多,只能用作大概估算。
来源:juejin.cn/post/7444919285170307107
订单超时自动取消,这7种方案真香!
大家好,我是苏三,又跟大家见面了。
前言
在电商、外卖、票务等系统中,订单超时未支付自动取消是一个常见的需求。
这个功能乍一看很简单,甚至很多初学者会觉得:"不就是加个定时器么?" 但真到了实际工作中,细节的复杂程度往往会超乎预期。
这里我们从基础到高级,逐步分析各种实现方案,最后分享一些在生产中常见的优化技巧,希望对你会有所帮助。
在电商、外卖、票务等系统中,订单超时未支付自动取消是一个常见的需求。
这个功能乍一看很简单,甚至很多初学者会觉得:"不就是加个定时器么?" 但真到了实际工作中,细节的复杂程度往往会超乎预期。
这里我们从基础到高级,逐步分析各种实现方案,最后分享一些在生产中常见的优化技巧,希望对你会有所帮助。
1. 使用延时队列(DelayQueue)
适用场景: 订单数量较少,系统并发量不高。
延时队列是Java并发包(java.util.concurrent
)中的一个数据结构,专门用于处理延时任务。
订单在创建时,将其放入延时队列,并设置超时时间。
延时时间到了以后,队列会触发消费逻辑,执行取消操作。
示例代码:
import java.util.concurrent.*;
public class OrderCancelService {
private static final DelayQueue delayQueue = new DelayQueue<>();
public static void main(String[] args) throws InterruptedException {
// 启动消费者线程
new Thread(() -> {
while (true) {
try {
OrderTask task = delayQueue.take(); // 获取到期任务
System.out.println("取消订单:" + task.getOrderId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
// 模拟订单创建
for (int i = 1; i <= 5; i++) {
delayQueue.put(new OrderTask(i, System.currentTimeMillis() + 5000)); // 5秒后取消
System.out.println("订单" + i + "已创建");
}
}
static class OrderTask implements Delayed {
private final long expireTime;
private final int orderId;
public OrderTask(int orderId, long expireTime) {
this.orderId = orderId;
this.expireTime = expireTime;
}
public int getOrderId() {
return orderId;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.expireTime, ((OrderTask) o).expireTime);
}
}
}
优点:
- 实现简单,逻辑清晰。
缺点:
- 依赖内存,系统重启会丢失任务。
- 随着订单量增加,内存占用会显著上升。
适用场景: 订单数量较少,系统并发量不高。
延时队列是Java并发包(java.util.concurrent
)中的一个数据结构,专门用于处理延时任务。
订单在创建时,将其放入延时队列,并设置超时时间。
延时时间到了以后,队列会触发消费逻辑,执行取消操作。
示例代码:
import java.util.concurrent.*;
public class OrderCancelService {
private static final DelayQueue delayQueue = new DelayQueue<>();
public static void main(String[] args) throws InterruptedException {
// 启动消费者线程
new Thread(() -> {
while (true) {
try {
OrderTask task = delayQueue.take(); // 获取到期任务
System.out.println("取消订单:" + task.getOrderId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
// 模拟订单创建
for (int i = 1; i <= 5; i++) {
delayQueue.put(new OrderTask(i, System.currentTimeMillis() + 5000)); // 5秒后取消
System.out.println("订单" + i + "已创建");
}
}
static class OrderTask implements Delayed {
private final long expireTime;
private final int orderId;
public OrderTask(int orderId, long expireTime) {
this.orderId = orderId;
this.expireTime = expireTime;
}
public int getOrderId() {
return orderId;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.expireTime, ((OrderTask) o).expireTime);
}
}
}
优点:
- 实现简单,逻辑清晰。
缺点:
- 依赖内存,系统重启会丢失任务。
- 随着订单量增加,内存占用会显著上升。
2. 基于数据库轮询
适用场景: 订单数量较多,但系统对实时性要求不高。
轮询是最容易想到的方案:定期扫描数据库,将超时的订单状态更新为“已取消”。
示例代码:
public void cancelExpiredOrders() {
String sql = "UPDATE orders SET status = 'CANCELLED' WHERE status = 'PENDING' AND create_time < ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis() - 30 * 60 * 1000)); // 30分钟未支付取消
int affectedRows = ps.executeUpdate();
System.out.println("取消订单数量:" + affectedRows);
} catch (SQLException e) {
e.printStackTrace();
}
}
优点:
- 数据可靠性强,不依赖内存。
- 实现成本低,无需引入第三方组件。
缺点:
- 频繁扫描数据库,会带来较大的性能开销。
- 实时性较差(通常定时任务间隔为分钟级别)。
优化建议:
- 为相关字段加索引,避免全表扫描。
- 结合分表分库策略,减少单表压力。
适用场景: 订单数量较多,但系统对实时性要求不高。
轮询是最容易想到的方案:定期扫描数据库,将超时的订单状态更新为“已取消”。
示例代码:
public void cancelExpiredOrders() {
String sql = "UPDATE orders SET status = 'CANCELLED' WHERE status = 'PENDING' AND create_time < ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis() - 30 * 60 * 1000)); // 30分钟未支付取消
int affectedRows = ps.executeUpdate();
System.out.println("取消订单数量:" + affectedRows);
} catch (SQLException e) {
e.printStackTrace();
}
}
优点:
- 数据可靠性强,不依赖内存。
- 实现成本低,无需引入第三方组件。
缺点:
- 频繁扫描数据库,会带来较大的性能开销。
- 实时性较差(通常定时任务间隔为分钟级别)。
优化建议:
- 为相关字段加索引,避免全表扫描。
- 结合分表分库策略,减少单表压力。
3. 基于Redis队列
适用场景: 适合对实时性有要求的中小型项目。
Redis 的 List 或 Sorted Set 数据结构非常适合用作延时任务队列。
我们可以把订单的超时时间作为 Score,订单 ID 作为 Value 存到 Redis 的 ZSet 中,定时去取出到期的订单进行取消。
例子:
public void addOrderToQueue(String orderId, long expireTime) {
jedis.zadd("order_delay_queue", expireTime, orderId);
}
public void processExpiredOrders() {
long now = System.currentTimeMillis();
Set<String> expiredOrders = jedis.zrangeByScore("order_delay_queue", 0, now);
for (String orderId : expiredOrders) {
System.out.println("取消订单:" + orderId);
jedis.zrem("order_delay_queue", orderId); // 删除已处理的订单
}
}
优点:
- 实时性高。
- Redis 的性能优秀,延迟小。
缺点:
- Redis 容量有限,适合中小规模任务。
- 需要额外处理 Redis 宕机或数据丢失的问题。
适用场景: 适合对实时性有要求的中小型项目。
Redis 的 List 或 Sorted Set 数据结构非常适合用作延时任务队列。
我们可以把订单的超时时间作为 Score,订单 ID 作为 Value 存到 Redis 的 ZSet 中,定时去取出到期的订单进行取消。
例子:
public void addOrderToQueue(String orderId, long expireTime) {
jedis.zadd("order_delay_queue", expireTime, orderId);
}
public void processExpiredOrders() {
long now = System.currentTimeMillis();
Set<String> expiredOrders = jedis.zrangeByScore("order_delay_queue", 0, now);
for (String orderId : expiredOrders) {
System.out.println("取消订单:" + orderId);
jedis.zrem("order_delay_queue", orderId); // 删除已处理的订单
}
}
优点:
- 实时性高。
- Redis 的性能优秀,延迟小。
缺点:
- Redis 容量有限,适合中小规模任务。
- 需要额外处理 Redis 宕机或数据丢失的问题。
4. Redis Key 过期回调
适用场景: 对超时事件实时性要求高,并且希望依赖 Redis 本身的特性实现简单的任务调度。
Redis 提供了 Key 的过期功能,结合 keyevent
事件通知机制,可以实现订单的自动取消逻辑。
当订单设置超时时间后,Redis 会在 Key 过期时发送通知,我们只需要订阅这个事件并进行相应的处理。
例子:
- 设置订单的过期时间:
public void setOrderWithExpiration(String orderId, long expireSeconds) {
jedis.setex("order:" + orderId, expireSeconds, "PENDING");
}
- 订阅 Redis 的过期事件:
public void subscribeToExpirationEvents() {
Jedis jedis = new Jedis("localhost");
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
if (channel.equals("__keyevent@0__:expired")) {
System.out.println("接收到过期事件,取消订单:" + message);
// 执行取消订单的业务逻辑
}
}
}, "__keyevent@0__:expired"); // 订阅过期事件
}
适用场景: 对超时事件实时性要求高,并且希望依赖 Redis 本身的特性实现简单的任务调度。
Redis 提供了 Key 的过期功能,结合 keyevent
事件通知机制,可以实现订单的自动取消逻辑。
当订单设置超时时间后,Redis 会在 Key 过期时发送通知,我们只需要订阅这个事件并进行相应的处理。
例子:
- 设置订单的过期时间:
public void setOrderWithExpiration(String orderId, long expireSeconds) {
jedis.setex("order:" + orderId, expireSeconds, "PENDING");
}
- 订阅 Redis 的过期事件:
public void subscribeToExpirationEvents() {
Jedis jedis = new Jedis("localhost");
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
if (channel.equals("__keyevent@0__:expired")) {
System.out.println("接收到过期事件,取消订单:" + message);
// 执行取消订单的业务逻辑
}
}
}, "__keyevent@0__:expired"); // 订阅过期事件
}
优点:
- 实现简单,直接利用 Redis 的过期机制。
- 实时性高,过期事件触发后立即响应。
缺点:
- 依赖 Redis 的事件通知功能,需要开启
notify-keyspace-events
配置。 - 如果 Redis 中大量使用过期 Key,可能导致性能问题。
注意事项: 要使用 Key 过期事件,需要确保 Redis 配置文件中 notify-keyspace-events
的值包含 Ex
。比如:
notify-keyspace-events Ex
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。
5. 基于消息队列(如RabbitMQ)
适用场景: 高并发系统,实时性要求高。
订单创建时,将订单消息发送到延迟队列(如RabbitMQ 的 x-delayed-message
插件)。
延迟时间到了以后,消息会重新投递到消费者,消费者执行取消操作。
示例代码(以RabbitMQ为例):
public void sendOrderToDelayQueue(String orderId, long delay) {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
ConnectionFactory factory = new ConnectionFactory();
try (Connection connection = factory.newConnection();
Channel channel = connection.createChannel()) {
channel.exchangeDeclare("delayed_exchange", "x-delayed-message", true, false, args);
channel.queueDeclare("delay_queue", true, false, false, null);
channel.queueBind("delay_queue", "delayed_exchange", "order.cancel");
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.headers(Map.of("x-delay", delay)) // 延迟时间
.build();
channel.basicPublish("delayed_exchange", "order.cancel", props, orderId.getBytes());
} catch (Exception e) {
e.printStackTrace();
}
}
优点:
- 消息队列支持分布式,高并发下表现优秀。
- 数据可靠性高,不容易丢消息。
缺点:
- 引入消息队列增加了系统复杂性。
- 需要处理队列堆积的问题。
6. 使用定时任务框架
适用场景: 订单取消操作复杂,需要分布式支持。
定时任务框架,比如:Quartz、Elastic-Job,能够高效地管理任务调度,适合处理批量任务。
比如 Quartz 可以通过配置 Cron 表达式,定时执行订单取消逻辑。
示例代码:
@Scheduled(cron = "0 */5 * * * ?")
public void scanAndCancelOrders() {
System.out.println("开始扫描并取消过期订单");
// 这里调用数据库更新逻辑
}
优点:
- 成熟的调度框架支持复杂任务调度。
- 灵活性高,支持分布式扩展。
缺点:
- 对实时性支持有限。
- 框架本身较复杂。
7. 基于触发式事件流处理
适用场景: 需要处理实时性较高的订单取消,同时结合复杂业务逻辑,例如根据用户行为动态调整超时时间。
可以借助事件流处理框架(如 Apache Flink 或 Spark Streaming),实时地处理订单状态,并触发超时事件。
每个订单生成后,可以作为事件流的一部分,订单未支付时通过流计算触发超时取消逻辑。
示例代码(以 Apache Flink 为例):
DataStream orderStream = env.fromCollection(orderEvents);
orderStream
.keyBy(OrderEvent::getOrderId)
.process(new KeyedProcessFunction() {
@Override
public void processElement(OrderEvent event, Context ctx, Collector out ) throws Exception {
// 注册一个定时器
ctx.timerService().registerProcessingTimeTimer(event.getTimestamp() + 30000); // 30秒超时
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector out ) throws Exception {
// 定时器触发,执行订单取消逻辑
System.out.println("订单超时取消,订单ID:" + ctx.getCurrentKey());
}
});
优点:
- 实时性高,支持复杂事件处理逻辑。
- 适合动态调整超时时间,满足灵活的业务需求。
缺点:
- 引入了流计算框架,系统复杂度增加。
- 对运维要求较高。
总结
每种方案都有自己的适用场景,大家在选择的时候,记得结合业务需求、订单量、并发量来综合考虑。
如果你的项目规模较小,可以直接用延时队列或 Redis;而在大型高并发系统中,消息队列和事件流处理往往是首选。
当然,代码实现只是第一步,更重要的是在实际部署和运行中进行性能调优,保证系统的稳定性。
来源:juejin.cn/post/7451018774743269391
妙用MyBatisPlus,12个实战技巧解锁新知识
妙用MyBatisPlus,12个实战技巧解锁新知识
前言
说起数据库ORM,我忽然想起了小时候外婆做的那锅鲜美的羊肉汤。平常人家做的羊肉汤无非是几块肉、几片姜,味道寡淡得很,喝了和喝白开水差不多。但外婆的汤,那是另一回事儿 —— 一锅汤,香气四溢,肉质软烂,汤头浓郁得能让人连碗都想舔干净。
写代码何尝不是如此?以前写Mybatis,就像是在煮一锅没有灵魂的羊肉汤:原料都在,但就是不够鲜美。代码繁琐,每写一个查询都像是在不断调味,却怎么也调不出那种令人惊艳的味道。直到遇见MyBatisPlus,一切都变了 —— 这就像是从普通的羊肉汤,突然升级到了外婆秘制的顶级羊肉汤!
MyBatisPlus就像一位精通厨艺的帮厨,它帮你处理了所有繁琐的准备工作。想要一个复杂的查询?不用自己一刀一刀地切肉、一勺一勺地调味,框架已经帮你准备好了。你只需要轻轻地指挥,代码就像汤汁一样顺滑流畅,性能更是鲜美可口。
在接下来的篇幅里,我将与你分享12个MyBatisPlus优化的"秘制配方"。相信看完这些,你写的每一行代码,都会像外婆的羊肉汤一样,让人回味无穷。
耐心看完,你一定有所收获。
避免使用isNull判断
// ❌ 不推荐
LambdaQueryWrapper<User> wrapper1 = new LambdaQueryWrapper<>();
wrapper1.isNull(User::getStatus);
// ✅ 推荐:使用具体的默认值
LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<>();
wrapper2.eq(User::getStatus, UserStatusEnum.INACTIVE.getCode());
- 📝 原因:
- 使用具体的默认值可以提高代码的可读性和维护性
- NULL值会使索引失效,导致MySQL无法使用索引进行查询优化
- NULL值的比较需要特殊的处理逻辑,增加了CPU开销
- NULL值会占用额外的存储空间,影响数据压缩效率
明确Select字段
// ❌ 不推荐
// 默认select 所有字段
List<User> users1 = userMapper.selectList(null);
// ✅ 推荐:指定需要的字段
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.select(User::getId, User::getName, User::getAge);
List<User> users2 = userMapper.selectList(wrapper);
- 📝 原因:
- 避免大量无用字段的网络传输开销
- 可以利用索引覆盖,避免回表查询
- 减少数据库解析和序列化的负担
- 降低内存占用,特别是在大量数据查询时
批量操作方法替代循环
// ❌ 不推荐
for (User user : userList) {
userMapper.insert(user);
}
// ✅ 推荐
userService.saveBatch(userList, 100); // 每批次处理100条数据
// ✅ 更优写法:自定义批次大小
userService.saveBatch(userList, BatchConstants.BATCH_SIZE);
- 📝 原因:
- 减少数据库连接的创建和销毁开销
- 批量操作可以在一个事务中完成,提高数据一致性
- 数据库可以优化批量操作的执行计划
- 显著减少网络往返次数,提升吞吐量
Exists方法子查询
// ❌ 不推荐
wrapper.inSql("user_id", "select user_id from order where amount > 1000");
// ✅ 推荐
wrapper.exists("select 1 from order where order.user_id = user.id and amount > 1000");
// ✅ 更优写法:使用LambdaQueryWrapper
wrapper.exists(orderService.lambdaQuery()
.gt(Order::getAmount, 1000)
.apply("order.user_id = user.id"));
- 📝 原因:
- EXISTS是基于索引的快速查询,可以使用到索引
- EXISTS在找到第一个匹配项就会停止扫描
- IN子查询需要加载所有数据到内存后再比较
- 当外表数据量大时,EXISTS的性能优势更明显
使用orderBy代替last
// ❌ 不推荐:SQL注入风险
wrapper.last("ORDER BY " + sortField + " " + sortOrder);
// ❌ 不推荐:直接字符串拼接
wrapper.last("ORDER BY FIELD(status, 'active', 'pending', 'inactive')");
// ✅ 推荐:使用 Lambda 安全排序
wrapper.orderBy(true, true, User::getStatus);
// ✅ 推荐:多字段排序示例
wrapper.orderByAsc(User::getStatus)
.orderByDesc(User::getCreateTime);
- 📝 原因:
- 直接拼接SQL容易导致SQL注入攻击
- 动态SQL可能破坏SQL语义完整性
- 影响SQL语句的可维护性和可读性
- last会绕过MyBatis-Plus的安全检查机制
使用LambdaQuery确保类型安全
// ❌ 不推荐:字段变更后可能遗漏
QueryWrapper<User> wrapper1 = new QueryWrapper<>();
wrapper1.eq("name", "张三").gt("age", 18);
// ✅ 推荐
LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<>();
wrapper2.eq(User::getName, "张三")
.gt(User::getAge, 18);
// ✅ 更优写法:使用链式调用
userService.lambdaQuery()
.eq(User::getName, "张三")
.gt(User::getAge, 18)
.list();
- 📝 原因:
- 编译期类型检查,避免字段名拼写错误
- IDE可以提供更好的代码补全支持
- 重构时能自动更新字段引用
- 提高代码的可维护性和可读性
用between代替ge和le
// ❌ 不推荐
wrapper.ge(User::getAge, 18)
.le(User::getAge, 30);
// ✅ 推荐
wrapper.between(User::getAge, 18, 30);
// ✅ 更优写法:条件动态判断
wrapper.between(ageStart != null && ageEnd != null,
User::getAge, ageStart, ageEnd);
- 📝 原因:
- 生成的SQL更简洁,减少解析开销
- 数据库优化器可以更好地处理范围查询
- 代码更易读,语义更清晰
- 减少重复编写字段名的机会
排序字段注意索引
// ❌ 不推荐
// 假设lastLoginTime无索引
wrapper.orderByDesc(User::getLastLoginTime);
// ✅ 推荐
// 主键排序
wrapper.orderByDesc(User::getId);
// ✅ 更优写法:组合索引排序
wrapper.orderByDesc(User::getStatus) // status建立了索引
.orderByDesc(User::getId); // 主键排序
- 📝 原因:
- 索引天然具有排序特性,可以避免额外的排序操作
- 无索引排序会导致文件排序,极大影响性能
- 当数据量大时,内存排序可能导致溢出
- 利用索引排序可以实现流式读取
分页参数设置
// ❌ 不推荐
wrapper.last("limit 1000"); // 一次查询过多数据
// ✅ 推荐
Page<User> page = new Page<>(1, 10);
userService.page(page, wrapper);
// ✅ 更优写法:带条件的分页查询
Page<User> result = userService.lambdaQuery()
.eq(User::getStatus, "active")
.page(new Page<>(1, 10));
- 📝 原因:
- 控制单次查询的数据量,避免内存溢出
- 提高首屏加载速度,优化用户体验
- 减少网络传输压力
- 数据库资源利用更合理
条件构造处理Null值
// ❌ 不推荐
if (StringUtils.isNotBlank(name)) {
wrapper.eq("name", name);
}
if (age != null) {
wrapper.eq("age", age);
}
// ✅ 推荐
wrapper.eq(StringUtils.isNotBlank(name), User::getName, name)
.eq(Objects.nonNull(age), User::getAge, age);
// ✅ 更优写法:结合业务场景
wrapper.eq(StringUtils.isNotBlank(name), User::getName, name)
.eq(Objects.nonNull(age), User::getAge, age)
.eq(User::getDeleted, false) // 默认查询未删除记录
.orderByDesc(User::getCreateTime); // 默认按创建时间倒序
- 📝 原因:
- 优雅处理空值,避免无效条件
- 减少代码中的if-else判断
- 提高代码可读性
- 防止生成冗余的SQL条件
⚠️ 下面就要来一些高级货了
查询性能追踪
// ❌ 不推荐:简单计时,代码冗余
public List<User> listUsers(QueryWrapper<User> wrapper) {
long startTime = System.currentTimeMillis();
List<User> users = userMapper.selectList(wrapper);
long endTime = System.currentTimeMillis();
log.info("查询耗时:{}ms", (endTime - startTime));
return users;
}
// ✅ 推荐:使用 Try-with-resources 自动计时
public List<User> listUsersWithPerfTrack(QueryWrapper<User> wrapper) {
try (PerfTracker.TimerContext ignored = PerfTracker.start()) {
return userMapper.selectList(wrapper);
}
}
// 性能追踪工具类
@Slf4j
public class PerfTracker {
private final long startTime;
private final String methodName;
private PerfTracker(String methodName) {
this.startTime = System.currentTimeMillis();
this.methodName = methodName;
}
public static TimerContext start() {
return new TimerContext(Thread.currentThread().getStackTrace()[2].getMethodName());
}
public static class TimerContext implements AutoCloseable {
private final PerfTracker tracker;
private TimerContext(String methodName) {
this.tracker = new PerfTracker(methodName);
}
@Override
public void close() {
long executeTime = System.currentTimeMillis() - tracker.startTime;
if (executeTime > 500) {
log.warn("慢查询告警:方法 {} 耗时 {}ms", tracker.methodName, executeTime);
}
}
}
}
- 📝 原因:
- 业务代码和性能监控代码完全分离
- try-with-resources 即使发生异常,close() 方法也会被调用,确保一定会记录耗时
- 不需要手动管理计时的开始和结束
- 更优雅
枚举类型映射
// 定义枚举
public enum UserStatusEnum {
NORMAL(1, "正常"),
DISABLED(0, "禁用");
@EnumValue // MyBatis-Plus注解
private final Integer code;
private final String desc;
}
// ✅ 推荐:自动映射
public class User {
private UserStatusEnum status;
}
// 查询示例
userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getStatus, UserStatusEnum.NORMAL)
);
- 📝 原因:
- 类型安全
- 自动处理数据库和枚举转换
- 避免魔法值
- 代码可读性更强
自动处理逻辑删除
@TableLogic // 逻辑删除注解
private Integer deleted;
// ✅ 推荐:自动过滤已删除数据
public List<User> getActiveUsers() {
return userMapper.selectList(null); // 自动过滤deleted=1的记录
}
// 手动删除
userService.removeById(1L); // 实际是更新deleted状态
- 📝 原因:
- 数据不丢失
- 查询自动过滤已删除数据
- 支持数据恢复
- 减少手动编写删除逻辑
- 📷 注意:
- XML中需要手动拼接 deleted = 1
乐观锁更新保护
public class Product {
@Version // 乐观锁版本号
private Integer version;
}
// ✅ 推荐:更新时自动处理版本
public boolean reduceStock(Long productId, Integer count) {
LambdaUpdateWrapper<Product> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Product::getId, productId)
.ge(Product::getStock, count);
Product product = new Product();
product.setStock(product.getStock() - count);
return productService.update(product, wrapper);
}
- 📝 原因:
- 防止并发冲突
- 自动处理版本控制
- 简化并发更新逻辑
- 提高数据一致性
递增和递减:setIncrBy 和 setDecrBy
// ❌ 不推荐:使用 setSql
userService.lambdaUpdate()
.setSql("integral = integral + 10")
.update();
// ✅ 推荐:使用 setIncrBy
userService.lambdaUpdate()
.eq(User::getId, 1L)
.setIncrBy(User::getIntegral, 10)
.update();
// ✅ 推荐:使用 setDecrBy
userService.lambdaUpdate()
.eq(User::getId, 1L)
.setDecrBy(User::getStock, 5)
.update();
- 📝 原因:
- 类型安全
- 避免手动拼接sql,防止sql注入
- 代码可维护性更强,更清晰
总结
写代码如烹小鲜,讲究的是精细和用心。就像一碗好汤,不仅仅在于锅和火候,更在于厨师对食材的理解和尊重。MyBatisPlus的这12个优化技巧,何尝不是程序员对代码的一种尊重和雕琢?
还记得文章开头说的外婆的羊肉汤吗?优秀的代码,和一碗好汤,都需要用心。每一个细节,每一个调整,都是为了让最终的成果更加完美。MyBatisPlus就像是厨房里的得力助手,它帮你处理繁琐,让你专注于创造。
当你掌握了这些技巧,你的代码将不再是简单的指令堆砌,而是一首优雅的诗,一曲悦耳的交响乐。它们将像外婆的羊肉汤一样,散发着独特的魅力,让人回味无穷。
愿每一位开发者,都能用MyBatisPlus,煮出属于自己的"秘制汤羹"!
代码,就应该是这个样子 —— 简单而不失优雅,高效而不失温度。
来源:juejin.cn/post/7436567167728812044
反射为什么慢?
1. 背景
今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。
2. 文章给出的解释
文章中给出的理由是因为以下4点:
- 反射涉及动态解析的内容,不能执行某些虚拟机优化,例如JIT优化技术
- 在反射时,参数需要包装成object[]类型,但是方法真正执行的时候,又使用拆包成真正的类型,这些动作不仅消耗时间,而且过程中会产生很多的对象,这就会导致gc,gc也会导致延时
- 反射的方法调用需要从数组中遍历,这个遍历的过程也比较消耗时间
- 不仅需要对方法的可见性进行检查,参数也需要做额外的检查
3. 结合实际理解
3.1 第一点分析
首先我们需要知道,java中的反射是一种机制,它可以在代码运行过程中,获取类的内部信息(变量、构造方法、成员方法);操作对象的属性、方法。
然后关于反射的原理,首先我们需要知道一个java项目在启动之后,会将class文件加载到堆中,生成一个class对象,这个class对象中有一个类的所有信息,通过这个class对象获取类相关信息的操作我们称为反射。
其次是JIT优化技术,首先我们需要知道在java虚拟机中有两个角色,解释器和编译器;这两者各有优劣,首先是解释器可以在项目启动的时候直接直接发挥作用,省去编译的时候,立即执行,但是在执行效率上有所欠缺;在项目启动之后,随着时间推移,编译器逐渐将机器码编译成本地代码执行,减少解释器的中间损耗,增加了执行效率。
我们可以知道JIT优化通常依赖于在编译时能够知道的静态信息,而反射的动态性可能会破坏这些假设,使得JIT编译器难以进行有效的优化。
3.2 第二点
关于第二点,我们直接写一段反射调用对象方法的demo:
@Test
public void methodTest() {
Class clazz = MyClass.class;
try {
//获取指定方法
//这个注释的会报错 java.lang.NoSuchMethodException
//Method back = clazz.getMethod("back");
Method back = clazz.getMethod("back", String.class);
Method say = clazz.getDeclaredMethod("say", String.class);
//私有方法需要设置
say.setAccessible(true);
MyClass myClass = new MyClass("abc", 99);
//反射调用方法
System.out.println(back.invoke(myClass, "back"));
say.invoke(myClass, "hello world");
} catch (Exception e) {
e.printStackTrace();
}
}
在上面这段代码中,我们调用了一个invoke 方法,并且传了class对象和参数,进入到invoke方法中,我们可以看到invoke方法的入参都是Object类型的,args更是一个Object 数组,这就第二点,关于反射调用过程中的拆装箱。
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
3.3 第三点
关于调用方法需要遍历这点,还是上面那个demo,我们在获取Method 对象的时候是通过调用getMethod、getDeclaredMethod方法,点击进入这个方法的源码,我们可以看到如下代码:
private static Method searchMethods(Method[] methods,
String name,
Class<?>[] parameterTypes)
{
Method res = null;
String internedName = name.intern();
for (int i = 0; i < methods.length; i++) {
Method m = methods[i];
if (m.getName() == internedName
&& arrayContentsEq(parameterTypes, m.getParameterTypes())
&& (res == null
|| res.getReturnType().isAssignableFrom(m.getReturnType())))
res = m;
}
return (res == null ? res : getReflectionFactory().copyMethod(res));
}
我们可以看到,底层实际上也是将class对象的所有method遍历了一遍,最终才拿到我们需要的方法的,这也就是第二点,执行具体方法的时候需要遍历class对象的方法。
3.4 第四点
第4点说需要对方法和参数进行检查,也就是我们在执行具体的某一个方法的时候,我们实际上是需要校验这个方法是否可见的,如果不可见,我们还需要将这个方法设置为可见,否则如果我们直接调用这个方法的话,会报错。
同时还有一个点,在我们调用invoke方法的时候,反射类会对方法和参数进行一个校验,让我们来看一下源码:
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
我们可以看到还有quickCheckMemberAccess、checkAccess 等逻辑
4. 总结
平时在反射这块用的比较少,也没针对性的去学习一下。在工作之余,还是得保持一个学习的习惯,这样子才不会出现今天这种被一个问题难倒的情况,而且才能产出更多、更优秀的方案。
来源:juejin.cn/post/7330115846140051496