注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Sa-Token v1.41.0 发布 🚀,来看看有没有令你心动的功能!

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、微服务网关鉴权 等一系列权限相关问题。🔐 目前最新版本 v1.41.0 已推送至 Maven 中央仓库 🎉,大家可以通过如下方式引入: <!...
继续阅读 »

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证权限认证单点登录OAuth2.0微服务网关鉴权 等一系列权限相关问题。🔐


目前最新版本 v1.41.0 已推送至 Maven 中央仓库 🎉,大家可以通过如下方式引入:


<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.41.0</version>
</dependency>

该版本包含大量 ⛏️️️新增特性、⛏️底层重构、⛏️️️代码优化 等,下面容我列举几条比较重要的更新内容供大家参阅:


🛡️ 更新点1:防火墙模块新增 hooks 扩展机制


本次更新针对防火墙新增了多条校验规则,之前的规则为:



  • path 白名单放行。

  • path 黑名单拦截。

  • path 危险字符校验。


本次新增规则为:



  • path 禁止字符校验。

  • path 目录遍历符检测(优化了检测算法)。

  • 请求 host 检测。

  • 请求 Method 检测。

  • 请求 Header 头检测。

  • 请求参数检测。


并且本次更新开放了 hooks 机制,允许开发者注册自定义的校验规则 🛠️,参考如下:


@PostConstruct
public void saTokenPostConstruct() {
// 注册新 hook 演示,拦截所有带有 pwd 参数的请求,拒绝响应
SaFirewallStrategy.instance.registerHook((req, res, extArg)->{
if(req.getParam("pwd") != null) {
throw new FirewallCheckException("请求中不可包含 pwd 参数");
}
});
}

文档直达地址:Sa-Token 防火墙 🔗


💡 更新点2:新增基于 SPI 机制的插件体系


之前在 Sa-Token 中也有插件体系,不过都是利用 SpringBoot 的 SPI 机制完成组件注册的。


这种注册机制有一个问题,就是插件只能在 SpringBoot 环境下正常工作,在其它环境,比如 Solon 项目中,就只能手动注册插件才行 😫。


也就是说,严格来讲,这些插件只能算是 SpringBoot 的插件,而非 Sa-Token 框架的插件 🌐。


为了提高插件的通用性,Sa-Token 设计了自己的 SPI 机制,使得这些插件可以在更多的项目环境下正常工作 🚀。


第一步:实现插件注册类,此类需要 implements SaTokenPlugin 接口 👨💻:


/**
* SaToken 插件安装:插件作用描述
*/

public class SaTokenPluginForXxx implements SaTokenPlugin {
@Override
public void install() {
// 书写需要在项目启动时执行的代码,例如:
// SaManager.setXxx(new SaXxxForXxx());
}
}

第二步:在项目的 resources\META-INF\satoken\ 文件夹下 📂 创建 cn.dev33.satoken.plugin.SaTokenPlugin 文件,内容为该插件注册类的完全限定名:


cn.dev33.satoken.plugin.SaTokenPluginForXxx

这样便可以在项目启动时,被 Sa-Token 插件管理器加载到此插件,执行插件注册类的 install 方法,完成插件安装 ✅。


文档直达地址:Sa-Token 插件开发指南 🔗


🎛️ 更新点3:重构缓存体系,将数据读写与序列化操作分离


在之前的版本中,Redis 集成通常和具体的序列化方式耦合在一起,这不仅让 Redis 相关插件产生大量的重复冗余代码,也让大家在选择 Redis 插件时严重受限。⚠️


本次版本更新彻底重构了此模块,将数据读写与序列化操作分离,使其每一块都可以单独自定义实现类,做到灵活扩展 ✨,例如:



  • 1️⃣ SaTokenDao 数据读写可以选择:RedisTemplate、Redisson、ConcurrentHashMap、Hutool-Timed-Cache 等不同实现类。

  • 2️⃣ SaSerializerTemplate 序列化器可以选择:Base64编码、Hex编码、ISO-8859-1编码、JSON序列化等不同方式。

  • 3️⃣ JSON 序列化可以选择:Jackson、Fastjson、Snack3 等组件。


所有实现类均可以按需选择,自由搭配,大大提高灵活性🏗️。


⚙️️ 更新点4:SaLoginParameter 登录参数类新增大量配置项


SaLoginParameter (前SaLoginModel) 用于控制登录操作中的部分细节行为,本次新增的配置项有:



  • isConcurrent:决定是否允许同一账号多地同时登录(为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)。🌍

  • isShare:在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)。🔄

  • maxLoginCount:同一账号最大登录数量,超出此数量的客户端将被自动注销,-1代表不限制数量。🚫

  • maxTryTimes:在创建 token 时的最高循环次数,用于保证 token 唯一性(-1=不循环尝试,直接使用。⏳

  • deviceId:此次登录的客户端设备id,用于判断后续某次登录是否为可信任设备。📱

  • terminalExtraData:本次登录挂载到 SaTerminalInfo 的自定义扩展数据。📦


以上大部分配置项在之前的版本中也有支持,不过它们都被定义在了全局配置类 SaTokenConfig 之上,本次更新支持在 SaLoginParameter 中定义这些配置项,
这将让登录策略的控制变得更加灵活。✨


🚪 更新点5:新增 SaLogoutParameter 注销参数类


SaLogoutParameter 用于控制注销操作中的部分细节行为️,例如:


通过 Range 参数决定注销范围 🎯:


// 注销范围: TOKEN=只注销当前 token 的会话,ACCOUNT=注销当前 token 指向的 loginId 其所有客户端会话
StpUtil.logout(new SaLogoutParameter().setRange(SaLogoutRange.TOKEN));

通过 DeviceType 参数决定哪些登录设备类型参与注销 💻:


// 指定 10001 账号,所有 PC 端注销下线,其它端如 APP 端不受影响 
StpUtil.logout(10001, new SaLogoutParameter().setDeviceType("PC"));

还有其它参数此处暂不逐一列举,文档直达地址:Sa-Token 登录参数 & 注销参数 🔗


🐞 更新点6:修复 StpUtil.setTokenValue("xxx")loginParameter.getIsWriteHeader() 空指针的问题。


这个没啥好说的,有 bug 🐛 必须修复。


fix issue:#IBKSM0 🔗


✨ 更新点7:API 参数签名模块升级



  • 1、新增了 @SaCheckSign 注解,现在 API 参数签名模块也支持注解鉴权了。🆕

  • 2、新增自定义签名的摘要算法,现在不仅可以 md5 算法计算签名,也支持 sha1、sha256 等算法了。🔐

  • 3、新增多应用模式:


多应用模式就是指,允许在对接多个系统时分别使用不同的秘钥等配置项,配置示例如下 📝:


sa-token: 
# API 签名配置 多应用模式
sign-many:
# 应用1
xm-shop:
secret-key: 0123456789abcdefg
digest-algo: md5
# 应用2
xm-forum:
secret-key: 0123456789hijklmnopq
digest-algo: sha256
# 应用3
xm-video:
secret-key: 12341234aaaaccccdddd
digest-algo: sha512

然后在签名时通过指定 appid 的方式获取对应的 SignTemplate 进行操作 👨💻:


// 创建签名示例
String paramStr = SaSignMany.getSignTemplate("xm-shop").addSignParamsAndJoin(paramMap);

// 校验签名示例
SaSignMany.getSignTemplate("xm-shop").checkRequest(SaHolder.getRequest());

⚡ 更新点8:新增 sa-token-caffeine 插件,用于整合 Caffeine


Caffeine 是一个基于 Java 的高性能本地缓存库,本次新增 sa-token-caffeine 插件用于将 Caffeine 作为 Sa-Token 的缓存层,存储会话鉴权数据。🚀
这进一步丰富了 Sa-Token 的缓存层插件生态。🌱


<!-- Sa-Token 整合 Caffeine -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-caffeine</artifactId>
<version>1.41.0</version>
</dependency>

🎪 更新点9:新增 sa-token-serializer-features 序列化扩展包


引入此插件可以为 Sa-Token 提供一些有意思的序列化方案。(娱乐向,不建议上生产 🎭)


例如:以base64 编码,采用:元素周期表 🧪、特殊符号 🔣、或 emoji 😊 作为元字符集存储数据 :


sa-custom-serializer-yszqb.png


sa-custom-serializer-tsfh.png


sa-custom-serializer-emoji.png


sa-custom-serializer-emoji2.png


📜 完整更新日志


除了以上提到的几点以外,还有更多更新点无法逐一详细介绍,下面是 v1.41.0 版本的完整更新日志:



  • core:

    • 修复:修复 StpUtil.setTokenValue("xxx")loginParameter.getIsWriteHeader() 空指针的问题。 fix: #IBKSM0

    • 修复:将 SaDisableWrapperInfo.createNotDisabled() 默认返回值封禁等级改为 -2,以保证向之前版本兼容。

    • 新增:新增基于 SPI 的插件体系。 [重要]

    • 重构:JSON 转换器模块。 [重要]

    • 新增:新增 serializer 序列化模块,控制 ObjectString 的序列化方式。 [重要]

    • 重构:重构防火墙模块,增加 hooks 机制。 [重要]

    • 新增:防火墙新增:请求 path 禁止字符校验、Host 检测、请求 Method 检测、请求头检测、请求参数检测。重构目录遍历符检测算法。

    • 重构:重构 SaTokenDao 模块,将序列化与存储操作分离。 [重要]

    • 重构:重构 SaTokenDao 默认实现类,优化底层设计。

    • 新增:isLastingCookie 配置项支持在全局配置中定义了。

    • 重构:SaLoginModel -> SaLoginParameter[不向下兼容]

    • 重构:TokenSign -> SaTerminalInfo[不向下兼容]

    • 新增:SaTerminalInfo 新增 extraData 自定义扩展数据设置。

    • 新增:SaLoginParameter 支持配置 isConcurrentisSharemaxLoginCountmaxTryTimes

    • 新增:新增 SaLogoutParameter,用于控制注销会话时的各种细节。 [重要]

    • 新增:新增 StpLogic#isTrustDeviceId 方法,用于判断指定设备是否为可信任设备。

    • 新增:新增 StpUtil.getTerminalListByLoginId(loginId)StpUtil.forEachTerminalList(loginId) 方法,以更方便的实现单账号会话管理。

    • 升级:API 参数签名配置支持自定义摘要算法。

    • 新增:新增 @SaCheckSign 注解鉴权,用于 API 签名参数校验。

    • 新增:API 参数签名模块新增多应用模式。 fix: #IAK2BI, #I9SPI1, #IAC0P9 [重要]

    • 重构:全局配置 is-share 默认值改为 false。 [不向下兼容]

    • 重构:踢人下线、顶人下线默认将删除对应的 token-session 对象。

    • 优化:优化注销会话相关 API。

    • 重构:登录默认设备类型值改为 DEF。 [不向下兼容]

    • 重构:BCrypt 标注为 @Deprecated

    • 新增:sa-token-quick-login 支持 SpringBoot3 项目。 fix: #IAFQNE#673

    • 新增:SaTokenConfig 新增 replacedRangeoverflowLogoutModelogoutRangeisLogoutKeepFreezeOpsisLogoutKeepTokenSession 配置项。



  • OAuth2:

    • 重构:重构 sa-token-oauth2 插件,使注解鉴权处理器的注册过程改为 SPI 插件加载。



  • 插件:

    • 新增:sa-token-serializer-features 插件,用于实现各种形式的自定义字符集序列化方案。

    • 新增:sa-token-fastjson 插件。

    • 新增:sa-token-fastjson2 插件。

    • 新增:sa-token-snack3 插件。

    • 新增:sa-token-caffeine 插件。



  • 单元测试:

    • 新增:sa-token-json-test json 模块单元测试。

    • 新增:sa-token-serializer-test 序列化模块单元测试。



  • 文档:

    • 新增:QA “多个项目共用同一个 redis,怎么防止冲突?”

    • 优化:补全 OAuth2 模块遗漏的相关配置项。

    • 优化:优化 OAuth2 简述章节描述文档。

    • 优化:完善 “SSO 用户数据同步 / 迁移” 章节文档。

    • 修正:补全项目目录结构介绍文档。

    • 新增:文档新增 “登录参数 & 注销参数” 章节。

    • 优化:优化“技术求助”按钮的提示文字。

    • 新增:新增 preview-doc.bat 文件,一键启动文档预览。

    • 完善:完善 Redis 集成文档。

    • 新增:新增单账号会话查询的操作示例。

    • 新增:新增顶人下线 API 介绍。

    • 新增:新增 自定义序列化插件 章节。



  • 其它:

    • 新增:新增 sa-token-demo/pom.xml 以便在 idea 中一键导入所有 demo 项目。

    • 删除:删除不必要的 .gitignore 文件

    • 重构:重构 sa-token-solon-plugin 插件。

    • 新增:新增设备锁登录示例。




更新日志在线文档直达链接:sa-token.cc/doc.html#/m…


🌟 其它


代码仓库地址:gitee.com/dromara/sa-…


框架功能结构图:


js


作者:省长
来源:juejin.cn/post/7484191942358499368
收起阅读 »

这个排队系统设计碉堡了

先赞后看,Java进阶一大半 各位好,我是南哥。 我在网上看到某厂最后一道面试题:如何设计一个排队系统? 关于系统设计的问题,大家还是要多多思考,可能这道题考的不是针对架构师的职位,而是关于你的业务设计能力。如果单单只会用开源软件的API,那似乎我们的竞争力...
继续阅读 »

先赞后看,Java进阶一大半



各位好,我是南哥。


我在网上看到某厂最后一道面试题:如何设计一个排队系统?


关于系统设计的问题,大家还是要多多思考,可能这道题考的不是针对架构师的职位,而是关于你的业务设计能力。如果单单只会用开源软件的API,那似乎我们的竞争力还可以再强些。学习设计东西、创作东西,把我们设计的产品给别人用,那竞争力一下子提了上来。


15岁的初中生开源了 AI 一站式 B/C 端解决方案chatnio,该产品在上个月被以几百万的价格收购了。这值得我们思考,程序创造力、设计能力在未来会变得越来越重要。


在这里插入图片描述



⭐⭐⭐收录在《Java学习/进阶/面试指南》:https://github/JavaSouth



精彩文章推荐



1.1 数据结构


排队的一个特点是一个元素排在另一个元素的后面,形成条状的队列。List结构、LinkedList链表结构都可以满足排队的业务需求,但如果这是一道算法题,我们要考虑的是性能因素。


排队并不是每个人都老老实实排队,现实会有多种情况发生,例如有人退号,那属于这个人的元素要从队列中删除;特殊情况安排有人插队,那插入位置的后面那批元素都要往后挪一挪。结合这个情况用LinkedList链表结构会更加合适,相比于List,LinkedList的性能优势就是增、删的效率更优。


但我们这里做的是一个业务系统,采用LinkedList这个结构也可以,不过要接受修改、维护起来困难,后面接手程序的人难以理解。大家都知道,在实际开发我们更常用List,而不是LinkedList。


List数据结构我更倾向于把它放在Redis里,有以下好处。


(1)数据存储与应用程序拆分。放在应用程序内存里,如果程序崩溃,那整条队列数据都会丢失。


(2)性能更优。相比于数据库存储,Redis处理数据的性能更加优秀,结合排队队列排完则销毁的特点,甚至可以不存储到数据库。可以补充排队记录到数据库里。


简单用Redis命令模拟下List结构排队的处理。


# 入队列(将用户 ID 添加到队列末尾)
127.0.0.1:6379> RPUSH queue:large user1
127.0.0.1:6379> RPUSH queue:large user2

#
出队列(将队列的第一个元素出队)
127.0.0.1:6379> LPOP queue:large

#
退号(从队列中删除指定用户 ID)
127.0.0.1:6379> LREM queue:large 1 user2

#
插队(将用户 ID 插入到指定位置,假设在 user1 之前插入 user3)
127.0.0.1:6379> LINSERT queue:large BEFORE user1 user3

1.2 业务功能


先给大家看看,南哥用过的费大厨的排队系统,它是在公众号里进行排队。


我们可以看到自己现在的排队进度。


在这里插入图片描述


同时每过 10 号,公众号会进行推送通知;如果 10 号以内,每过 1 号会微信公众号通知用户实时排队进度。最后每过 1 号就通知挺人性化,安抚用户排队的焦急情绪。


在这里插入图片描述


总结下来,我们梳理下功能点。虽然上面看起来是简简单单的查看、通知,背后可能隐藏许多要实现的功能。


在这里插入图片描述


1.3 后台端


(1)排队开始


后台管理员创建排队活动,后端在Redis创建List类型的数据结构,分别创建大桌、中桌、小桌三条队列,同时设置没有过期时间。


// 创建排队接口
@Service
public class QueueManagementServiceImpl {

@Autowired
private RedisTemplate<String, String> redisTemplate;

// queueType为桌型
public void createQueue(String queueType) {
String queueKey = "queue:" + queueType;
redisTemplate.delete(queueKey); // 删除队列,保证队列重新初始化
}
}


(2)排队操作


前面顾客用餐完成后,后台管理员点击下一号,在Redis的表现为把第一个元素从List中踢出,次数排队的总人数也减 1。


// 排队操作
@Service
public class QueueManagementServiceImpl {

@Autowired
private RedisTemplate<String, String> redisTemplate;

/**
* 将队列中的第一个用户出队
*/

public void dequeueNextUser(String queueType) {
String queueKey = "queue:" + queueType;
String userId = redisTemplate.opsForList().leftPop(queueKey);
}
}

1.4 用户端


(1)点击排队


用户点击排队,把用户标识添加到Redis队列中。


// 用户排队
@Service
public class QueueServiceImpl {

@Autowired
private RedisTemplate<String, String> redisTemplate;

public void enterQueue(String queueType, String userId) {
String queueKey = "queue:" + queueType;
redisTemplate.opsForList().rightPush(queueKey, userId);
log.info("用户 " + userId + " 已加入 " + queueType + " 队列");
}
}


(2)排队进度


用户可以查看三条队列的总人数情况,直接从Redis三条队列中查询队列个数。此页面不需要实时刷新,当然可以用WebSocket实时刷新或者长轮询,但具备了后面的用户通知功能,这个不实现也不影响用户体验。


而用户的个人排队进度,则计算用户所在队列前面的元素个数。


// 查询排队进度
@Service
public class QueueServiceImpl {

@Autowired
private RedisTemplate<String, String> redisTemplate;

public long getUserPositionInQueue(String queueType, String userId) {
String queueKey = "queue:" + queueType;
List<String> queue = redisTemplate.opsForList().range(queueKey, 0, -1);
if (queue != null) {
return queue.indexOf(userId);
}
return -1;
}
}


(3)用户通知


当某一个顾客用餐完成后,后台管理员点击下一号。此时后续的后端逻辑应该包括用户通知。


从三个队列里取出当前用户进度是 10 的倍数的元素,微信公众号通知该用户现在是排到第几桌了。


从三个队列里取出排名前 10 的元素,微信公众号通知该用户现在的进度。


// 用户通知
@Service
public class NotificationServiceImpl {

@Autowired
private RedisTemplate<String, String> redisTemplate;

private void notifyUsers(String queueType) {
String queueKey = "queue:" + queueType;
// 获取当前队列中的所有用户
List<String> queueList = jedis.lrange(queueKey, 0, -1);

// 通知排在10的倍数的用户
for (int i = 0; i < queueList.size(); i++) {
if ((i + 1) % 10 == 0) {
String userId = queueList.get(i);
sendNotification(userId, "您的排队进度是第 " + (i + 1) + " 位,请稍作准备!");
}
}

// 通知前10位用户
int notifyLimit = Math.min(10, queueList.size()); // 避免队列小于10时出错
for (int i = 0; i < notifyLimit; i++) {
String userId = queueList.get(i);
sendNotification(userId, "您已经在前 10 位,准备好就餐!");
}
}
}

这段逻辑应该移动到前面后台端的排队操作。


1.5 存在问题


上面的业务情况,实际上排队人员不会太多,一般会比较稳定。但如果每一条队列人数激增的情况下,可以预见到会有问题了。


对于Redis的List结构,我们需要查询某一个元素的排名情况,最坏情况下需要遍历整条队列,时间复杂度是O(n),而查询用户排名进度这个功能又是经常使用到。


对于上面情况,我们可以选择Redis另一种数据结构:Zset。有序集合类型Zset可以在O(lgn)的时间复杂度判断某元素的排名情况,使用ZRANK命令即可。


# zadd命令添加元素
127.0.0.1:6379> zadd 100run:ranking 13 mike
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 12 jake
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 16 tom
(integer) 1

# zrank命令查看排名
127.0.0.1:6379> zrank 100run:ranking jake
(integer) 0
127.0.0.1:6379> zrank 100run:ranking tom
(integer) 2

# zscore判断元素是否存在
127.0.0.1:6379> zscore 100run:ranking jake
"12"

我是南哥,南就南在Get到你的点赞点赞点赞。


在这里插入图片描述



创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️



作者:JavaSouth南哥
来源:juejin.cn/post/7436658089703145524
收起阅读 »

Spring 6.0 + Boot 3.0:秒级启动、万级并发的开发新姿势

Spring生态重大升级全景图 一、Spring 6.0核心特性详解 1. Java版本基线升级 最低JDK 17:全面拥抱Java模块化特性,优化现代JVM性能 虚拟线程(Loom项目):轻量级线程支持高并发场景(需JDK 19+) // 示例:虚拟...
继续阅读 »

Spring生态重大升级全景图


Spring 6.0 + Boot 3.0 技术体系.png




一、Spring 6.0核心特性详解


1. Java版本基线升级



  • 最低JDK 17:全面拥抱Java模块化特性,优化现代JVM性能

  • 虚拟线程(Loom项目):轻量级线程支持高并发场景(需JDK 19+)


// 示例:虚拟线程使用
Thread.ofVirtual().name("my-virtual-thread").start(() -> {
// 业务逻辑
});




    1. 虚拟线程(Project Loom)



  • 应用场景:电商秒杀系统、实时聊天服务等高并发场景


// 传统线程池 vs 虚拟线程
// 旧方案(平台线程)
ExecutorService executor = Executors.newFixedThreadPool(200);
// 新方案(虚拟线程)
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
// 处理10000个并发请求
IntStream.range(0, 10000).forEach(i ->
virtualExecutor.submit(() -> {
// 处理订单逻辑
processOrder(i);
})
);

2. HTTP接口声明式客户端



  • @HttpExchange注解:类似Feign的声明式REST调用


@HttpExchange(url = "/api/users")
public interface UserClient {
@GetExchange
List<User> listUsers();
}

应用场景:微服务间API调用


@HttpExchange(url = "/products", accept = "application/json")
public interface ProductServiceClient {
@GetExchange("/{id}")
Product getProduct(@PathVariable String id);
@PostExchange
Product createProduct(@RequestBody Product product);
}
// 自动注入使用
@Service
public class OrderService {
@Autowired
private ProductServiceClient productClient;

public void validateProduct(String productId) {
Product product = productClient.getProduct(productId);
// 校验逻辑...
}
}

3. ProblemDetail异常处理



  • RFC 7807标准:标准化错误响应格式


{
"type": "https://example.com/errors/insufficient-funds",
"title": "余额不足",
"status": 400,
"detail": "当前账户余额为50元,需支付100元"
}


  • 应用场景:统一API错误响应格式


@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleProductNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setType(URI.create("/errors/product-not-found"));
problem.setTitle("商品不存在");
problem.setDetail("商品ID: " + ex.getProductId());
return problem;
}
}
// 触发异常示例
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable String id) {
return productRepo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}

4. GraalVM原生镜像支持



  • AOT编译优化:启动时间缩短至毫秒级,内存占用降低50%+

  • 编译命令示例:


native-image -jar myapp.jar



二、Spring Boot 3.0突破性改进


1. 基础架构升级



  • Jakarta EE 9+:包名javax→jakarta全量替换

  • 自动配置优化:更智能的条件装配策略



    1. OAuth2授权服务器
      应用场景:构建企业级认证中心




# application.yml配置
spring:
security:
oauth2:
authorization-server:
issuer-url: https://auth.yourcompany.com
token:
access-token-time-to-live: 1h

定义权限端点


@Configuration
@EnableWebSecurity
public class AuthServerConfig {
@Bean
public SecurityFilterChain authServerFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
}

2. GraalVM原生镜像支持


应用场景:云原生Serverless函数


# 打包命令(需安装GraalVM)
mvn clean package -Pnative
# 运行效果对比
传统JAR启动:启动时间2.3s | 内存占用480MB
原生镜像启动:启动时间0.05s | 内存占用85MB

3. 增强监控(Prometheus集成)



  • Micrometer 1.10+:支持OpenTelemetry标准

  • 全新/actuator/prometheus端点:原生Prometheus格式指标

  • 应用场景:微服务健康监测


// 自定义业务指标
@RestController
public class OrderController {
private final Counter orderCounter = Metrics.counter("orders.total");
@PostMapping("/orders")
public Order createOrder() {
orderCounter.increment();
// 创建订单逻辑...
}
}
# Prometheus监控指标示例
orders_total{application="order-service"} 42
http_server_requests_seconds_count{uri="/orders"} 15



三、升级实施路线图


升级准备阶段.png


四、新特性组合实战案例


场景:电商平台升级


// 商品查询服务(组合使用新特性)
@RestController
public class ProductController {
// 声明式调用库存服务
@Autowired
private StockServiceClient stockClient;
// 虚拟线程处理高并发查询
@GetMapping("/products/{id}")
public ProductDetail getProduct(@PathVariable String id) {
return CompletableFuture.supplyAsync(() -> {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));

// 并行查询库存
Integer stock = stockClient.getStock(id);
return new ProductDetail(product, stock);
}, Executors.newVirtualThreadPerTaskExecutor()).join();
}
}



四、升级实践建议



  1. 环境检查:确认JDK版本≥17,IDE支持Jakarta包名

  2. 渐进式迁移

    • 先升级Spring Boot 3.x → 再启用Spring 6特性

    • 使用spring-boot-properties-migrator检测配置变更



  3. 性能测试:对比GraalVM原生镜像与传统JAR包运行指标


通过以上升级方案:



  1. 使用虚拟线程支撑万级并发查询

  2. 声明式客户端简化服务间调用

  3. ProblemDetail统一异常格式

  4. Prometheus监控接口性能




本次升级标志着Spring生态正式进入云原生时代。重点关注:虚拟线程的资源管理策略、GraalVM的反射配置优化、OAuth2授权服务器的定制扩展等深度实践方向。


作者:后端出路在何方
来源:juejin.cn/post/7476389305881296934
收起阅读 »

让闲置 Ubuntu 服务器华丽转身为家庭影院

让闲置 Ubuntu 服务器华丽转身为家庭影院在数字化的时代,家里的设备更新换代频繁,很容易就会有闲置的服务器吃灰。我家里就有一台闲置的 Ubuntu 24.04 服务器,一直放在角落,总觉得有些浪费。于是,我决定让它重新发挥作用,打造一个属于自己的家庭影院。...
继续阅读 »

让闲置 Ubuntu 服务器华丽转身为家庭影院

在数字化的时代,家里的设备更新换代频繁,很容易就会有闲置的服务器吃灰。我家里就有一台闲置的 Ubuntu 24.04 服务器,一直放在角落,总觉得有些浪费。于是,我决定让它重新发挥作用,打造一个属于自己的家庭影院。

一、实现 Windows 与 Ubuntu 服务器文件互通

要打造家庭影院,首先得让本地 Windows 电脑和 Ubuntu 服务器之间能够方便地传输电影文件。我选择安装 Samba 来实现这一目的。

  1. 安装 Samba:在 Ubuntu 服务器的终端中输入命令

    sudo apt-get install samba samba-common

    系统会自动下载并安装 Samba 相关的软件包。

  2. 备份配置文件:为了以防万一,我先将原来的 Samba 配置文件进行备份,执行命令

    mv /etc/samba/smb.conf /etc/samba/smb.conf.bak
  3. 新建配置文件:使用 vim /etc/samba/smb.conf 命令打开编辑器,写入以下配置内容:
[global]
server min protocol = CORE
workgroup = WORKGR0UP
netbios name = Nas
security = user
map to guest = bad user
guest account = nobody
client min protocol = SMB2
server min protocol = SMB2
server smb encrypt = off
[NAS]
comment = NASserver
path = /home/bddxg/nas
public = Yes
browseable = Yes
writable = Yes
guest ok = Yes
passdb backend = tdbsam
create mask = 0775
directory mask = 0775

这里需要注意的是,我计划的媒体库目录是个人目录下的 nas/,所以 path 是 /home/bddxg/nas ,如果大家要部署的话记得根据自己的实际情况修改为对应的位置。 

  1. 连接 Windows 电脑:在 Windows 电脑这边基本不需要什么复杂配置,因为在网络里无法直接看到 Ubuntu,我直接在电脑上添加了网络位置。假设服务器地址是 192.168.10.100,那么添加网络位置就是 \\192.168.10.100\nas,这样就可以在 Windows 电脑和 Ubuntu 服务器之间传输文件了。

二、安装 Jellyfin 搭建家庭影院

文件传输的问题解决后,接下来就是安装 Jellyfin 来实现家庭影院的功能了。

  1. 尝试 Docker 安装失败:一开始我选择使用 Docker 安装,毕竟 Docker 有很多优点,使用起来也比较方便。按照官网指南进行操作,在第三步启动 Docker 并挂载本地目录的时候却一直失败。报错信息为:

    docker: Error response from daemon: error while creating mount source path '/srv/jellyfin/cache': mkdir /srv/jellyfin: read-only file system.

    即使我给 /srv/jellyfin 赋予了 777 权限也没有效果。无奈之下,我决定放弃 Docker 安装方式,直接安装 server 版本的 Jellyfin。

  1. 安装 server 版本的 Jellyfin:在终端中输入命令 curl https://repo.jellyfin.org/install-debuntu.sh | sudo bash,安装过程非常顺利。

  1. 配置 Jellyfin:安装完成后,通过浏览器访问 http://192.168.10.100:8096 进入配置页面。在添加媒体库这里,我遇到了一个麻烦,网页只能选择到 /home/bddxg 目录,无法继续往下选择到我的媒体库位置 /home/bddxg/nas。于是我向 deepseek 求助,它告诉我需要执行命令:

    sudo usermod -aG bddxg jellyfin
    # 并且重启 Jellyfin 服务
    sudo systemctl restart jellyfin

    按照它的建议操作后,我刷新了网页,重新配置了 Jellyfin,终于可以正常添加媒体库了。

  2. 电视端播放:在电视上安装好 Jellyfin apk 客户端后,现在终于可以正常读取 Ubuntu 服务器上的影视资源了,坐在沙发上,享受着大屏观影的乐趣,这种感觉真的太棒了!

 通过这次折腾,我成功地让闲置的 Ubuntu 服务器重新焕发生机,变成了一个功能强大的家庭影院。希望我的经验能够对大家有所帮助,也欢迎大家一起交流更多关于服务器利用和家庭影院搭建的经验。

[!WARNING] 令人遗憾的是,目前 jellyfin 似乎不支持rmvb 格式的影片, 下载资源的时候注意影片格式,推荐直接下载 mp4 格式的资源


本次使用到的软件名称和版本如下:

软件名版本号安装命令
sambaVersion 4.19.5-Ubuntusudo apt-get install samba samba-common
jellyfinJellyfin.Server 10.10.6.0curl https://repo.jellyfin.org/install-debuntu.sh | sudo bash
ffmpeg(jellyfin 内自带)ffmpeg version 7.0.2-Jellyfinnull

作者:冰冻大西瓜
来源:juejin.cn/post/7476614823883833382

收起阅读 »

Mybatis接口方法参数不加@Param,照样流畅取值

在 MyBatis 中,如果 Mapper 接口的方法有多个参数,但没有使用 @Param 注解,默认情况下,MyBatis 会将这些参数放入一个 Map 中,键名为 param1、param2 等,或者使用索引 0、1 等来访问。以下是具体的使用方法和注意事...
继续阅读 »

在 MyBatis 中,如果 Mapper 接口的方法有多个参数,但没有使用 @Param 注解,默认情况下,MyBatis 会将这些参数放入一个 Map 中,键名为 param1param2 等,或者使用索引 01 等来访问。以下是具体的使用方法和注意事项。




一、Mapper 接口方法


假设有一个 Mapper 接口方法,包含多个参数但没有使用 @Param 注解:


public interface UserMapper {
User selectUserByNameAndAge(String name, int age);
}



二、XML 文件中的参数引用


在 XML 文件中,可以通过以下方式引用参数:


1. 使用 param1param2 等


MyBatis 会自动为参数生成键名 param1param2 等:


<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{param1} AND age = #{param2}
</select>

2. 使用索引 01 等


也可以通过索引 01 等来引用参数:


<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{0} AND age = #{1}
</select>



三、注意事项



  1. 可读性问题



    • 使用 param1param2 或索引 01 的方式可读性较差,容易混淆。

    • 建议使用 @Param 注解明确参数名称。



  2. 参数顺序问题



    • 如果参数顺序发生变化,XML 文件中的引用也需要同步修改,容易出错。



  3. 推荐使用 @Param 注解



    • 使用 @Param 注解可以为参数指定名称,提高代码可读性和可维护性。


      public interface UserMapper {
      User selectUserByNameAndAge(@Param("name") String name, @Param("age") int age);
      }

      XML 文件:


      <select id="selectUserByNameAndAge" resultType="User">
      SELECT * FROM user WHERE name = #{name} AND age = #{age}
      </select>







四、示例代码


1. Mapper 接口


public interface UserMapper {
User selectUserByNameAndAge(String name, int age);
}

2. XML 文件


<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{param1} AND age = #{param2}
</select>

或者:


<select id="selectUserByNameAndAge" resultType="User">
SELECT * FROM user WHERE name = #{0} AND age = #{1}
</select>

3. 测试代码


SqlSession sqlSession = sqlSessionFactory.openSession();
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectUserByNameAndAge("John", 25);
System.out.println(user);
sqlSession.close();




  • 如果 Mapper 接口方法有多个参数且没有使用 @Param 注解,可以通过 param1param2 或索引 01 等方式引用参数。

  • 这种方式可读性较差,容易出错,推荐使用 @Param 注解明确参数名称。

  • 使用 @Param 注解后,XML 文件中的参数引用会更加清晰和易于维护。


作者:码农liuxin
来源:juejin.cn/post/7475643579781333029
收起阅读 »

Java web后端转Java游戏后端

作为Java后端开发者转向游戏后端开发,虽然核心编程能力相通,但游戏开发在架构设计、协议选择、实时性处理等方面有显著差异。以下从实际工作流程角度详细说明游戏后端开发的核心要点及前后端协作流程: 一、游戏后端核心职责 实时通信管理 采用WebSocket/...
继续阅读 »

作为Java后端开发者转向游戏后端开发,虽然核心编程能力相通,但游戏开发在架构设计、协议选择、实时性处理等方面有显著差异。以下从实际工作流程角度详细说明游戏后端开发的核心要点及前后端协作流程:




一、游戏后端核心职责



  1. 实时通信管理



    • 采用WebSocket/TCP长连接(90%以上MMO游戏选择)

    • 使用Netty/Mina框架处理高并发连接(单机支撑5W+连接是基本要求)

    • 心跳机制设计(15-30秒间隔,检测断线)



  2. 游戏逻辑处理



    • 战斗计算(需在50ms内完成复杂技能伤害计算)

    • 状态同步(通过Delta同步优化带宽,减少60%数据传输量)

    • 定时器管理(Quartz/时间轮算法处理活动开启等)



  3. 数据持久化



    • Redis集群缓存热点数据(玩家属性缓存命中率需>95%)

    • 分库分表设计(例如按玩家ID取模分128个库)

    • 异步落库机制(使用Disruptor队列实现每秒10W+写入)






二、开发全流程实战(以MMORPG为例)


阶段1:预研设计(2-4周)



  • 协议设计
    // 使用Protobuf定义移动协议
    message PlayerMove {
    int32 player_id = 1;
    Vector3 position = 2; // 三维坐标
    float rotation = 3; // 朝向
    int64 timestamp = 4; // 客户端时间戳
    }

    message BattleSkill {
    int32 skill_id = 1;
    repeated int32 target_ids = 2; // 多目标锁定
    Coordinate cast_position = 3; // 技能释放位置
    }


  • 架构设计
    graph TD
    A[Gateway] --> B[BattleServer]
    A --> C[SocialServer]
    B --> D[RedisCluster]
    C --> E[MySQLCluster]
    F[MatchService] --> B



阶段2:核心系统开发(6-8周)



  1. 网络层实现


    // Netty WebSocket处理器示例
    @ChannelHandler.Sharable
    public class GameServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
    ProtocolMsg msg = ProtocolParser.parse(frame.text());
    switch (msg.getType()) {
    case MOVE:
    handleMovement(ctx, (MoveMsg)msg);
    break;
    case SKILL_CAST:
    validateSkillCooldown((SkillMsg)msg);
    broadcastToAOI(ctx.channel(), msg);
    break;
    }
    }
    }


  2. AOI(Area of Interest)管理



    • 九宫格算法实现视野同步

    • 动态调整同步频率(近距离玩家100ms/次,远距离500ms/次)



  3. 战斗系统



    • 采用确定性帧同步(Lockstep)

    • 使用FixedPoint替代浮点数运算保证一致性






三、前后端协作关键点



  1. 协议版本控制



    • 强制版本校验:每个消息头包含协议版本号


    {
    "ver": "1.2.3",
    "cmd": 1001,
    "data": {...}
    }


  2. 调试工具链建设



    • 开发GM指令系统:


    /debug latency 200  // 模拟200ms延迟
    /simulate 5000 // 生成5000个机器人


  3. 联调流程



    • 使用Wireshark抓包分析时序问题

    • Unity引擎侧实现协议回放功能

    • 自动化测试覆盖率要求:

      • 基础协议:100%

      • 战斗用例:>85%








四、性能优化实践



  1. JVM层面



    • G1GC参数优化:


    -XX:+UseG1GC -XX:MaxGCPauseMillis=50 
    -XX:InitiatingHeapOccupancyPercent=35


  2. 网络优化



    • 启用Snappy压缩协议(降低30%流量)

    • 合并小包(Nagle算法+50ms合并窗口)



  3. 数据库优化



    • 玩家数据冷热分离:

      • 热数据:位置、状态(Redis)

      • 冷数据:成就、日志(MySQL)








五、上线后运维



  1. 监控体系



    • 关键指标报警阈值设置:

      • 单服延迟:>200ms

      • 消息队列积压:>1000

      • CPU使用率:>70%持续5分钟





  2. 紧急处理预案



    • 自动扩容规则:
      if conn_count > 40000:
      spin_up_new_instance()
      if qps > 5000:
      enable_rate_limiter()







六、常见问题解决方案


问题场景:战斗不同步

排查步骤



  1. 对比客户端帧日志与服务端校验日志

  2. 检查确定性随机数种子一致性

  3. 验证物理引擎的FixedUpdate时序


问题场景:登录排队

优化方案



  1. 令牌桶限流算法控制进入速度

  2. 预计等待时间动态计算:
    wait_time = current_queue_size * avg_process_time / available_instances



通过以上流程,Java后端开发者可逐步掌握游戏开发特性,重点需要转变的思维模式包括:从请求响应模式到实时状态同步、从CRUD主导到复杂逻辑计算、从分钟级延迟到毫秒级响应的要求。建议从简单的棋牌类游戏入手,逐步过渡到大型实时游戏开发。


作者:加瓦点灯
来源:juejin.cn/post/7475292103146684479
收起阅读 »

记一次 CDN 流量被盗刷经历

先说损失,被刷了 70 多RMB,还好止损相对即时了,亏得不算多,PCDN 真可恶啊。 600多G流量,100多万次请求。 怎么发现的 先是看到鱼皮大佬发了一篇推文突发,众多网站流量被盗刷!我特么也中招了。 抱着看热闹的心情点开阅读了。。。心想,看看自己的中...
继续阅读 »

先说损失,被刷了 70 多RMB,还好止损相对即时了,亏得不算多,PCDN 真可恶啊。



600多G流量,100多万次请求。


怎么发现的


先是看到鱼皮大佬发了一篇推文突发,众多网站流量被盗刷!我特么也中招了


抱着看热闹的心情点开阅读了。。。心想,看看自己的中招没,结果就真中招了 🍉。


被盗刷资源分析


笔者在 缤纷云七牛云又拍云 都有存放一些图片资源。本次中招的是 缤纷云,下面是被刷的资源。



IP来源


查了几个 IP 和文章里描述的大差不差,都是来自山西联通的请求。



大小流量计算


按日志时间算的话,QPS 大概在 20 左右,单文件 632 K,1分钟大概就760MB ,1小时约 45G 左右。


看了几天前的日志,都是 1 小时刷 40G 就停下,从 9 点左右开始,刷到 12 点。


07-0907-08

但是 10 号的就变多了,60-70 GB 1次了。也是这天晚上才开始做的反制,不知道是不是加策略的时候影响到它计算流量大小了 😝。



反制手段


Referer 限制


通过观察这些资源的请求头,发现 Referer 和请求资源一致,通常情况下,不应该这样,应该是笔者的博客地址https://sugarat.top



于是第一次就限制了 Referer 头不能为空,同时将 cdn.bitiful.sugarat.top 的来源都拉黑。


这个办法还比较好使,后面的请求都给 403 了。



但这个还是临时解决方案,在 V 站上看到讨论,说资源是人为筛选的,意味着 Referer 换个资源还是会发生变化。


IP 限制


有 GitHub 仓库 unclemcz/ban-pcdn-ip 收集了此次恶意刷流量的 IP。


CDN 平台一般支持按 IP 或 IP 段屏蔽请求(虽然后者可能会屏蔽一些正常请求),可以将 IP 段配置到平台上,这样就能限制掉这些 IP 的请求。


缤纷云上这块限制还比较弱,我就直接把缤纷云的 CDN 直接关了,七牛云和又拍云上都加上了 IP 和 地域运营商的限制,等这阵风头过去再恢复。


七牛云又拍云

限速


限制单 IP 的QPS和峰值流量。



但是这个只能避免说让它刷得慢一点,还是不治本。



最后


用了CDN的话,日常还是多看看,能加阈值控制的平台优先加上,常规的访问控制防盗链的啥的安排上。



作者:粥里有勺糖
来源:juejin.cn/post/7390678994998526003
收起阅读 »

新来的总监,把闭包讲得那叫一个透彻

😃文章首发于公众号[精益码农]。 闭包作为前端面试的必考题目,常让1-3年工作经验的Javascripter感到困惑,我的主力语言C#/GO均有闭包。 1. 闭包:关键点在于函数是否捕获了其外部作用域的变量 闭包的形成: 定义函数时, 函数引用了其外部作用域的...
继续阅读 »

😃文章首发于公众号[精益码农]。


闭包作为前端面试的必考题目,常让1-3年工作经验的Javascripter感到困惑,我的主力语言C#/GO均有闭包。


1. 闭包:关键点在于函数是否捕获了其外部作用域的变量


闭包的形成: 定义函数时, 函数引用了其外部作用域的变量, 之后就形成了闭包。


闭包的结果: 引用的变量和定义的函数都会一同存在(即使已经脱离了函数定义/引用的变量的作用域),一直到闭包被消灭。


    public  static Action Closure()
{
var x = 1;
Action action= () =>
{
var y = 1;
var result = x + y;
Console.WriteLine(result);
x++;
};
return action;
}

public static void Main() {
var a=Closure();
a();
a();
}
// 调用函数输出
2
3

委托action是一个函数,它使用了“x”这个外部作用域的变量(x变量不是函数内局部变量),变量引用将被捕获形成闭包。


即使action被返回了(即使“x”已经脱离了它被引用时的作用域环境(Closure)),但是两次执行能输出2,3 说明它脱离原引用环境仍然能用。




当你在代码调试器(debugger)里观察“action”时,可以看到一个Target属性,里面封装了捕获的x变量:




实际上,委托,匿名函数和lambda都是继承自Delegate类
Delegate不允许开发者直接使用,只有编译器才能使用, 也就是说delegate Action都是语法糖。



  • Method:MethodInfo反射类型- 方法执行体

  • Target:当前委托执行的对象,这些语法糖由编译器生成了继承自Delegate类型的对象,包含了捕获的自由变量。



再给一个反例:


public class Program
{
private static int x = 1; // 静态字段
public static void Main()
{
var action = NoClosure();
action();
action();
}

public static Action NoClosure(){
Action action=()=>{
var y =1;
var sum = x+y;
Console.WriteLine($"sum = { sum }");
x++;
};
return action;
}
}

x 是静态字段,在程序中有独立的存储区域, 不在线程的函数堆栈区,不属于某个特定的作用域。


匿名函数使用了 x,但没有捕获外部作用域的变量,因此不构成闭包, Target属性对象无捕获的字段。


从编程设计的角度:闭包开创了除全局变量传值, 函数参数传值之外的第三种变量使用方式。


2. 闭包的形成时机和效果


闭包是词法闭包的简称,维基百科上是这样定义的:

在计算机科学中,闭包是在词法环境中绑定自由变量的一等函数”。


闭包的形成时机:



  • 一等函数

  • 外部作用域变量


闭包的形态:

会捕获闭包函数内引用的外部作用域变量, 一直持有,直到闭包函数不再使用被销毁。



内部实现是形成了一个对象(包含执行函数和捕获的变量,参考Target对象), 只有形成堆内存,才有后续闭包销毁的行为,当闭包这个对象不再被引用时,闭包被GC清理。



闭包的作用周期:


离不开作用域这个概念,函数理所当然管控了函数内的局部变量作用域,但当它引用了外部有作用域的变量时, 就形成了闭包函数。
当闭包(例如一个委托或 lambda 表达式)不再被任何变量、对象或事件持有引用时,它就变成了“不可达”对象, 闭包被gc清理,其实就是堆内存被清理。


2.1 一等函数


一等函数很容易理解,就是在各语言, 函数被认为是某类数据类型, 定义函数就成了定义变量, 函数也可以像变量一样被传递。


很明显,在C#中我们常使用的匿名函数、lambda表达式都是一等函数。


Func<string,string> myFunc = delegate(string var1)
{
return "some value";
};
Func<string,string> myFunc = var1 => "some value";

string myVar = myFunc("something");

2.2 自由变量


在函数中被引用的外部作用域变量, 注意, 这个变量是外部有作用域的变量,也就说排除全局变量(这些变量在程序的独立区域, 不属于任何作用域)。


public void Test() 
{
var myVar = "this is good";
Func<string,string> myFunc = delegate(string var1)
{
return var1 + myVar;
};
}

上面这个示例,myFunc形成了闭包,捕获了myVar这个外部作用域的变量;
即使Test函数返回了委托myFunc(脱离了定义myVar变量的作用域),闭包依然持有myVar的变量引用,
注意,引用变量,并不是使用当时变量的副本值


我们再回过头来看结合了线程调度的闭包面试题。


3. 闭包函数关联线程调度: 依次打印连续的数字


 static void Closure1()
{
for (int i = 0; i < 10; i++)
{
Task.Run(()=> Console.WriteLine(i));
}
}

每次输出数字不固定


并不是预期的 0.1.2.3.4.5.6.7.8.9


首先形成了闭包函数()=> Console.WriteLine(i), 捕获了外部有作用域变量i的引用, 此处捕获的变量i相对于函数是全局变量。
但是Task调度闭包函数的时机不确定, 所以打印的是被调度时引用的变量i值。


数字符合但乱序:为每个闭包函数绑定独立变量


循环内增加局部变量, 解绑全局变量 (或者可以换成foreach,foreach相当于内部给你整了一个局部变量)。


能输出乱序的0,1,2,3,4,5,6,7,8,9


因为每次循环内产生的闭包函数捕获了对应的局部变量j,这样每个任务执行环境均独立维护了一个变量j, 这个j不是全局变量, 但是由于Task启动时机依然不确定,故是乱序。



数字符合且有序


核心是解决 Task调度问题。


思路是:一个共享变量,每个任务打印该变量自增的一个阶段,但是该自增不允许被打断。


 public static void Main(string[] args)
{
var s =0;
var lo = new Program();
for (int i = 0; i < 10; i++)
{
Task.Run(()=>
{
lock(lo)
{
Console.WriteLine(s); // 依然形成了闭包函数, 之后闭包函数被线程调度
s++;
}
});
}
Thread.Sleep(2000);
} // 上面是一个明显的锁争用

3.Golang闭包的应用


gin 框架中中间件的默认形态是:


package middleware
func AuthenticationMiddleware(c *gin.Context) {
......
}

// Use方法的参数签名是这样: type HandlerFunc func(*Context), 不支持入参
router.Use(middleware.AuthenticationMiddleware)

实际实践上我们又需要给中间件传参, 闭包提供了这一能力。


func Authentication2Middleware(log *zap.Logger) gin.HandlerFunc  {
return func(c *gin.Context) {
... 这里面可以利用log 参数。
}
}

var logger *zap.Logger
api.Use(middleware.Authentication2Middleware(logger))

总结


本文屏蔽语言差异,理清了[闭包]的概念核心: 函数引用了其外部作用域的变量,


核心特征:一等函数、自由变量,核心结果: 即使脱离了原捕获变量的原作用域,闭包函数依然持有该变量引用。


不仅能帮助我们应对多语种有关闭包的面试题, 也帮助我们了解[闭包]在通用语言中的设计初衷。


另外我们通过C# 调试器巩固了Delegate 抽象类,这是lambda表达式,委托,匿名函数的底层抽象数据结构类,包含两个重要属性 Method Target,分别表征了方法执行体、当前委托作用的对象,


可想而知,其他语言也是通过这个机制捕获闭包当中的自由变量。


作者:不卷牛马
来源:juejin.cn/post/7474982751365038106
收起阅读 »

Java利用Deepseek进行项目代码审查

一、为什么需要AI代码审查?写代码就像做饭,即使是最有经验的厨师(程序员),也难免会忘记关火(资源未释放)、放错调料(逻辑错误)或者切到手(空指针异常)。Deepseek就像一位24小时待命的厨房监理,能帮我们实时发现这些"安全隐患"。二、环境准备(5分钟搞定...
继续阅读 »

一、为什么需要AI代码审查?

写代码就像做饭,即使是最有经验的厨师(程序员),也难免会忘记关火(资源未释放)、放错调料(逻辑错误)或者切到手(空指针异常)。Deepseek就像一位24小时待命的厨房监理,能帮我们实时发现这些"安全隐患"。

二、环境准备(5分钟搞定)

  1. 安装Deepseek插件(以VSCode为例):
    • 插件市场搜索"Deepseek Code Review"
    • 点击安装(就像安装手机APP一样简单)

  1. Java项目配置:

<dependency>
<groupId>com.deepseekgroupId>
<artifactId>code-analyzerartifactId>
<version>1.3.0version>
dependency>

三、真实案例:用户管理系统漏洞检测

原始问题代码:

public class UserService {
// 漏洞1:未处理空指针
public String getUserRole(String userId) {
return UserDB.query(userId).getRole();
}

// 漏洞2:资源未关闭
public void exportUsers() {
FileOutputStream fos = new FileOutputStream("users.csv");
fos.write(getAllUsers().getBytes());
}

// 漏洞3:SQL注入风险
public void deleteUser(String input) {
Statement stmt = conn.createStatement();
stmt.execute("DELETE FROM users WHERE id = " + input);
}
}

使用Deepseek审查后:

智能修复建议:

  1. 空指针防护 → 建议添加Optional处理
  2. 流资源 → 推荐try-with-resources语法
  3. SQL注入 → 提示改用PreparedStatement

修正后的代码:

public class UserService {
// 修复1:Optional处理空指针
public String getUserRole(String userId) {
return Optional.ofNullable(UserDB.query(userId))
.map(User::getRole)
.orElse("guest");
}

// 修复2:自动资源管理
public void exportUsers() {
try (FileOutputStream fos = new FileOutputStream("users.csv")) {
fos.write(getAllUsers().getBytes());
}
}

// 修复3:预编译防注入
public void deleteUser(String input) {
PreparedStatement pstmt = conn.prepareStatement(
"DELETE FROM users WHERE id = ?");
pstmt.setString(1, input);
pstmt.executeUpdate();
}
}

四、实现原理揭秘

Deepseek的代码审查就像"X光扫描仪",通过以下三步工作:

  1. 模式识别:比对数千万个代码样本
    • 就像老师批改作业时发现常见错误
  1. 上下文理解:分析代码的"人际关系"
    • 数据库连接有没有"成对出现"(打开/关闭)
    • 敏感操作有没有"保镖"(权限校验)
  1. 智能推理:预测代码的"未来"
    • 这个变量走到这里会不会变成null?
    • 这个循环会不会变成"无限列车"?

五、进阶使用技巧

  1. 自定义审查规则(配置文件示例):
rules:
security:
sql_injection: error
performance:
loop_complexity: warning
style:
var_naming: info

2. 与CI/CD集成(GitHub Action示例):

- name: Deepseek Code Review
uses: deepseek-ai/code-review-action@v2
with:
severity_level: warning
fail_on: error

六、开发者常见疑问

Q:AI会不会误判我的代码?
A:就像导航偶尔会绕路,Deepseek给出的是"建议"而非"判决",最终决策权在你手中

Q:处理历史遗留项目要多久?
A:10万行代码项目约需3-5分钟,支持增量扫描

七、效果对比数据

指标人工审查Deepseek+人工
平均耗时4小时30分钟
漏洞发现率78%95%
误报率5%12%
知识库更新速度季度实时

作者:Java技术小馆
来源:juejin.cn/post/7473799336675639308

收起阅读 »

再见Typora,这款大小不到3M的Markdown编辑器,满足你的所有幻想!

Typora 是一款广受欢迎的 Markdown 编辑器,以其所见即所得的编辑模式和优雅的界面而闻名,长期以来是许多 Markdown 用户的首选。然而,从 2021 年起,Typora 不再免费,采用一次性付费授权模式。虽然费用不高,但对于轻量使用者或预算有...
继续阅读 »

Typora 是一款广受欢迎的 Markdown 编辑器,以其所见即所得的编辑模式和优雅的界面而闻名,长期以来是许多 Markdown 用户的首选。然而,从 2021 年起,Typora 不再免费,采用一次性付费授权模式。虽然费用不高,但对于轻量使用者或预算有限的用户可能并不友好。



今天来推荐一款开源替代品,一款更加轻量化、注重隐私且完全免费的 Markdown 编辑器,专为 macOS 用户开发。


项目介绍


MarkEdit 是一款轻量级且高效的 Markdown 编辑器,专为 macOS 用户设计,安装包大小不到 3 MB。它以简洁的设计和流畅的性能,成为技术写作、笔记记录、博客创作以及项目文档编辑的理想工具。无论是编写技术文档、撰写博客文章,还是编辑 README 文件,MarkEdit 都能以快速响应和便捷操作帮助用户专注于内容创作。


图片


根据官方介绍,MarkEdit 免费的原因如下:



MarkEdit 是完全免费和开源的,没有任何广告或其他服务。我们之所以发布它,是因为我们喜欢它,我们不期望从中获得任何收入。



功能特性


MarkEdit 的核心功能围绕 Markdown 写作展开,注重实用与高效,以下是其主要特性:



  • 实时语法高亮:清晰呈现 Markdown 的结构,让文档层次分明。

  • 多种主题:提供不同的配色方案,总有一种适合你。

  • 分屏实时预览:支持所见即所得的写作体验,左侧编辑,右侧实时渲染。

  • 文件树视图:适合多文件项目管理,方便在项目间快速切换。

  • 文档导出:支持将 Markdown 文件导出为 PDF 或 HTML 格式,方便分享和发布。

  • CodeMirror 插件支持:通过插件扩展功能,满足更多 Markdown 使用需求。

  • ......


MarkEdit 的特点让它能胜任多种写作场合:



  • 技术文档:帮助开发者快速记录项目相关文档。

  • 博客创作:支持实时预览,让博客排版更直观。

  • 个人笔记:轻量且启动迅速,适合日常记录。

  • 项目文档:文件管理功能让多文件项目的编辑更加高效。


效果展示


多种主题风格,总有一种适合你:




实时预览,让博客排版更直观:



设置界面,清晰直观:



安装方法


方法 1:安装包下载


找到 MarkEdit 的最新版本安装包下载使用即可,地址:github.com/MarkEdit-ap…


方法 2:通过 Homebrew


在终端中运行相关命令即可完成安装。


brew install markedit

注意:MarkEdit 支持 macOS Sonoma 和 macOS Sequoia, 历史兼容版本包括 macOS 12 和 macOS 13。


总结


MarkEdit 是一款专注于 Markdown 写作的 macOS 原生编辑器,以简洁、高效、隐私友好为核心设计理念。无论是日常写作还是处理复杂文档,它都能提供流畅的体验和强大的功能。对于追求高效写作的 macOS 用户来说,MarkEdit 是一个不可多得的优秀工具。


项目地址:github.com/MarkEdit-ap…


作者:Github掘金计划
来源:juejin.cn/post/7456685819047919651
收起阅读 »

前端哪有什么设计模式

前言 常网IT源码上线啦! 本篇录入吊打面试官专栏,希望能祝君拿下Offer一臂之力,各位看官感兴趣可移步🚶。 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用...
继续阅读 »

前言



  • 常网IT源码上线啦!

  • 本篇录入吊打面试官专栏,希望能祝君拿下Offer一臂之力,各位看官感兴趣可移步🚶。

  • 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。

  • 接下来想分享一些自己在项目中遇到的技术选型以及问题场景。





你生命的前半辈子或许属于别人,活在别人的认为里。那把后半辈子还给你自己,去追随你内在的声音。



1.jpg


一、前言


之前在讨论设计模式、算法的时候,一个后端组长冷嘲热讽的说:前端哪有什么设计模式、算法,就好像只有后端语言有一样,至今还记得那不屑的眼神。


今天想起来,就随便列几个,给这位眼里前端无设计模式的人,睁眼看世界。


二、观察者模式 (Observer Pattern)


观察者模式的核心是当数据发生变化时,自动通知并更新相关的视图。在 Vue 中,这通过其响应式系统实现。


Vue 2.x:Object.defineProperty


在 Vue 2.x 中,响应式系统是通过 Object.defineProperty 实现的。每当访问某个对象的属性时,getter 会被触发;当设置属性时,setter 会触发,从而实现数据更新时视图的重新渲染。


源码(简化版):


function defineReactive(obj, key, val) {
// 创建一个 dep 实例,用于收集依赖
const dep = new Dep();

Object.defineProperty(obj, key, {
get() {
// 当访问属性时,触发 getter,并把当前 watcher 依赖收集到 dep 中
if (Dep.target) {
dep.addDep(Dep.target);
}
return val;
},
set(newVal) {
if (newVal !== val) {
val = newVal;
dep.notify(); // 数据更新时,通知所有依赖重新渲染
}
}
});
}


  • Dep :它管理依赖,addDep 用于添加依赖,notify 用于通知所有依赖更新。


class Dep {
constructor() {
this.deps = [];
}

addDep(dep) {
this.deps.push(dep);
}

notify() {
this.deps.forEach(dep => dep.update());
}
}


  • 依赖收集:当 Vue 组件渲染时,会创建一个 watcher 对象,表示一个视图的更新需求。当视图渲染过程中访问数据时,getter 会触发,并将 watcher 添加到 dep 的依赖列表中。


Vue 3.x:Proxy


Vue 3.x 使用了 Proxy 来替代 Object.defineProperty,从而实现了更高效的响应式机制,支持深度代理。


源码(简化版):


function reactive(target) {
const handler = {
get(target, key) {
// 依赖收集:当访问某个属性时,触发 getter,收集依赖
track(target, key);
return target[key];
},
set(target, key, value) {
// 数据更新时,通知相关的视图更新
target[key] = value;
trigger(target, key);
return true;
}
};

return new Proxy(target, handler);
}



  • track:收集依赖,确保只有相关组件更新。

  • trigger:当数据发生变化时,通知所有依赖重新渲染。


三、发布/订阅模式 (Publish/Subscribe Pattern)


发布/订阅模式通过中央事件总线(Event Bus)实现不同组件间的解耦,Vue 2.x 中,组件间的通信就是基于这种模式实现的。


Vue 2.x:事件总线(Event Bus)


事件总线就是一个中央的事件处理器,Vue 实例可以充当事件总线,用来处理不同组件之间的消息传递。


// 创建一个 Vue 实例作为事件总线
const EventBus = new Vue();

// 组件 A 发布事件
EventBus.$emit('message', 'Hello from A');

// 组件 B 订阅事件
EventBus.$on('message', (msg) => {
console.log(msg); // 输出 'Hello from A'
});


  • $emit:用于发布事件。

  • $on:用于订阅事件。

  • $off:用于取消订阅事件。


四、工厂模式 (Factory Pattern)


工厂模式通过一个函数生成对象或实例,Vue 的组件化机制和动态组件加载就是通过工厂模式来实现的。


Vue 的 render 函数和 functional 组件支持动态生成组件实例。例如,functional 组件本质上是一个工厂函数,通过给定的 props 返回一个 VNode。


Vue.component('dynamic-component', {
functional: true,
render(h, context) {
// 工厂模式:根据传入的 props 创建不同的 VNode
return h(context.props.type);
}
});


  • functional 组件:它没有实例,所有的逻辑都是在 render 函数中处理,返回的 VNode 就是组件的“产物”。


五、单例模式 (Singleton Pattern)


单例模式确保某个类只有一个实例,Vue 实例就是全局唯一的。


在 Vue 中,全局的 Vue 构造函数本身就是一个单例对象,通常只会创建一个 Vue 实例,用于管理应用的生命周期和全局配置。


const app = new Vue({
data: {
message: 'Hello, Vue!'
}
});


  • 单例保证:整个应用只有一个 Vue 实例,所有全局的配置(如 Vue.config)都是共享的。


六、模板方法模式 (Template Method Pattern)


模板方法模式定义了一个操作中的算法框架,而将一些步骤延迟到子类中。Vue 的生命周期钩子就是一个模板方法模式的实现。


Vue 定义了一系列生命周期钩子(如 createdmountedupdated 等),它们实现了组件从创建到销毁的完整过程。开发者可以在这些钩子中插入自定义逻辑。


Vue.component('my-component', {
data() {
return {
message: 'Hello, 泽!'
};
},
created() {
console.log('Component created');
},
mounted() {
console.log('Component mounted');
},
template: '<div>{{ message }}</div>'
});

Vue 组件的生命周期钩子实现了模板方法模式的核心思想,开发者可以根据需要重写生命周期钩子,而 Vue 保证生命周期的流程和框架。


七、策略模式 (Strategy Pattern)


策略模式通过定义一系列算法,将它们封装起来,使它们可以相互替换。Vue 的 计算属性(computed)方法(methods) 可以看作是策略模式的应用。


计算属性允许我们定义动态的属性,其值是基于其他属性的计算结果。Vue 会根据依赖关系缓存计算结果,只有在依赖的属性发生变化时,计算属性才会重新计算。


new Vue({
data() {
return {
num1: 10,
num2: 20
};
},
computed: {
sum() {
return this.num1 + this.num2;
}
}
});

八、装饰器模式 (Decorator Pattern)


装饰器模式允许动态地给对象添加功能,而无需改变其结构。在 Vue 中,指令就是一种装饰器模式的应用,它通过指令来动态地改变元素的行为。


<div v-bind:class="className"></div>
<div v-if="isVisible">谁的疯太谍</div>

这些指令动态地修改 DOM 元素的行为,类似于装饰器在不修改对象结构的情况下,动态地增强其功能。


九、代理模式 (Proxy Pattern)


代理模式通过创建一个代理对象来控制对目标对象的访问。在 Vue 3.x 中,响应式系统就是通过 Proxy 来代理对象的访问。


vue3


const state = reactive({
count: 0
});

state.count++; // 会触发依赖更新

reactive:使用 Proxy 对对象进行代理,当对象的属性被访问或修改时,都会触发代理器的 get 和 set 操作。


function reactive(target) {
const handler = {
get(target, key) {
// 依赖收集:当访问某个属性时,触发 getter,收集依赖
track(target, key);
return target[key];
},
set(target, key, value) {
// 数据更新时,触发依赖更新
target[key] = value;
trigger(target, key);
return true;
}
};

return new Proxy(target, handler);
}


  • track:当读取目标对象的属性时,收集依赖,这通常涉及到将当前的 watcher 加入到依赖列表中。

  • trigger:当对象的属性发生改变时,通知所有相关的依赖(如组件)更新。


这个 Proxy 机制使得 Vue 可以动态地观察和更新对象的变化,比 Object.defineProperty 更具灵活性。


十、适配器模式 (Adapter Pattern)


适配器模式用于将一个类的接口转换成客户端期望的另一个接口,使得原本不兼容的接口可以一起工作。Vue 的插槽(Slots)和组件的跨平台支持某种程度上借用了适配器模式的思想。


Vue 插槽机制


Vue 的插槽机制是通过提供一个适配层,将父组件传入的内容插入到子组件的指定位置。开发者可以使用具名插槽、作用域插槽等方式,实现灵活的插槽传递。


<template>
<child-component>
<template #header>
<h1>This is the header</h1>
</template>
<p>This is the default content</p>
</child-component>

</template>

父组件通过 #header 插槽插入了一个标题内容,而 child-component 会将其插入到适当的位置。这里,插槽充当了一个适配器,允许父组件插入的内容与子组件的内容结构灵活匹配。


十全十美


至此撒花~


后记


我相信技术不分界,不深入了解,就不要轻易断言。


一个圆,有了一个缺口,不知道的东西就更多了。


但是没有缺口,不知道的东西就少了。


这也就是为什么,知道得越多,不知道的就越多。


谢谢!


最后,祝君能拿下满意的offer。


我是Dignity_呱,来交个朋友呀,有朋自远方来,不亦乐乎呀!深夜末班车



👍 如果对您有帮助,您的点赞是我前进的润滑剂。



以往推荐


小小导出,我大前端足矣!


靓仔,说一下keep-alive缓存组件后怎么更新及原理?


面试官问我watch和computed的区别以及选择?


面试官问我new Vue阶段做了什么?


前端仔,快把dist部署到Nginx上


多图详解,一次性啃懂原型链(上万字)


Vue-Cli3搭建组件库


Vue实现动态路由(和面试官吹项目亮点)


项目中你不知道的Axios骚操作(手写核心原理、兼容性)


VuePress搭建项目组件文档


原文链接


juejin.cn/post/744421…


作者:Dignity_呱
来源:juejin.cn/post/7444215159289102347
收起阅读 »

再见 XShell!一款万能通用的终端工具,用完爱不释手!

作为一名后端开发,我们经常需要使用终端工具来管理Linux服务器。最近发现一款比Xshell更好用终端工具XPipe,能支持SSH、Docker、K8S等多种环境,还具有强大的文件管理工具,分享给大家! XPipe简介 XPipe是一款全新的终端管理工具,具...
继续阅读 »

作为一名后端开发,我们经常需要使用终端工具来管理Linux服务器。最近发现一款比Xshell更好用终端工具XPipe,能支持SSH、Docker、K8S等多种环境,还具有强大的文件管理工具,分享给大家!



XPipe简介


XPipe是一款全新的终端管理工具,具有强大的文件管理功能,目前在Github上已有4.8k+Star。它可以基于你本地安装的命令行工具(例如PowerShell)来执行远程命令,反应速度非常快。如果你有使用 ssh、docker、kubectl 等命令行工具来管理服务器的需求,使用它就可以了。


XPipe具有如下特性:



  • 连接中心:能轻松实现所有类型的远程连接,支持SSH、Docker、Podman、Kubernetes、Powershell等环境。

  • 强大的文件管理功能:具有对远程系统专门优化的文件管理功能。

  • 多种命令行环境支持:包括bash、zsh、cmd、PowerShell等。

  • 多功能脚本系统:可以方便地管理可重用脚本。

  • 密码保险箱:所有远程连接账户均完全存储于您本地系统中的一个加密安全的存储库中。


下面是XPipe使用过程中的截图,界面还是挺炫酷的!




这或许是一个对你有用的开源项目,mall项目是一套基于 SpringBoot3 + Vue 的电商系统(Github标星60K),后端支持多模块和2024最新微服务架构 ,采用Docker和K8S部署。包括前台商城项目和后台管理系统,能支持完整的订单流程!涵盖商品、订单、购物车、权限、优惠券、会员、支付等功能!



项目演示:



使用



  • 首先去XPipe的Release页面下载它的安装包,我这里下载的是Portable版本,解压即可使用,地址:github.com/xpipe-io/xp…




  • 下载完成后进行解压,解压后双击xpiped.exe即可使用;




  • 这里我们先进行一些设置,将语言设置成中文,然后设置下主题,个人比较喜欢黑色主题;




  • 接下来新建一个SSH连接,输入服务器地址后,选择添加预定义身份




  • 这个预定义身份相当于一个可重用的Linux访问账户;




  • 然后输入连接名称,点击完成即可创建连接;




  • 我们可以发现XPipe能自动发现服务器器上的Docker环境并创建连接选项,如果你安装了K8S环境的话,也是可以发现到的;




  • 然后我们单击下Linux-local这个连接,就可以通过本地命令行工具来管理Linux服务器了;




  • 如果你想连接到某个Docker容器的话,直接点击对应容器即可连接,这里以mysql为例;




  • 选中左侧远程服务器,点击右侧的文件浏览器按钮可以直接管理远程服务器上的文件,非常方便;




  • 所有脚本功能中,可以存储我们的可重用脚本;




  • 所有身份中存储着我们的账号密码,之前创建的Linux root账户在这里可以进行修改。



总结


今天给大家分享了一款好用的终端工具XPipe,界面炫酷功能强大,它的文件管理功能确实惊艳到我了。而且它可以用本地命令行工具来执行SSH命令,对比一些套壳的跨平台终端工具,反应速度还是非常快的!


项目地址


github.com/xpipe-io/xp…


作者:MacroZheng
来源:juejin.cn/post/7475662844789637160
收起阅读 »

Java 泛型中的通配符 T,E,K,V,?有去搞清楚吗?

前言不久前,被人问到Java 泛型中的通配符 T,E,K,V,? 是什么?有什么用?这不经让我有些回忆起该开始学习Java那段日子,那是对泛型什么的其实有些迷迷糊糊的,学的不这么样,是在做项目的过程中,渐渐有又看到别人的代码、在看源码的时候老是遇见,之后就专门...
继续阅读 »

前言

不久前,被人问到Java 泛型中的通配符 T,E,K,V,? 是什么?有什么用?这不经让我有些回忆起该开始学习Java那段日子,那是对泛型什么的其实有些迷迷糊糊的,学的不这么样,是在做项目的过程中,渐渐有又看到别人的代码、在看源码的时候老是遇见,之后就专门去了解学习,才对这几个通配符 T,E,K,V,?有所了解。

泛型有什么用?

在介绍这几个通配符之前,我们先介绍介绍泛型,看看泛型带给我们的好处。
Java泛型是JDK5中引入的一个新特性,泛型提供了编译是类型安全检测机制,这个机制允许开发者在编译是检测非法类型。泛型的本质就是参数化类型,就是在编译时对输入的参数指定一个数据类型。

  1. 类型安全:编译是检查类型是否匹配,避免了ClassCastexception的发生。
// 非泛型写法(存在类型转换风险)
List list1 = new ArrayList();
list1.add("a");
Integer num = (Long) list1.get(0); // 运行时抛出 ClassCastException

// 泛型写法(编译时检查类型)
List list2 = new ArrayList<>();
// list.add(1); // 编译报错
list2.add("a");
String str = list2.get(0); // 无需强制转换
  1. 消除代码强制类型转换:减少了一些类型转换操作。
// 非泛型写法
Map map1 = new HashMap();
map1.put("user", new User());
User user1 = (User) map1.get("user");

// 泛型写法
Map map2 = new HashMap<>();
map2.put("user", new User());
// 自动转换
User user2 = map2.get("user");

3.代码复用:可以支持多种数据类型,不要重复编写代码,例如:我们常用的统一响应结果类。

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
/**
* 响应状态码
*/

private int code;

/**
* 响应信息
*/

private String message;

/**
* 响应数据
*/

private T data;

/**
* 时间戳
*/

private long timestamp;
其他代码省略...
  1. 增强可读性:通过类型参数就直接能看出要填入什么类型。
List list = new ArrayList<>();

泛型里的通配符

我们在使用泛型的时候,经常会使用或者看见多种不同的通配符,常见的 T,E,K,V,?这几种,相信大家一定不陌生,但是真的问你他们有什么作用?有什么区别时,很多人应该是不能很好的介绍它们的,接下来我就来给大家介绍介绍。

T,E,K,V

  1. T(Type) T表示任意类型参数,我们举个例子
pubile class A{
prvate T t;
//其他省略...
}

//创建一个不带泛型参数的A
A a = new A();
a.set(new B());
B b = (B) a.get();//需要进行强制类型转换

//创建一个带泛型参数的A
A a = new A();
a.set(new B());
B b = a.get();
  1. E(Element) E表示集合中的元素类型
List list = new ArrayList<>();
  1. K(Key) K表示映射的键的数据类型
Map map = new HashMap<>();
  1. V(Value) V表示映射的值的数据类型
Map map = new HashMap<>();

通配符 ?

  1. 无界通配符 表示未知类型,接收任意类型
   // 使用无界通配符处理任意类型的查询结果
public void logQueryResult(List resultList) {
resultList.forEach(obj -> log.info("Result: {}", obj));
}
  1. 上界通配符 表示类型是T或者是子类
 // 使用上界通配符读取缓存
public extends Serializable> T getCache(String key, Class clazz) {
Object value = redisTemplate.opsForValue().get(key);
return clazz.cast(value);
}
  1. 下界通配符 表示类型是T或者是父类
  // 使用下界通配符写入缓存
public void setCache(String key, super Serializable> value) {
redisTemplate.opsForValue().set(key, value);
}


综合示例:

import java.util.ArrayList;
import java.util.List;

public class Demo {
//实体类
class Animal {
void eat() {
System.out.println("Animal is eating");
}
}

class Dog extends Animal {
@Override
void eat() {
System.out.println("Dog is eating");
}
}

class Husky extends Dog {
@Override
void eat() {
System.out.println("Husky is eating");
}
}

/**
* 无界通配符
*/

// 只能读取元素,不能写入(除null外)
public static void printAllElements(List list) {
for (Object obj : list) {
System.out.println(obj);
}
// list.add("test"); // 编译错误!无法写入具体类型
list.add(null); // 唯一允许的写入操作
}

/**
* 上界通配符
*/

// 安全读取为Animal,但不能写入(生产者场景)
public static void processAnimals(List animals) {
for (Animal animal : animals) {
animal.eat();
}
// animals.add(new Dog()); // 编译错误!无法确定具体子类型
}

/**
* 下界通配符
*/

// 安全写入Dog,读取需要强制转换(消费者场景)
public static void addDogs(Listsuper Dog> dogList) {
dogList.add(new Dog());
dogList.add(new Husky()); // Husky是Dog子类
// dogList.add(new Animal()); // 编译错误!Animal不是Dog的超类

Object obj = dogList.get(0); // 读取只能为Object
if (obj instanceof Dog) {
Dog dog = (Dog) obj; // 需要显式类型转换
}
}

public static void main(String[] args) {
// 测试无界通配符
List strings = List.of("A", "B", "C");
printAllElements(strings);

List integers = List.of(1, 2, 3);
printAllElements(integers);

// 测试上界通配符
List dogs = new ArrayList<>();
dogs.add(new Dog());
processAnimals(dogs);

List huskies = new ArrayList<>();
huskies.add(new Husky());
processAnimals(huskies);

// 测试下界通配符
List animals = new ArrayList<>();
addDogs(animals);
System.out.println(animals);

List objects = new ArrayList<>();
addDogs(objects);
}
}

我们需要清楚,这些只是我们开发过程中约定,不是强制规定,但遵循它们可以提高代码的可读性。



我们在很多时候只是单纯的会使用某些技术,但是对它们里面许许多多常见的都是一知半解的,只是会使用确实很重要,但是如果有时间,我们不妨好好的在对这些技术进行深入学习,不仅知其然,而且知其所以然,这样我们的技术才会不断提升进步。


作者:镜花水月linyi
来源:juejin.cn/post/7475629913329008649

总结

收起阅读 »

入职第一天,看了公司代码,牛马沉默了

入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。打开代码发现问题不断读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置 一边获取WEB-INF下的配置文件,一...
继续阅读 »

入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。

4f7ca8c685324356868f65dd8862f101~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg

打开代码发现问题不断

  1. 读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置

image.png

image.png

image.png 一边获取WEB-INF下的配置文件,一边用外部配置文件进行覆盖,有人可能会问既然覆盖,那可以全在外部配置啊,问的好,如果全用外部配置,咱们代码获取属性有的加上了项目前缀(上面的两个put),有的没加,这样配置文件就显得很乱不可取,所以形成了分开配置的局面,如果接受混乱,就写在外部配置;不能全写在内部配置,因为

prop_c.setProperty(key, value);

value获取外部配置为空的时候会抛出异常;properties底层集合用的是hashTable

public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
}
  1. 很多参数写死在代码里,如果有改动,工作量会变得异常庞大,举例权限方面伪代码
role.haveRole("ADMIN_USE")
  1. 日志打印居然sout和log混合双打

image.png

image.png

先不说双打的事,对于上图这个,应该输出包括堆栈信息,不然定位问题很麻烦,有人可能会说e.getMessage()最好,可是生产问题看多了发现还是打堆栈好;还有如果不是定向返回信息,仅仅是记录日志,完全没必要catch多个异常,一个Exception足够了,不知道原作者这么写的意思是啥;还是就是打印日志要用logger,用sout打印在控制台,那我日志文件干啥;

4.提交的代码没有技术经理把关,下发生产包是个人就可以发导致生产环境代码和本地代码或者数据库数据出现不一致的现象,数据库数据的同步是生产最容易忘记执行的一个事情;比如我的这家公司上传文件模板变化了,但是没同步,导致出问题时开发环境复现问题真是麻烦;

5.随意更改生产数据库,出不出问题全靠开发的职业素养;

6.Maven依赖的问题,Maven引pom,而pom里面却是另一个pom文件,没有生成的jar供引入,是的,我们可以在dependency里加上

<type>pom

来解决这个问题,但是公司内的,而且实际也是引入这个pom里面的jar的,我实在不知道这么做的用意是什么,有谁知道;求教 a972880380654b389246a3179add2cca~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg

以上这些都是我最近一家公司出现的问题,除了默默接受还能怎么办;

那有什么优点呢:

  1. 不用太怎么写文档
  2. 束缚很小
  3. 学到了js的全局调用怎么写的(下一篇我来写,顺便巩固一下)

解决之道

怎么解决这些问题呢,首先对于现有的新项目或升级的项目来说,spring的application.xml/yml 完全可以写我们的配置,开发环境没必要整外部文件,如果是生产环境我们可以在脚本或启动命令添加 nohup java -Dfile.encoding=UTF-8 -Dspring.config.location=server/src/main/config/application.properties -jar xxx.jar & 来告诉jar包引哪里的配置文件;也可以加上动态配置,都很棒的,

其次就是规范代码,养成良好的规范,跟着节奏,不要另辟蹊径;老老实实的,如果原项目上迭代,不要动源代码,追加即可,没有时间去重构的;

我也曾是个快乐的童鞋,也有过崇高的理想,直到我面前堆了一座座山,脚下多了一道道坑,我。。。。。。!


作者:小红帽的大灰狼
来源:juejin.cn/post/7371986999164928010
收起阅读 »

为什么程序员痴迷于错误信息上报?

前言上一篇已经聊过日志上报的调度原理,讲述如何处理日志上报堆积、上报失败以及上报优化等方案。从上家公司开始,监控就由我们组身强体壮的同事来负责,而我只能开发Admin和H5;经过一系列焦虑的面试后,咸鱼翻身,这辈子我也做上监控了。千万不要以为我是因为监控的重要...
继续阅读 »

前言

上一篇已经聊过日志上报的调度原理,讲述如何处理日志上报堆积、上报失败以及上报优化等方案。

从上家公司开始,监控就由我们组身强体壮的同事来负责,而我只能开发AdminH5;经过一系列焦虑的面试后,咸鱼翻身,这辈子我也做上监控了。千万不要以为我是因为监控的重要性才这么执着,人往往得不到的东西才是最有吸引力的。

在写这篇文章时,我也在思考,为什么走到哪里都会有一群程序员喜欢封装监控呢?即使换个公司、换个组,依然可能需要有人来迭代监控。嗯,话不多说,先点关注,正文开始

错误监控的核心价值

如果让你封装一个前端监控,你会怎么设计监控的上报优先级?

对于一个网页来说,能否带给用户好的体验,核心指标就是 白屏时长 和 FMP时长,这两项指标直接影响用户的 留存率 和 体验

下面通过数据加强理解:

  • 白屏时间 > 3秒 导致用户流失率上升47%
  • 接口错误率 > 0.5%  造成订单转化率下降23%
  • JS错误数 > 1/千次访问 预示着系统稳定性风险

设想一下,当你访问页面时白屏等待了3秒,并且页面没有骨架屏或者Loading态时,你会不会觉得这个页面挂了?这时候如果我们的监控优先关注的是性能,可能用户已经退出了,我们的上报还没调用到。

在这个白屏等待的过程中,JS Error可能已经打印在控制台了,接口可能已经返回了错误信息,但是程序员却毫无感知。

优先上报错误信息,本质是为了提升生产环境的错误响应速度、减少生产环境的损失、提高上线流程的规范。以下是错误响应的黄金时间轴:

时间窗口响应动作业务影响
< 1分钟自动熔断异常接口避免错误扩散
1-5分钟触发告警通知值班人员降低MTTR(平均修复时间)
>5分钟生成故障诊断报告优化事后复盘流程

重要章节

一:错误类型,你需要关注的五大场景

技术本质:任何错误收集系统都需要先明确错误边界。前端错误主要分为两类: 显性错误(直接阻断执行)和 隐性错误(资源加载、异步异常等)。

// 显性错误(同步执行阶段)
function criticalFunction() {
undefinedVariable.access(); // ReferenceError
}

// 隐性错误(异步场景)
fetchData().then(() => {
invalidJSON.parse(); // 异步代码中的错误
});

关键分类: 通过错误本质将前端常见错误分为5种类型,图示如下。 image.png

  1. 语法层错误(SyntaxError)
    ESLint 可拦截,但运行时需注意动态语法(如 eval,这个用法不推荐)。
  2. 运行时异常
    错误的时机场景大部分是在页面渲染完成后,用户对页面发生交互行为,触发JS执行异常。以下是模拟报错的一个例子,用于学习。 // 典型场景 element.addEventListener('click', () => { throw new Error('Event handler crash'); });
  3. 资源加载失败
    常见的资源比如图片、JS脚本、字体文件、外链引入的三方依赖等。我们可以通过全局监听处理,比如使用document.addEventListener('error', handler, true)来捕获资源加载失败的情况。但需要注意以下几点:

收起阅读 »

某程序员自曝:凡是打断点调试代码的,都不是真正的程序员,都是外行

大家好,我是大明哥,一个专注 「死磕 Java」 的硬核程序员。 某天我在逛今日头条的时候,看到一个大佬,说凡是打断点调试代码的,都不是真正的程序员,都是外行。 我靠,我敲了 10 多年代码,打了 10 多年的断点,竟然说我是外行!!我还说,真正的大佬都是...
继续阅读 »

大家好,我是大明哥,一个专注 「死磕 Java」 的硬核程序员。



某天我在逛今日头条的时候,看到一个大佬,说凡是打断点调试代码的,都不是真正的程序员,都是外行。



我靠,我敲了 10 多年代码,打了 10 多年的断点,竟然说我是外行!!我还说,真正的大佬都是用文档编辑器来写代码呢!!!


其实,打断点不丢脸,丢脸的是工作若干年后只知道最基础的断点调试!大明哥就见过有同事因为 for 循环里面实体对象报空指针异常,不知道怎么调试,选择一条一条得看,极其浪费时间!!所以,大明哥来分享一些 debug 技巧,赶紧收藏,日后好查阅!!


Debug 分类


对于很多同学来说,他们几乎就只知道在代码上面打断点,其实断点可以打在多个地方。


行断点


行断点的投标就是一个红色的圆形点。在需要断点的代码行头点击即可打上:



方法断点


方法断点就是将断点打在某个具体的方法上面,当方法执行的时候,就会进入断点。这个当我们阅读源码或者跟踪业务流程时比较有用。尤其是我们在阅读源码的时候,我们知道优秀的源码(不优秀的源码你也不会阅读)各种设计模式使用得飞起,什么策略、模板方法等等。具体要走到哪个具体得实现,还真不是猜出来,比如下面代码:


public interface Service {
void test();
}

public class ServiceA implements Service{
@Override
public void test() {
System.out.println("ServiceA");
}
}


public class ServiceB implements Service{
@Override
public void test() {
System.out.println("ServiceB");
}
}


public class ServiceC implements Service{
@Override
public void test() {
System.out.println("ServiceC");
}
}


public class DebugTest {
public static void main(String[] args) {
Service service = new ServiceA();
service.test();
}
}

在运行时,你怎么知道他要进入哪个类的 test() 方法呢?有些小伙伴可能就会在 ServiceAServiceBServiceC 中都打断点(曾经我也是这么干的,初学者可以理解...),这样就可以知道进入哪个了。其实我们可以直接在接口 Servicetest() 方法上面打断点,这样也是可以进入具体的实现类的方法:



当然,也可以在方法调用的地方打断点,进入这个断点后,按 F7 就可以了。


属性断点


我们也可以在某个属性字段上面打断点,这样就可以监听这个属性的读写变化过程。比如,我们定义这样的:


@Getter
@Setter
@AllArgsConstructor
public class Student {

private String name;

private Integer age;
}

public class ServiceA implements Service{
@Override
public void test() {
Student student = new Student("张三",12);

System.out.println(student.getName());

student.setName("李四");
}
}

如下:



断点技巧


条件断点


在某些场景下,我们需要在特定的条件进入断点,尤其是 for 循环中(我曾经在公司看到一个小伙伴在循环内部看 debug 数据,惊呆我了),比如下面代码:


public class DebugTest {

public static void main(String[] args) {
List<Student> studentList = new ArrayList<>();
for (int i = 1 ; i < 1000 ; i++) {
if (new Random().nextInt(100) % 10 == 0) {
studentList.add(new Student("" + i, i));
} else {
studentList.add(new Student("" + i, i));
}
}

for (Student student : studentList) {
System.out.println(student.toString());
}
}
}

我们在 System.out.println(student.toString()); 打个断点,但是要 name"skjava" 开头时才进入,这个时候我们就可以使用条件断点了:



条件断点是非常有用的一个断点技巧,对于我们调试复杂的业务场景,尤其是 for、if 代码块时,可以节省我们很多的调试时间。


模拟异常


这个技巧也是很有用,在开发阶段我们就需要人为制造异常场景来验证我们的异常处理逻辑是否正确。比如如下代码:


public class DebugTest {

public static void main(String[] args) {
methodA();

try {
methodB();
} catch (Exception e) {
e.printStackTrace();
// do something
}

methodC();
}

public static void methodA() {
System.out.println("methodA...");
}

public static void methodB() {
System.out.println("methodA...");
}

public static void methodC() {
System.out.println("methodA...");
}
}

我们希望在 methodB() 方法中抛出异常,来验证 catch(Exception e) 中的 do something 是否处理正确。以前大明哥是直接在 methodB() 中 throw 一个异常,或者 1 / 0。这样做其实并没有什么错,只不过不是很优雅,同时也会有一个风险,就是可能会忘记删除这个测试代码,将异常提交上去了,最可怕的还是上了生产。


所以,我们可以使用 idea 模拟异常。



  • 我们首先在 methodB() 打上一个断点

  • 运行代码,进入断点处

  • 在 Frames 中找到对应的断点记录,右键,选择 Throw Execption

  • 输入你想抛出的异常,点击 ok 即可



这个技巧在我们调试异常场景时非常有用!!!


多线程调试


不知道有小伙伴遇到过这样的场景:在你和前端进行本地调试时,你同时又要调试自己写的代码,前端也要访问你的本地调试,这个时候你打断点了,前端是无法你本地的。为什么呢?因为 Idea 在 debug 时默认阻塞级别为 ALL,如果你进入 debug 场景了,idea 就会阻塞其他线程,只有当前调试线程完成后才会走其他线程。


这个时候,我们可以在 View Breakpoints 中选择 Thread,同时点击 Make Default设置为默认选项。这样,你就可以调试你的代码,前端又可以访问你的应用了。



或者



调试 Stream


Java 中的 Stream 好用是好用,但是依然有一些小伙伴不怎么使用它,最大的一个原因就是它不好调试。你利用 Stream 处理一个 List 对象后,发现结果不对,但是你很难判断到底是哪一行出来问题。我们看下面代码:


public class DebugTest {

public static void main(String[] args) {
List<Student> studentList = new ArrayList<>();
for (int i = 1; i < 1000; i++) {
if (new Random().nextInt(100) % 10 == 0) {
studentList.add(new Student("" + i, i));
} else {
studentList.add(new Student("" + i, i));
}
}

studentList = studentList.stream()
.filter(student -> student.getName().startsWith(""))
.peek(item -> {
item.setName(item.getName() + "-**");
item.setAge(item.getAge() * 10);
}).collect(Collectors.toList());
}
}

在 stream() 打上断点,运行代码,进入断点后,我们只需要点击下图中的按钮:




在这个窗口中会记录这个 Stream 操作的每一个步骤,我们可以点击每个标签来看数据处理是否符合预期。这样是不是就非常方便了。


有些小伙伴的 idea 版本可能过低,需要安装 Java Stream Debugger 插件才能使用。


操作回退


我们 debug 调试的时候肯定不是一行一行代码的调试,而是在每个关注点处打断点,然后跳着看。但是跳到某个断点处时,突然发现有个变量的值你没有关注到需要回退到这个变量值的赋值处,这个时候怎么办?我们通常的做法是重新来一遍。虽然,可以达到我们的预期效果,但是会比较麻烦,其实 idea 有一个回退断点的功能,非常强大。在 idea 中有两种回退:



  • Reset Frame


看下面代码:


public class DebugTest {

public static void main(String[] args) {
int a = 1;
int b = 2;
int c = (a + b) * 2;

int d = addProcessor(a, b,c);

System.out.println();
}

private static int addProcessor(int a, int b, int c) {
a = a++;
b = b++;
return a + b + c;
}
}

我们在 addProcessor()return a + b + c; 打上断点,到了这里 ab 的值已经发生了改变,如果我们想要知道他们两的原始值,就只能回到开始的地方。idea 提供了一个 Reset Frame 功能,这个功能可以回到上一个方法处。如下图:




  • Jump To Line


Reset Frame 虽然可以用,但是它有一定的局限性,它只能方法级别回退,是没有办法向前或向后跳着我们想要执行的代码处。但 Jump To Line 可以做到。


Jump To Line 是一个插件,所以,需要先安装它。



由于大明哥使用的 idea 版本是 2024.2,这个插件貌似不支持,所以就在网上借鉴了一张图:



在执行到 debug 处时,会出现一个黄颜色的箭头,我们可以将这个箭头拖动到你想执行的代码处就可以了。向前、向后都可以,是不是非常方便。


目前这 5 个 debug 技巧是大明哥在工作中运用最多的,还有一个就是远程 debug 调试,但是这个我个人认为是野路子,大部分公司一般是不允许这么做的,所以大明哥就不演示了!


作者:大明哥_
来源:juejin.cn/post/7470185977434144778
收起阅读 »

产品:大哥,你这列表查询有问题啊!

前言 👳‍♂️产品大哥(怒气冲冲跑过来): “大哥你这查询列表有问题啊,每次点一下查询,返回的数据不一样呢” 👦我:“FKY 之前不是说好的吗,加了排序查询很卡,就取消了” 🧔技术经理:“卡主要是因为分页查询加了排序之后,mybatisPlus 生成的 cou...
继续阅读 »

前言


👳‍♂️产品大哥(怒气冲冲跑过来): “大哥你这查询列表有问题啊,每次点一下查询,返回的数据不一样呢”


👦:“FKY 之前不是说好的吗,加了排序查询很卡,就取消了”


🧔技术经理:“卡主要是因为分页查询加了排序之后,mybatisPlus 生成的 count 也会有Order by就 很慢,自己实现一个count 就行了”


👦:“分页插件在执行统计操作的时候,一般都会对Sql 简单的优化,会去掉排序的”



今天就来看看分页插件处理 count 的时候的优化逻辑,是否能去除order by


同时 简单阐述一下 order bylimit 的运行原理


往期好文:最近发现一些同事的代码问题



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 优化逻辑


主要优化的是以下两点



  1. 去除 SQl 中的order by

  2. 去除 left join


哪些情况count 优化限制



  1. SQL 中 有 这些集合操作的 INTERSECT,EXCEPT,MINUS,UNION 直接不优化count

  2. 包含groupBy 不去除orderBy

  3. order by 里带参数,不去除order by

  4. 查看select 字段中是否动态条件,如果有条件字段,则不会优化 Count SQL

  5. 包含 distinct、groupBy不优化

  6. 如果 left join 是子查询,并且子查询里包含 ?(代表有入参) 或者 where 条件里包含使用 join 的表的字段作条件,就不移除 join

  7. 如果 where 条件里包含使用 join 的表的字段作条件,就不移除 join

  8. 如果 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 的执行顺序大致如下:



  1. 从数据表中读取所有符合条件的数据(包括排序和过滤)。

  2. 将数据按照 ORDER BY 排序。

  3. 根据 LIMIT 参数选择返回的记录:

    • 跳过前 10000 行数据(这个过程是通过丢弃数据来实现的)。

    • 然后返回接下来的 100 行数据。




所以,LIMIT 是先检索所有符合条件的数据,然后丢弃掉前面的行,再返回指定的行数。这解释了为什么如果数据集很大,LIMIT 会带来性能上的一些问题,尤其是在有很大的偏移量(比如 LIMIT 10000, 100)时。


总结


本篇文章分析,mybatisPlus 分页插件处理count sql 的逻辑,以及优化过程,同时也简单分析order bylimit 执行原理。


希望这篇文章能够让你对SQL优化 有不一样的认知,最后感谢各位老铁一键三连!



ps: 云服务器找我返点;面试宝典私;收徒ING;



作者:提前退休的java猿
来源:juejin.cn/post/7457934738356338739
收起阅读 »

字节2面:为了性能,你会违反数据库三范式吗?

大家好,我是猿java。 数据库的三大范式,它是数据库设计中最基本的三个规范,那么,三大范式是什么?在实际开发中,我们一定要严格遵守三大范式吗?这篇文章,我们一起来聊一聊。 1. 三大范式 1. 第一范式(1NF,确保每列保持原子性) 第一范式要求数据库中的每...
继续阅读 »

大家好,我是猿java


数据库的三大范式,它是数据库设计中最基本的三个规范,那么,三大范式是什么?在实际开发中,我们一定要严格遵守三大范式吗?这篇文章,我们一起来聊一聊。


1. 三大范式


1. 第一范式(1NF,确保每列保持原子性)


第一范式要求数据库中的每个表格的每个字段(列)都具有原子性,即字段中的值不可再分割。换句话说,每个字段只能存储一个单一的值,不能包含集合、数组或重复的组。


如下示例: 假设有一个学生表 Student,结构如下:


学生ID姓名电话号码
1张三123456789, 987654321
2李四555555555

在这个表中,电话号码字段包含多个号码,违反了1NF的原子性要求。为了满足1NF,需要将电话号码拆分为单独的记录或创建一个新的表。


满足 1NF后的设计:


学生表 Student


学生ID姓名
1张三
2李四

电话表 Phone


电话ID学生ID电话号码
11123456789
21987654321
32555555555

1.2 第二范式(2NF,确保表中的每列都和主键相关)


第二范式要求满足第一范式,并且消除表中的部分依赖,即非主键字段必须完全依赖于主键,而不是仅依赖于主键的一部分。这主要适用于复合主键的情况。


如下示例:假设有一个订单详情表 OrderDetail,结构如下:


订单ID商品ID商品名称数量单价
1001A01苹果102.5
1001A02橙子53.0
1002A01苹果72.5

在上述表中,主键是复合主键 (订单ID, 商品ID)商品名称单价只依赖于复合主键中的商品ID,而不是整个主键,存在部分依赖,违反了2NF。


满足 2NF后的设计:


订单详情表 OrderDetail


订单ID商品ID数量
1001A0110
1001A025
1002A017

商品表 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用户姓名用户地址订单日期总金额
1001U01张三北京市2023-10-01500元
1002U02李四上海市2023-10-02300元

2.2 简化查询和开发


严格规范化可能导致数据库结构过于复杂,增加开发和维护的难度,为了简化查询逻辑和减少开发复杂度,我们也可能会选择适当的冗余。


比如,在内容管理系统(CMS)中,文章表 Articles 和分类表 Categories 通常是独立的,如果频繁需要显示文章所属的分类名称,联表查询可能增加复杂性。因此,通过在 Articles 表中直接存储 分类名称,可以简化前端展示逻辑,减少开发工作量。


破坏 3NF后的设计:


文章ID标题内容分类ID分类名称
A01文章一C01技术
A02文章二C02生活

2.3 报表和数据仓库


在数据仓库和报表系统中,通常需要快速读取和聚合大量数据。为了优化查询性能和数据分析,可能会采用冗余的数据结构,甚至使用星型或雪花型模式,这些模式并不完全符合三范式。


在销售数据仓库中,为了快速生成销售报表,可能会创建一个包含维度信息的事实表。


破坏 3NF后的设计:


销售ID产品ID产品名称类别销售数量销售金额销售日期
S01P01手机电子10050000元2023-10-01
S02P02书籍教育20020000元2023-10-02

在事实表中直接存储 产品名称类别,避免了需要联表查询维度表,提高了报表生成的效率。


2.4 特殊业务需求


在某些业务场景下,可能需要快速响应特定的查询或操作,这时通过适当的冗余设计可以满足业务需求。


比如,在实时交易系统中,为了快速计算用户的账户余额,可能会在用户表中直接存储当前余额,而不是每次交易时都计算。


破坏 3NF后的设计:


用户ID用户名当前余额
U01王五10000元
U02赵六5000元

在交易记录表中存储每笔交易的增减,但直接在用户表中维护 当前余额,避免了每次查询时的复杂计算。


2.5 兼顾读写性能


在某些应用中,读操作远多于写操作。为了优化读性能,可能会通过数据冗余来提升查询速度,而接受在数据写入时需要额外的维护工作。


社交媒体平台中,用户的好友数常被展示在用户主页上。如果每次请求都计算好友数量,效率低下。可以在用户表中维护一个 好友数 字段。


破坏3NF后的设计:


用户ID用户名好友数
U01Alice150
U02Bob200

通过在 Users 表中冗余存储 好友数,可以快速展示,无需实时计算。


2.6 快速迭代和灵活性


在快速发展的产品或初创企业中,数据库设计可能需要频繁调整。过度规范化可能导致设计不够灵活,影响迭代速度。适当的冗余设计可以提高开发的灵活性和速度。


一个初创电商平台在初期快速上线,数据库设计时为了简化开发,可能会将用户的收货地址直接存储在订单表中,而不是单独创建地址表。


破坏3NF后的设计:


订单ID用户ID用户名收货地址订单日期总金额
O1001U01李雷北京市海淀区…2023-10-01800元
O1002U02韩梅梅上海市浦东新区…2023-10-021200元

这样设计可以快速上线,后续根据需求再进行规范化和优化。


2.7 降低复杂性和提高可理解性


有时,过度规范化可能使数据库结构变得复杂,难以理解和维护。适度的冗余可以降低设计的复杂性,提高团队对数据库结构的理解和沟通效率。


在一个学校管理系统中,如果将学生的班级信息独立为多个表,可能增加理解难度。为了简化设计,可以在学生表中直接存储班级名称。


破坏3NF后的设计:


学生ID姓名班级ID班级名称班主任
S01张三C01三年级一班李老师
S02李四C02三年级二班王老师

通过在学生表中直接存储 班级名称班主任,减少了表的数量,简化了设计。


3. 总结


本文,我们分析了数据库的三范式以及对应的示例,它是数据库设计的基本规范。但是,在实际工作中,为了满足性能、简化设计、快速迭代或特定业务需求,我们很多时候并不会严格地遵守三范式。


所以说,架构很多时候都是业务需求、数据一致性、系统性能、开发效率等各种因素权衡的结果,我们需要根据具体应用场景做出合理的设计选择。


4. 学习交流


如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。


作者:猿java
来源:juejin.cn/post/7455635421529145359
收起阅读 »

Redis - 全局ID生成器 RedisIdWorker

概述 定义:一种分布式系统下用来生成全局唯一 ID 的工具 特点 唯一性,满足优惠券需要唯一的 ID 标识用于核销 高可用,随时能够生成正确的 ID 高性能,生成 ID 的速度很快 递增性,生成的 ID 是逐渐变大的,有利于数据库形成索引 安全性,生成的 ...
继续阅读 »

概述



  1. 定义:一种分布式系统下用来生成全局唯一 ID 的工具

  2. 特点



    1. 唯一性,满足优惠券需要唯一的 ID 标识用于核销

    2. 高可用,随时能够生成正确的 ID

    3. 高性能,生成 ID 的速度很快

    4. 递增性,生成的 ID 是逐渐变大的,有利于数据库形成索引

    5. 安全性,生成的 ID 无明显规律,可以避免间接泄露信息

    6. 生成量大,可满足优惠券订单数据量大的需求



  3. ID 组成部分



    1. 符号位:1bit,永远为0

    2. 时间戳:31bit,以秒为单位,可以使用69年

    3. 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID




image.png


代码实现



  1. 目标:手动实现一个简单的全局 ID 生成器

  2. 实现流程



    1. 创建生成器:在 utils 包下创建 RedisIdWorker 类,作为 ID 生成器

    2. 创建时间戳:创建一个时间戳,即 RedisId 的高32位

    3. 获取当前日期:创建当前日期对象 date,用于自增 id 的生成

    4. count:设置 Id 格式,保证 Id 严格自增长

    5. 拼接 Id 并将其返回



  3. 代码实现


    @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 工具类



  1. 定义:一个同步工具类,用于协调多个线程的等待与唤醒

  2. 功能



    1. 控制多个线程的执行顺序和同步

    2. 确保主线程在所有子线程完成后才继续执行

    3. 防止主线程过早结束导致子线程执行状态丢失



  3. 常用方法



    1. await:用于主线程的阻塞方法,使其阻塞等待直到计数器归零

    2. countDown:用于子线程的计数方法,使计数器递减




二、ExecutorService & Executors



  1. 定义:Java 提供的线程池管理接口

  2. 功能



    1. 简化异步任务的执行管理

    2. 提供有关 “线程池” 和 “任务执行” 的标准 API



  3. 常用方法


    方法说明
    Executors.newFixedThreadPool(xxxThreads)Executors 提供的工厂方法,用于创建 ExecutorService 实例
    execute(functionName)调用线程执行 functionName 任务,无返回值
    ⭐ submit(functionName)调用线程执行 functionName 任务,返回一个 Future 类
    invokeAny(functionName)调用线程执行一组 functionName 任务,返回首成功执行的任务的结果
    invokeAll(functionName)调用线程执行一组 functionName 任务,返回所有任务执行的结果
    ⭐ shutdown()停止接受新任务,并在所有正在运行的线程完成当前工作后关闭
    ⭐ awaitTermination()停止接受新任务,在指定时间内等待所有任务完成


  4. 参考资料:一文秒懂 Java ExecutorService

  5. 代码实现



    1. 目标:测试 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)); // 打印任务执行的总耗时
    }





超卖问题



  1. 目标:通过数据库的 SQL 语句直接实现库存扣减(存在超卖问题)


一、乐观锁



  1. 定义:一种并发控制机制,不使用数据库锁,而是在更新时通过版本号或条件判断来确保数据一致性

  2. 优点:并发性能高,不会产生死锁,适合读多写少的场景

  3. 实现方式:CAS (Compare and Swap) - 比较并交换操作

  4. 实现示例 (基于版本号的乐观锁)


    boolean success = seckillVoucherService.update()
    .setSql("stock = stock - 1, version = version + 1")
    .eq("voucher_id", voucherId)
    .eq("version", version)
    .gt("stock", 0)
    .update();


  5. 分布式环境的局限性



    1. **原子性问题:**多个线程同时检查库存并更新时,可能导致超卖。这是因为检查和更新操作不是原子的

    2. **事务隔离:**在默认的"读已提交"隔离级别下,分布式环境中的多个节点可能读取到不一致的数据状态

    3. **分布式一致性:**在分布式环境中,不同的应用服务器可能同时操作数据库,而数据库层本身并不能感知跨服务器的事务一致性




二、悲观锁



  1. 定义:一种并发控制机制,通过添加同步锁强制线程串行执行

  2. 优点:实现简单,可以确保数据一致性

  3. 缺点:由于串行执行导致性能较低,不适合高并发场景

  4. 事务隔离级别:读已提交及以上

  5. 实现方法:使用 SQL 的 forUpdate() 子句,可以在查询时锁定选中的数据行。被锁定的行在当前事务提交或回滚前,其他事务无法对其进行修改或读取


三、事务隔离级别



  1. 定义:数据库事务并发执行时的隔离程度,用于解决并发事务可能带来的问题

  2. 优点:可以防止脏读、不可重复读和幻读等并发问题

  3. 缺点:隔离级别越高,并发性能越低

  4. 实现方法:



    • 读未提交(Read Uncommitted):允许读取未提交的数据

    • 读已提交(Read Committed):只允许读取已提交的数据

    • 可重复读(Repeatable Read):在同一事务中多次读取同样数据的结果是一致的

    • 串行化(Serializable):最高隔离级别,完全串行化执行






一人一单问题


一、单服务器系统解决方案



  1. 需求:每个人只能抢购一张大额优惠券,避免同一用户购买多张优惠券

  2. 重点



    1. 事务:库存扣减操作必须在事务中执行

    2. 粒度:事务粒度必须够小,避免影响性能

    3. 锁:事务开启时必须确保拿到当前下单用户的订单,并依据用户 Id 加锁

    4. 找到事务的代理对象,避免 Spring 事务注解失效 (需要给启动类加 @EnableAspectJAutoProxy(exposeProxy = true) 注解)



  3. 实现逻辑



    1. 获取优惠券 id、当前登录用户 id

    2. 查询数据库的优惠券表(voucher_order)



      1. 如果存在优惠券 id 和当前登录用户 id 都匹配的 order 则拒绝创建订单,返回 fail()

      2. 如果不存在则创建订单 voucherOrder 并保存至 voucher_order 表中,返回 ok()






二、分布式系统解决方案 (通过 Lua 脚本保证原子性)


一、优惠券下单逻辑


image.png


二、代码实现 (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 脚本



  1. RedisScript 接口:用于绑定一个具体的 Lua 脚本

  2. DefaultRedisScript 实现类



    1. 定义:RedisScript 接口的实现类

    2. 功能:提前加载 Lua 脚本

    3. 示例


      // 创建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 脚本



  1. 调用Lua脚本 API :StringRedisTemplate.execute( RedisScript script, List keys, Object… args )

  2. 示例



    1. 执行 ”下单脚本” (此时不需要 key,因为下单时只需要用 userId 和 voucherId 查询是否有锁)


      Long result = stringRedisTemplate.execute(
      SECKILL_SCRIPT, // 要执行的脚本
      Collections.emptyList(), // KEY
      voucherId.toString(), userId.toString(), String.valueOf(orderId) // VALUES
      );


    2. 执行 “unlock脚本”






实战:添加优惠券 & 单服务器创建订单


添加优惠券



  1. 目标:商家在主页上添加一个优惠券的抢购链接,可以点击抢购按钮抢购优惠券


一、普通优惠券



  1. 定义:日常可获取的资源

  2. 代码实现


    @PostMapping
    public Result addVoucher(@RequestBody Voucher voucher) {
    voucherService.save(voucher);
    return Result.ok(voucher.getId());
    }



二、限量优惠券



  1. 定义:限制数量,需要设置时间限制、面对高并发请求的资源

  2. 下单流程



    1. 查询优惠券:通过 voucherId 查询优惠券

    2. 时间判断:判断是否在抢购优惠券的固定时间范围内

    3. 库存判断:判断优惠券库存是否 ≥ 1

    4. 扣减库存

    5. 创建订单:创建订单 VoucherOrder 对象,指定订单号,指定全局唯一 voucherId,指定用户 id

    6. 保存订单:保存订单到数据库

    7. 返回结果:Result.ok(orderId)



  3. 代码实现



    1. VoucherController


      @PostMapping("seckill")
      public Result addSeckillVoucher( @RequestBody Voucher voucher ){
      voucherService.addSeckillVoucher(voucher);
      return Result.o(voucher.getId());
      }


    2. 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());
      }





(缺陷) 优惠券下单功能


一、功能说明



  1. 目标:用户抢购代金券,保证用户成功获得优惠券,保证效率并且避免超卖

  2. 工作流程



    1. 提交优惠券 ID

    2. 查询优惠券信息 (下单时间是否合法,下单时库存是否充足)

    3. 扣减库存,创建订单

    4. 返回订单 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);
    }



作者:LoopLee
来源: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 将数据分成多个小段,每段数据都有唯一的序列号,以确保数据包的顺序传输和完整性。同时,发送方如果没有收到接收方的确认应答,会重传数据。


三分恶面渣逆袭:序列号/确认应答


③、流量控制:接收方会发送窗口大小告诉发送方它的接收能力。发送方会根据窗口大小调整发送速度,避免网络拥塞。


三分恶面渣逆袭:滑动窗口简图


④、超时重传:如果发送方发送的数据包超过了最大生存时间,接收方还没有收到,发送方会重传数据包以保证丢失数据重新传输。


三分恶面渣逆袭:超时重传


⑤、拥塞控制:TCP 会采用慢启动的策略,一开始发的少,然后逐步增加,当检测到网络拥塞时,会降低发送速率。在网络拥塞缓解后,传输速率也会自动恢复。


三分恶面渣逆袭:拥塞控制简略示意图


http的get和post区别


三分恶面渣逆袭:Get 和 Post 区别


GET 请求主要用于获取数据,参数附加在 URL 中,存在长度限制,且容易被浏览器缓存,有安全风险;而 POST 请求用于提交数据,参数放在请求体中,适合提交大量或敏感的数据。


另外,GET 请求是幂等的,多次请求不会改变服务器状态;而 POST 请求不是幂等的,可能对服务器数据有影响。


https使用过吗 怎么保证安全


HTTP 是明文传输的,存在数据窃听、数据篡改和身份伪造等问题。而 HTTPS 通过引入 SSL/TLS,解决了这些问题。


SSL/TLS 在加密过程中涉及到了两种类型的加密方法:



  • 非对称加密:服务器向客户端发送公钥,然后客户端用公钥加密自己的随机密钥,也就是会话密钥,发送给服务器,服务器用私钥解密,得到会话密钥。

  • 对称加密:双方用会话密钥加密通信内容。


三分恶面渣逆袭:HTTPS 主要流程


客户端会通过数字证书来验证服务器的身份,数字证书由 CA 签发,包含了服务器的公钥、证书的颁发机构、证书的有效期等。


https能不能抓包


可以,HTTPS 可以抓包,但因为通信内容是加密的,需要解密后才能查看。


MonkeyWie:wireshark抓HTTPS


其原理是通过一个中间人,伪造服务器证书,并取得客户端的信任,然后将客户端的请求转发给服务器,将服务器的响应转发给客户端,完成中间人攻击。


常用的抓包工具有 Wireshark、Fiddler、Charles 等。


threadlocal 原理 怎么避免垃圾回收?


ThreadLocal 的实现原理就是,每个线程维护一个 Map,key 为 ThreadLocal 对象,value 为想要实现线程隔离的对象。


1、当需要存线程隔离的对象时,通过 ThreadLocal 的 set 方法将对象存入 Map 中。


2、当需要取线程隔离的对象时,通过 ThreadLocal 的 get 方法从 Map 中取出对象。


3、Map 的大小由 ThreadLocal 对象的多少决定。


ThreadLocal 的结构


通常情况下,随着线程 Thread 的结束,其内部的 ThreadLocalMap 也会被回收,从而避免了内存泄漏。


但如果一个线程一直在运行,并且其 ThreadLocalMap 中的 Entry.value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。当 Entry 非常多时,可能就会引发更严重的内存溢出问题。


ThreadLocalMap 内存溢出


使用完 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 值。


沉默王二: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 中,事务管理可以分为两大类:声明式事务管理和编程式事务管理。


三分恶面渣逆袭: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赋能剪纸艺术,剪映助力多地文旅点亮新春

近日,一场别开生面的文化盛宴在社交媒体拉开帷幕。多地文旅纷纷在官方账号发布剪纸风格的视频,以独特的视角展现当地丰富的文旅资源,将传统非遗文化与春节的喜庆氛围完美融合,这一创新形式收获网友大量点赞。在这些令人眼前一亮的视频中,各地的标志性景点和特色风土人情以剪纸...
继续阅读 »

近日,一场别开生面的文化盛宴在社交媒体拉开帷幕。多地文旅纷纷在官方账号发布剪纸风格的视频,以独特的视角展现当地丰富的文旅资源,将传统非遗文化与春节的喜庆氛围完美融合,这一创新形式收获网友大量点赞。

在这些令人眼前一亮的视频中,各地的标志性景点和特色风土人情以剪纸艺术的形式生动呈现。细腻的线条勾勒出西安大雁塔的宏伟庄严,鲜艳的色彩展现出塞上江南的瑰丽,精致的图案描绘出江南水乡的温婉秀丽。每一幅剪纸都仿佛在诉说着一个地方的故事,让大众在感受剪纸艺术魅力的同时,领略到祖国大地的壮美多姿。

图片1.png

图片来源:陕西文旅、威海文旅、内蒙古文旅的官方社交媒体账号

记者注意到,本次剪纸效果采用了剪映提供的“中式剪纸”模板功能。作为字节跳动旗下的视频创作工具产品,剪映团队发挥技术优势,将AI新技术与传统剪纸艺术深度融合,为创作者提供了便捷且强大的创作工具。通过AI算法,用户只需上传照片素材,就能快速生成效果精细的剪纸风格视频,大大降低了创作门槛,让更多人参与到创作中来。

除了风景类的剪纸视频模板,剪映在春节期间还推出了丰富多样的其他模板,如人物剪纸模板。用户可以通过这些模板,将自己或身边人的形象创作为剪纸风格的人物,为视频增添更多趣味性和个性化元素。无论是阖家团圆的场景,还是展现个人风采的画面,都能通过这些模板以独特的剪纸艺术形式呈现。

剪映相关负责人表示,新春将至,希望通过AI技术的应用让剪纸艺术突破地域和传统展示形式的限制,激发更多人对家乡的热爱,鼓励大家用这种新颖的方式秀出自己家乡的风景,共同分享美好。(作者:刘洪)

收起阅读 »

synchronized就该这么学

先赞后看,Java进阶一大半 早期sychonrized重量级锁开销大,于是JDK1.5引入了ReentrantLock,包含现在很多偏见都是认为ReentrantLock性能要优于sychonrized。但JDK1.6引入的锁升级,不断迭代,怕是性能往往还...
继续阅读 »

先赞后看,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为重量级锁,因为它对资源消耗是比较大的。



  1. synchronized需要频繁的获得锁、释放锁,这会带来了不少性能消耗。

  2. 另外没有获得锁的线程会被操作系统进行挂起阻塞、唤醒。而唤醒操作需要保存当前线程状态,切换到下一个线程,也就是进行上下文切换。上下文切换是很耗费资源的一种操作。


1.4 保存线程状态



面试官:为什么上下文切换要保存当前线程状态?



这就跟读英文课文时查字典一样,我们要先记住课文里的页数,查完字典好根据页数翻到英文课文原来的位置。


同理,CPU要保证可以切换到上一个线程的状态,就需要保存当前线程的状态。


1.5 锁升级



面试官:可以怎么解决synchronized资源消耗吗?



上文我有提到Java SE 1.6对synchronized进行了各种优化,具体的实现是给synchronized引入了锁升级的概念。synchronized锁一共有四种状态,级别从低到高依次是无锁、偏向锁、轻量级锁、重量级锁。


大家思考下,其实多线程环境有着各种不同的场景,同一个锁状态并不能够适应所有的业务场景。而这四种锁状态就是为了适应各种不同场景来使得线程并发的效率最高。



  1. 没有任何线程访问同步代码块,此时synchronized是无锁状态。

  2. 只有一个线程访问同步代码块的场景的话,会进入偏向锁状态。偏向锁顾名思义会偏向访问它的线程,使其加锁、解锁不需要额外的消耗。

  3. 少量线程竞争的场景的话,偏向锁会升级为轻量级锁。而轻量级采用CAS操作来获得锁,CAS操作不需要获得锁、释放锁,减少了像synchronized重量级锁带来的上下文切换资源消耗。

  4. 轻量级锁通过CAS自旋来获得锁,如果自旋10次失败,为了减少CPU的消耗则锁会膨胀为重量级锁。此时synchronized重量级锁就回归到了悲观锁的状态,其他获取不到锁的都会进入阻塞状态。


1.6 锁升级优缺点



面试官:它们都有什么优缺点呢?



由于每个锁状态都有其不同的优缺点,也意味着有其不同的适应场景。



  1. 偏向锁的优点是加锁和解锁操作不需要额外的消耗;缺点是如果线程之间存在锁竞争,偏向锁会撤销,这也带来额外的撤销消耗;所以偏向锁适用的是只有一个线程的业务场景。

  2. 轻量级锁状态下,优点是线程不会阻塞,提高了程序执行效率;但如果始终获取不到锁的线程会进行自旋,而自旋动作是需要消耗CPU的;所以轻量级锁适用的是追求响应时间、同时同步代码块执行速度快的业务场景。

  3. 重量级锁的优点是不需要自旋消耗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一共有以下特性:



  1. 可见性。volatile修饰的变量每次被修改后的值,对于任何线程都是可见的,即任何线程会读取到最后写入的变量值。

  2. 原子性。volatile变量的读写具有原子性。

  3. 禁止代码重排序。对于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的,对于加锁操作会使用的更多些。



  1. synchronized加锁操作虽然开销比volatile大,但却适合复杂的业务场景。而volatile只适用于状态独立的场景,例如上文对flag变量的读写。

  2. volatile编写的代码是比较难以理解的,不清楚整个流程和原理很难维护代码。

  3. 类似于volatile++这种复合操作,volatile不能确保原子性。



⭐⭐⭐本文收录在《Java学习/进阶/面试指南》:https://github/JavaSouth



我是南哥,南就南在Get到你的点赞点赞点赞。


在这里插入图片描述



创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️



作者:JavaSouth南哥
来源:juejin.cn/post/7435894119103430665
收起阅读 »

如何进行千万级别数据跑批优化

最近观看公司前辈文档,看到对大数据量跑批的优化方案,参照自己的理解和之前相关经验整理了一份优化方案~ Background 定义: 跑批通常指代的是我们应用程序在固定日期针对某一批大量数据定时进行特定的处理,在金融业务中一般跑批的场景有分户日结、账务计提、账单...
继续阅读 »

最近观看公司前辈文档,看到对大数据量跑批的优化方案,参照自己的理解和之前相关经验整理了一份优化方案~


Background


定义: 跑批通常指代的是我们应用程序在固定日期针对某一批大量数据定时进行特定的处理,在金融业务中一般跑批的场景有分户日结、账务计提、账单逾期、不良资产处理等等,它具有高连贯性特点,通常我们执行完跑批后还要对跑批数据进行进一步处理,比如发 MQ 给下游消费,数仓拉取分析等。。。



跑批最怕的就是上来就干,从不考虑涉及到第三方接口时的响应时间、大事务等问题。



Problem


针对大数据量跑批会有很多的问题,比如我们要在指定日期指定时间的大数据量去处理,还要保证处理期间尽可能的高效,在出现错误时也要进行相应的补偿措施避免影响到其它业务等 ~



  1. OOM 查询跑批数据,未进行分片处理,随着业务纵向发展数据膨胀一旦上来,就容易导致 OOM 悲剧;

  2. 未对数据进行批量处理: 针对业务中间的处理未采用批量处理的思维,造成花费大量的时间,另外频繁的 IO 也是问题之一;

  3. 避免大事务: 直接用 @Transaction 去覆盖所有的业务是不可取的,问题定位困难不说,方法处理时间变久了;

  4. 下游接口的承受能力: 下游的承载能力也要在我们的考虑范围之内,比如大数量分批一直发,你是爽了,下游没有足够的能力消费就会造成灾难性的问题;

  5. 任务时间上的隔离: 通常大数据量跑批后面还有一些业务上的处理,对于时间和健壮性上要严格控制;

  6. 失败任务补偿: 分布式任务调度创建跑批任务,然后拆分子任务并发到消息队列,线程池执行任务调用远程接口,这中间的任何步骤都有可能会出问题导致任务失败;


Analyze


通过以上问题的总结,我们可以得出要完整的进行大数据量跑批任务我们的代码设计需要具备以下的几点素质:



  1. 健壮性: 跑批任务是要通过定时的去处理这些数据,不能因为其中一条数据出现异常从而导致整批数据无法继续进行操作,所以它必须是健壮的;

  2. 可靠性: 针对于异常数据我们后续可进行补偿处理,所以它必须是可靠的;

  3. 隔离性: 避免干扰任何其他应用程序的正常运行;

  4. 高性能: 通常跑批任务要处理的数据量较大,我们不能让它处理的时间过于久,这样会挤压后续的其它连贯性业务处理时间,所以我们必须考虑其性能处理;


Solution


大数据量的数据是很庞大的,如果一次性都加载到内存里面将会是灾难性的后果,因此我们要对大数据量数据进行分割处理,这是防止 OOM 必要的一环!此外,监控、异常等方法措施也要实施到位,到问题出现再补救就晚了~


1、数据库问题


使用数据库扫表问题:
遍历数据对数据库的压力是很大的,越往后速度越慢;


解决:
遍历数据库越往后查压力越大,可以设置在每次查询的时候携带上一次的极值,让你分页查找的offect永远控制在0


2、分片广播


分片: 在生产环境中,都是采用集群部署,如果一个跑批任务只跑在一个机器上,那效率肯定很低,我们可以利用 xxl-job「分片广播」 和 「动态分片」 功能;


image.png


分布式调度幂等: 分布式任务调度只能保证准时调到一个节点上,而且通常都有失败重试的功能。所以任务幂等都是要的,一般都是通过分布式锁来实现,这里遵循简单原则使用数据库就可以了,可以通过在任务表里 insert 一条唯一的任务记录,通过唯一键来防止重复调度。


除了用唯一键,还可以在记录中增加一个状态字段,使用乐观锁来更新状态。比如开始是初始化状态,更新成正在运行的状态,更新失败说明别的节点已经在跑这个任务。当然分布式锁的实现方案有很多,比如 redis、zk 等等。


集群分布式任务调度 xxl-job: 执行器集群部署时,“分片广播” 以执行器为维度进行分片,任务路由策略选择”分片广播”情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;



  • 分片任务场景:10个执行器的集群来处理10w条数据,每台机器只需要处理1w条数据,耗时降低10倍;

  • 广播任务场景:广播执行器机器运行shell脚本、广播集群节点进行缓存更新等


// Index 是属于 Total 第几个序列(从0开始)
int shardIndex = XxlJobHelper.getShardIndex();
// Total 是总的执行器数量
int shardTotal = XxlJobHelper.getShardTotal();

3、分批获取



  1. 设置步长: 分派到一个 Pod 负责的数据也是庞大的,一下查出来耗时太久容易导致超时,通常我们会引入步长的概念,比如分派给 Pod 1w条数据,我们可以将它划分 10 次查出,一次查出 1k 数据,进而避免了数据库查询数据耗时太久 ~

  2. 空间换时间: 跑批可能会涉及到数据准备的过程,边循环跑批数据边去查找所需的数据,涉及多个 for 嵌套的循环处理时,可以采用空间换时间的思想,将数据加载到内存中进行筛选查找,但是要做好 OOM 防范措施,比如用包装类接查找出来的数据等等,毕竟内存不是无限大的!

  3. 深分页: 分批查询时 limit 的偏移量越大,执行时间越长。比如 limit a, b 会查询前 a + b 条数据,然后丢弃前 a 条数据,select * 会查询所有的列,也会有回表操作。我们可以使用 子查询 优化 SQL ,先查出 id 后分页,尽量用覆盖索引 来优化


4、事务控制



  1. 这些操作自身是无法回滚的,这就会导致数据的不一致。可能 RPC 调用成功了,但是本地事务回滚了,可是 PRC 调用无法回滚了;

  2. 在事务中有远程调用,就会拉长整个事务导致本事务的数据库连接一直被占用,从而导致数据库连接池耗尽或者单个链接超时,因此要熟悉调用链路,将事务粒度控制在最小范围内


5、充分利用服务器资源


需要充分利用服务器资源,采用多线程,MySQL的CPU在罚息期间也是低于 50%、IOPS 使用率低于 50%;
其实跑数据是 io 密集型的,不需要非得压榨服务器资源 ~


6、MQ 消费任务并行


MQ 消费消息队列的消息时要在每个节点上同时跑多个子任务才能资源利用最大化。那么就使用到线程池了,如果选择的是Kafka或者 RocketMQ,他们的客户端本来就是线程池消费的,只需要合理调整客户端参数就可以了。如果使用的是 Redis,那就需要自己创建一个线程池,然后让一个 EventLoop 线程从 Redis 队列中取任务。放入线程池中运行,因为我们已经使用 Redis 队列做缓冲,所以线程池的队列长度设为0,这里直接使用JDK提供的 SynchronousQueue。(这里以java为例)


7、动态调整并发度


跑批任务中能动态调整速度是很重要的,有 2 个地方可以进行操作:



  1. 任务中调用远程接口,这个速度控制其实用 Thread.sleep() 就好了。

  2. 控制任务并发度,就是有多少个线程同时运行任务。这个控制可以通过调整线程池的线程数来实现,但是线程池动态调整线程数比较麻烦。动态调整可以通过开源的限流组件来实现,比如 Guava 的 RateLimiter。可以在每次调用远程接口前调用限流组件来控制并发速度。


8、失败任务如何继续


一般分布式调度路径:



  1. 分布式 任务调度创建跑批任务;

  2. 拆分子任务 多线程 并发的发送到 消息队列

  3. 线程池 执行任务调用远程接口;


在这个链条中,可能导致任务失败或者中止的原因无非下面几个。



  1. 服务器 Pod 因为其它业务影响重启导致任务中止;

  2. 任务消费过程中失败,达到最大的重试次数;

  3. 业务逻辑不合理或者数据膨胀导致 OOM ;

  4. 消费时调用远程接口超时(这个很多人专注自己的业务逻辑从而忽略第三方接口的调用)


其实解决起来也简单,因为其它因素导致失败,你需要记录下任务的进度,然后在失败的点去再次重试 ~



  1. 记录进度: 我们需要知道这个任务执行到哪里了,同时也要记录更新的时间,这样才知道补偿哪里,比如进行跑批捞取时,要记录我们捞取的数据区间 ~

  2. 任务重试: 编写一个补偿式的任务(比如FixJob),定时的去扫面处在中间态的任务,如果扫到就触发补偿机制,将这个任务改成待执行状态投入消息队列;


9、下游接口时间


跑批最怕的就是上来就干,从不考虑涉及到第三方接口时的响应时间,如果不考虑第三方接口调用时间,那么在测试时候你会发现频繁的 YGC,这是很致命的问题,属于你设计之外的事件,但也是你必须要考虑的~


解决起来也简单,在业务可以容忍的情况下,我们可以将调用接口的业务逻辑设计一个中间态,然后挂起我们的这个业务,随后用定时任务去查询我们的业务结果,在收到信息后继续我们的业务逻辑,避免它一直在内存中堆积 ~


10、线程安全


在进行跑批时,一般会采用多线程的方式进行处理,因此要考虑线程安全的问题,比如使用线程安全的容器,使用JUC包下的工具类。


11、异常 & 监控



  1. 异常: 要保证程序的健壮性,做好异常处理,不能因为一处报错,导致整个任务执行失败,对于异常的数据可以跳过,不影响其他数据的正常执行;

  2. 监控: 一般大数据量跑批是业务核心中的核心,一次异常就是很大的灾难,对业务的损伤不可预估,因此要配置相应的监控措施,在发送异常前及时察觉,进而做补偿措施;


Reference


京东云定时任务优化总结(从半个小时优化到秒级)
记一次每日跑批任务耗时性能从六分钟优化到半分钟历程及总结


作者:Point
来源:juejin.cn/post/7433315676051406888
收起阅读 »

别再混淆了!一文带你搞懂@Valid和@Validated的区别

上篇文章我们简单介绍和使用了一下Springboot的参数校验,同时也用到了 @Valid 注解和 @Validated 注解,那它们之间有什么不同呢?区别先总结一下它们的区别:来源@Validated :是S...
继续阅读 »

上篇文章我们简单介绍和使用了一下Springboot的参数校验,同时也用到了 @Valid 注解和 @Validated 注解,那它们之间有什么不同呢?

区别

先总结一下它们的区别:

  1. 来源

    • @Validated :是Spring框架特有的注解,属于Spring的一部分,也是JSR 303的一个变种。它提供了一些 @Valid 所没有的额外功能,比如分组验证。
    • @Valid:Java EE提供的标准注解,它是JSR 303规范的一部分,主要用于Hibernate Validation等场景。
  2. 注解位置

    • @Validated : 用在类、方法和方法参数上,但不能用于成员属性。
    • @Valid:可以用在方法、构造函数、方法参数和成员属性上。
  3. 分组

    • @Validated :支持分组验证,可以更细致地控制验证过程。此外,由于它是Spring专有的,因此可以更好地与Spring的其他功能(如Spring的依赖注入)集成。
    • @Valid:主要支持标准的Bean验证功能,不支持分组验证。
  4. 嵌套验证

    • @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,这个时候就需要用到分组校验了。

对于定义分组有两点要特别注意

  1. 定义分组必须使用接口。
  2. 要校验字段上必须加上分组,分组只对指定分组生效,不加分组不校验。

有这样一个需求,在创建用户时校验用户名,修改用户时校验用户id。下面对我们对这个需求进行一个简单的实现。

  1. 创建分组

CreationGr0up 用于创建时指定的分组:

public interface CreationGr0up {
}

UpdateGr0up 用于更新时指定的分组:

public interface UpdateGr0up {
}
  1. 创建用户类

创建一个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;
}
  1. 创建接口

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;
}
}
  1. 测试

先对 createUser接口进行测试,我们将id的值设置为0,也就是不满足id必须大于0的条件,同样 username 不传值,即不满足 username 不能为空的条件。  通过测试结果我们可以看到,虽然id没有满足条件,但是并没有提示,只提示了username不能为空。

再对 updateUser接口进行测试,条件和测试 createUser接口的条件一样,再看测试结果,和 createUser接口测试结果完全相反,只提示了id最小不能小于1。 

至此,分组功能就演示完毕了。

嵌套校验

介绍嵌套校验之前先看一下两个概念:

  1. 嵌套校验(Nested Validation) 指的是在验证对象时,对对象内部包含的其他对象进行递归验证的过程。当一个对象中包含另一个对象作为属性,并且需要对这个被包含的对象也进行验证时,就需要进行嵌套校验。
  2. 嵌套属性指的是在一个对象中包含另一个对象作为其属性的情况。换句话说,当一个对象的属性本身又是一个对象,那么这些被包含的对象就可以称为嵌套属性

有这样一个需求,在保存用户时,用户地址必须要填写。下面来简单看下示例:

  1. 创建地址类 AddressBean

AddressBean 设置 countrycity两个属性为必填项。

@Data
public class AddressBean {

@NotBlank
private String country;

@NotBlank
private String city;
}
  1. 修改用户类,将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;
}
  1. 创建一个嵌套校验测试接口
@PostMapping("nestValid")
public UserBean nestValid(@Validated @RequestBody UserBean userBean){
System.out.println(userBean);
return userBean;
}
  1. 测试

我们在传参时,只传 country字段,通过响应结果可以看到提示了city 字段不能为空。 响应结果

可以看到使用了 @Valid 注解来对 Address 对象进行验证,这会触发对其中的 Address 对象的验证。通过这种方式,可以确保嵌套属性内部的对象也能够参与到整体对象的验证过程中,从而提高验证的完整性和准确性。

总结

本文介绍了@Valid注解和@Validated注解的不同,同时也进一步介绍了Springboot 参数校验的使用。不管是 JSR-303JSR-380又或是 Hibernate Validator ,它们提供的参数校验注解都是有限的,实际工作中这些注解可能是不够用的,这个时候就需要我们自定义参数校验了。下篇文章将介绍一下如何自定义一个参数校验器。


作者:索码理
来源:juejin.cn/post/7344958089429434406
收起阅读 »

Java 实现责任链模式 + 策略模式:优雅处理多级请求的方式

一、什么是责任链模式?责任链模式(Chain of Responsibility Pattern) 是一种行为设计模式,它允许将请求沿着一个处理链传递,直到链中的某个对象处理它。这样,发送者无需知道哪个对象将处理请求,所有的处理对象都可以尝试处理请求...
继续阅读 »

一、什么是责任链模式?

责任链模式(Chain of Responsibility Pattern) 是一种行为设计模式,它允许将请求沿着一个处理链传递,直到链中的某个对象处理它。这样,发送者无需知道哪个对象将处理请求,所有的处理对象都可以尝试处理请求或将请求传递给链上的下一个对象。

image.png

核心思想:将请求的发送者与接收者解耦,通过让多个对象组成一条链,使得请求沿着链传递,直到被处理。


二、责任链模式的特点

  1. 解耦请求发出者和处理者:请求的发送者不需要知道具体的处理者是谁,增强了系统的灵活性和扩展性。
  2. 动态组合处理逻辑:可以根据需要动态改变链的结构,添加或移除处理者。
  3. 职责单一:责任链模式可以将每个验证逻辑封装到一个独立的处理器中,每个处理器负责单一的验证职责,符合单一职责原则。
  4. 可扩展性: 增加新的验证逻辑时,处理者只需继承一个统一的接口,并添加新的处理器,而不需要修改现有的代码。
  5. 清晰的流程: 将所有验证逻辑组织在一起,使得代码结构更加清晰,易于理解。

三、责任链模式和策略模式结合的意义

  • 责任链模式的作用
    • 用于动态处理请求,将多个处理逻辑串联起来。
  • 策略模式的作用
    • 用于封装一组算法,使得可以在运行时动态选择需要的算法。

结合两者:

  • 责任链模式负责串联和传递请求,而策略模式定义了每一个处理者的具体处理逻辑
  • 两者结合可以实现既动态构建责任链,又灵活应用不同策略来处理请求的需求。

四、责任链模式解决的问题

  1. 耦合过高:将请求的处理者从请求的发送者中解耦,使得处理者可以独立扩展或变更。
  2. 复杂的多条件判断:避免在代码中使用过多 if-else  switch-case 语句。
  3. 灵活性不足:通过链的动态组合可以轻松调整请求的传递逻辑或插入新的处理者。
  4. 代码重复:每个处理者只专注于处理它关心的部分,减少重复代码。

五、代码中的责任链模式解析

场景 1:商品上架逻辑(多重校验)

实现一个类似的场景——商品上架逻辑(如校验商品信息、库存信息等),可以按照以下步骤实现:

  1. 定义责任链抽象接口
public interface MerchantAdminAbstractChainHandler extends Ordered {

/**
* 执行责任链逻辑
*
* @param requestParam 责任链执行入参
*/

void handler(T requestParam);

/**
* @return 责任链组件标识
*/

String mark();
}
  1. 定义商品上架的责任链标识:
public enum ChainBizMarkEnum {
MERCHANT_ADMIN_CREATE_PRODUCT_TEMPLATE_KEY,
MERCHANT_ADMIN_PRODUCT_UPSHELF_KEY; // 新增商品上架责任链标识
}

  1. 定义每个处理器的通用行为:
@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;
}
}

  1. 定义商品上架的责任链处理器:
@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();
}
}

  1. 调用责任链进行处理:
@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) 执行对应的责任链。

(3) 业务服务层:ProductInventoryCheckChainFilter

  • 通过 MerchantAdminChainContext 调用对应的责任链,完成业务参数校验逻辑。
  • 责任链完成校验后,后续可以继续执行其他具体的业务逻辑。

责任链的执行流程

通过 MerchantAdminChainContext,上述两个处理器会被自动扫描并加载到责任链中。运行时,根据 mark() 和 getOrder() 的值,系统自动按顺序执行它们。

123.png

五、Java 实现责任链模式 + 策略模式

以下是实现一个责任链 + 策略模式的完整 Java 示例。

场景:模拟用户请求的审核流程(如普通用户审批、管理员审批、高级管理员审批),并结合不同策略处理请求。

1. 定义处理请求的接口

// 抽象处理者接口
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;
}
}

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());
}
}

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);
}
}
}

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);
}
}

六、为何责任链模式和策略模式结合使用?

  1. 责任链控制流程,策略定义处理逻辑

    • 责任链模式将处理请求的逻辑连接成链,便于动态调整请求传递的流程。
    • 策略模式将处理逻辑封装为独立的策略,可以灵活复用和替换。
  2. 职责分离

    • 责任链模式负责管理请求的传递,策略模式专注于实现具体的业务逻辑。
    • 结合使用可以让代码结构更清晰,职责分配更明确。
  3. 增强灵活性和可扩展性

    • 责任链可以动态增删处理者,策略可以动态选择或扩展新的处理逻辑,两者结合大大增强了系统的适配性和扩展性。

通过责任链模式与策略模式的结合,可以应对复杂的处理流程和多变的业务需求,同时保持代码的简洁与高内聚的设计结构。


作者:后端出路在何方
来源:juejin.cn/post/7457366224823124003

收起阅读 »

权限模型-ABAC模型

权限模型-ABAC模型📝 ABAC 的概念ABAC 的概念ABAC(Attribute-Based Access Control)基于属性的访问控制的权限模型,是一种细粒度的权限控制模型,通过对请求中的各种属性进行分析和匹配,实现对权限的灵活的、动态的控制。A...
继续阅读 »

权限模型-ABAC模型

📝 ABAC 的概念

ABAC 的概念

ABAC(Attribute-Based Access Control)基于属性的访问控制的权限模型,是一种细粒度的权限控制模型,通过对请求中的各种属性进行分析和匹配,实现对权限的灵活的、动态的控制

ABAC 的组成部分

  1. 主体(Subject): 发起访问资源请求的用户或实体(如应用程序、系统)。主体具有多种属性,例如角色、身份、部门、敏感级别、创建时间。

💡Tips: 实际中可能就是存储用户信息的记录表、发起请求的设备信息等。

  1. 对象(Object): 被访问的资源或数据。对象可以是文件、数据库表、API 接口等,同样具备多种属性,如文件名、文件类型、敏感级别、创建时间等。
  2. 操作(Action): 用户试图对资源的操作 ,例如”读”、“写”、“创建“、”删除“、”复制“等。
  3. 环境(Environment): 外部环境属性,如访问时间、地点、网络状态、安全级别等,用于动态调整访问策略。
  4. 策略(Policy): 定义允许或拒绝的访问的规则。策略基于主体、对象、操作和环境属性的组合,通过逻辑规则决定是否允许访问。

💡Tips: 策略如何定义?
一般来说 策略都有自己的语法设计,以 XML、JSON 这种形式去描述一个访问策略如何构成。 策略也是访问规则,本文中不再做区分。

ABAC 工作的基本原理

ABAC 的基本原理是:系统根据主体、对象、操作、环境的属性,以及预定义的策略,动态生成访问决策。

简单流程

LP3BIiD058RtynH3Lxemu6NbGleE9RjkGksYeB6qST-M1g8sjHgD5skyO8qWIGYYJKXh7wOv9vDLNy6HGQIxuUV_lsyunQQcDBJ3_JsYLBI31fMRrGPLcbGcTPxNAhMwecgqmFnPVkLZtmNZA_j8ikHXtkeKVeibGcIwjaDBT9kkIt1wnZx7eis2COQTihe2FHq6xscKf5Dhtcf34BFmYJ_GCjFfS9MK_lOR4lJYN3V5Cesy.png

  1. 发起请求 : 通常一个访问请求由主体、对象(资源)、环境、操作中的一个或者多个组成。每个组成部分又有各自的属性,需要用到各个组成部分的属性,去动态构建一个访问规则。

    例如 中午 12 点后禁止 A 部门的人访问 B 系统这个访问规则。

    中午 12 点以后:环境(时间属性)

    A 部门的人:主体(身份属性)

    B 系统:对象(被访问的资源)

    访问: 操作

  2. 匹配属性:在预设的访问规则库中查找与请求匹配的规则或规则集合。
  3. 规则评估:根据匹配的访问规则中的具体规则来评估请求。将请求中的属性值与规则中的条件进行对比,判断请求是否满足规则中的条件。例如请求中包含了访问的时间在 12 点以后,那么访问控制系统就会对比访问时间这个属性值。
  4. 返回结果:向用户返回最终的规则执行的结果。

💡Tips: 图中只是演示了一个大致的工作流程,实际要设计一个 ABAC 权限系统要复杂的多。

因为所有的规则条件是动态的、逻辑也是动态执行的。

ABAC的难点

试想以下场景:

  • 当前文档是文档的拥有者且是拥有者才能编辑。
  • 售卖的产品只能是上海地区的用户才能可见。
  • 中午 12 点后禁止 A 部门的人访问 B 系统。

如果使用 RBAC 模型很难实现以上的需求。RBAC 是静态的权限模型,没有对象的属性动态参与计算的,所以很难实现以上场景。

ABAC 系统非常灵活但实现比较困难,

1.属性收集和管理复杂度

  • 属性管理

    访问规则依赖属性属性值去构建,特别是动态属性(实时位置,时间),如何确保获取到最新的属性值。

💡Tips: 属性是否可以动态增加也是构建系统的一部分,例如用户属性中你增加了一项职业,那该属性的值类型和获取该值的方法如何定义也是属性管理的一个难点。

  • 数据一致性和同步

    分布式系统中,各种属性的数据源可能分散在不同的系统中,如何准确的、高效的获取该属性和属性值。

2.访问规则的复杂度

  • 条件逻辑

    构建一个访问规则通常包含复杂的条件,条件可能是大于、小于、区间、地理位置等。这些条件需要仔细的定义和维护。

  • 多条件组合

    访问规则需要涵盖不同属性的组合条件,属性组合的数量随着属性的增加呈指数型增长。

  • 策略管理

    如果访问规则在数量一直增长,访问规则的生命周期(更新、删除)将变得复杂。如果其中属性的变动也会影响现有存在的访问规则。

  • 动态性

    ABAC进行决策时需要实时评估所有相关属性的当前值,并与策略条件进行匹配。这种评估会增加计算的开销,尤其在处理大量请求时对计算资源要求更高。

3.透明性

  • 可追溯性

    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 系统通常需要考虑以下核心的三个关键步骤:

  1. 属性管理:属性的定义、结构和属性值的获取。
  2. 访问规则:访问规则的结构化和语法定义。
  3. 规则编辑器:规则编辑器和规则匹配引擎。

💡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 的设计思维达到你想要的基于属性控制权限就可以了。至于采用何种方式、是否搭配其他权限模型,具体业务、具体分析。


作者:newrain_zh
来源:juejin.cn/post/7445219433017376780

收起阅读 »

用java做一套离线且免费的智能语音系统,ASR+LLM+TTS

其实调用第三方接口完成一个智能语音系统是非常简单的,像阿里、科大讯飞、微软都有相关接口,直接根据官方文档集成就可以,但想要离线的就要麻烦一点了,主要是想不花钱,现在人工智能基本是python的天下,不得不感慨,再不学python感觉自己要被淘汰了。 言归正传,...
继续阅读 »

其实调用第三方接口完成一个智能语音系统是非常简单的,像阿里、科大讯飞、微软都有相关接口,直接根据官方文档集成就可以,但想要离线的就要麻烦一点了,主要是想不花钱,现在人工智能基本是python的天下,不得不感慨,再不学python感觉自己要被淘汰了。


言归正传,首先说一下标题中的ASR+LLM+TTS,ASR就是语音识别,LLM就是大语言模型,TTS就是文字转语音,要想把这几个功能做好对电脑性能要求还是蛮高的,本次方案是一个新的尝试也是减少对性能的消耗


1.先看效果


image.png
生成的音频效果放百度网盘 通过网盘分享的文件: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)中文模型一个大的一个小的,小的识别速度快准确率低,大的识别速度慢准确率高


image.png

提前预加载模型,提升识别速度


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选择要加载的模型在控制台运行对应的命令即可


image.png
添加依赖


<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服务,效果可能还会好点,但是我试过的速度都有点慢啊,还是容我再继续研究一下吧


作者:北冥有鱼518
来源:juejin.cn/post/7409329136555048999
收起阅读 »

从MySQL迁移到PostgreSQL经验总结

背景最近一两周在做从MySQL迁移到PostgreSQL的任务(新项目,历史包袱较小,所以迁移比较顺利), 感觉还是有一些知识,可以拿出来分享,希望对大家有所帮助。为什么要转到PostgreSQL因架构团队安全组安全需求,需要将Mysql迁移到PostgreS...
继续阅读 »

背景

最近一两周在做从MySQL迁移到PostgreSQL的任务(新项目,历史包袱较小,所以迁移比较顺利), 感觉还是有一些知识,可以拿出来分享,希望对大家有所帮助。

为什么要转到PostgreSQL

因架构团队安全组安全需求,需要将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>>() {
};
}
}
  1. pgsql不支持mysql insert ignore语法,  pgsql提供了类似的语法:
INSERT INTO orders (product_id, user_id)

VALUES (101202)

ON CONFLICT (product_id, user_id) DO NOTHING;

但是与mysql insert ignore并不完全等价,  关于这个点如何改造,  需要结合场景或者业务逻辑来斟酌定夺.

  1. 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;
  1. 分页:mysql的分页使用的是:   limit B(offset),A(count), 但是pgsql不支持这种语法, pgsql支持的是如下两种:

(1)、limit A offset B; (2)、OFFSET B ROWS FETCH NEXT A ROWS ONLY;

  1. pgsql查询区分大小写,  而mysql是不区分的
  2. 其它情况 (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):变长字符串,最大长度为n
    • character(n) 或 char(n):定长字符串,长度为n
    • text:变长字符串,没有长度限制
  • 日期/时间类型

    • 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 和数据库之间进行转换。


作者:Java分布式架构实战
来源:juejin.cn/post/7460410854775455794

收起阅读 »

如何统一管理枚举类?

Hello,大家好,今天我们来聊一下关于系统中的枚举是如何统一进行管理的。 业务场景 我们公司有这样的一个业务场景前端表单中 下拉选择的枚举值,是需要从后端获取的。那么这时候有个问题,我们不可能每次新增加一个枚举,都需要 改造获取枚举 的相关接口(getEnu...
继续阅读 »

Hello,大家好,今天我们来聊一下关于系统中的枚举是如何统一进行管理的。


业务场景


我们公司有这样的一个业务场景前端表单中 下拉选择的枚举值,是需要从后端获取的。那么这时候有个问题,我们不可能每次新增加一个枚举,都需要 改造获取枚举 的相关接口(getEnum),所以我们就需要对系统中的所有枚举类,进行统一的一个管理。


核心思路


为了解决这个问题,我们采用了如下的方案



  1. 当服务启动时,统一对 枚举类 进行 注册发现

  2. 枚举管理类,对外暴露一个方法,可以根据我的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; // 或抛出异常
}
}


  1. 所有枚举均使用 @IsEnum进行标记(这是一个自定义注解)

  2. 所有枚举均实现 BaseEnums 接口(具体作用后续会提到)

  3. 所有的枚举的 value 值 统一使用 String 类型,并使用 @EnumValue 注解进行标记



    1. 这主要是为了兼容Mybatis Plus 查表时 基础数据类型与枚举类型的转化

    2. 如果将 value 定义为Object类型,Mybatis Plus 无法正确识别,会报错



  4. 使用 @JsonCreator 注解标记转化函数



    1. 这个注解是有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是AP还是CP?



再说这个话题之前,这里的是APCP不是"A片"和"C骗"啊 !~哈哈哈,就离谱,博文后面我会解释下的


我说下自己对Redis的感觉,我一直很好奇Redis,不仅仅是当缓存用那么简单,包括的它的底层设计


所以,思考再三,我决定先从Redis基础开始写(基础是王道!~万丈高楼平地起,我米开始!~嘿嘿)



一、总纲图:


image.png


二、什么是CAP?



要想谈一谈我们本文的主题APCP,可能有的小伙伴会说: 这我也不是 怎么熟悉啊!


那么我们先复习下大名鼎鼎CAP 理论



CAP理论



看下面的这张图,我们会发现CAP对应的三个单词【建议自己画画图,印象深刻】


C: 一致性(Consistency)--



  • 每次读取都会收到最新的写入数据或者错误信息

  • (:这里面的一致性,指的是强一致性,不是市面上所说的所有节点在相同时间看到是一样的数据)


A:可用性(Availability)--



  • 每个请求都会收到非错误地响应,但是这个响应的信息不保证是最新的 ,只保证可用


P:分区容错性(Partition Tolerance)--



  • 就是网络节点间丢弃或者延迟一定数量(就是任意数量)信息,也不影响大局,系统还是能够正常运行



image.png


好了,我们言归正传,回到我们的主题上面


三、为啥说Redis是AP?不是CP?



我们知道,Redis是一个开源的内存数据库,且是执行单线程处理


但是网上,若是喜欢读博客的小伙伴,会发现很多人说这样一句话:



  • 单机的RedisCP的,集群的REDISAP



这句话真的对吗? 大家在看下文前,倾思考思考!~我当时读到第一反应就是疑惑,于是我就去查询大量资料


有的人说:




  1. CAP是针对分布式场景中,如果是单机REDIS,就压根儿和什么分布式不着边,都没 P了!!还说哈APCP??

  2. 在单机的REDIS中,应为只有一个实例,那么他的一致性是有保障的,如果这个节点挂了,就没有可用性可言了,所以他是CP系统


我在这里说下,以上两个观点都特么错的!!!以偏概全,混淆是非!~就是AP!!


~哈哈哈!你可能会说:我去,那你证明啊,这特么为啥是错的啊!,别急嘛!我们往下读,让你心服口服,嘿嘿



REDISAP的理由


第一点: 一致性



我们都知道,REDIS设计目标高性能,高扩展高可用性 ,


而且REDIS的一致性模型是最终一致性:



(什么意思呢?)就是在某个时间点读取的数据可能不是最新的,但殊途同归,最终会达到一致的状态




为什么Redis无法保持强一致性??



主要原因: 异步复制




  • 因为Redis在分布式的设计中采用的是异步复制,者导致在节点之间存在数据在同步和延迟不一致的情况存在



  • 换句话说:



    • 当某个节点的数据发生改变,Redis会将这个节点的修改操作发送给其他节点进行同步~(这是正常步骤,没毛病是不,我们继续往下看)

    • 但是(不怕一万,就怕万一来了,哈哈哈)因为网络传输的延迟,拥塞等原因,这些操作没有立即被被其他节点收到和执行,

    • 从而产生节点之间数据不一致的情况!!!






  • 抛开上面的影响点,节点故障Redis的一致性影响也是很大的


    举个例子:


    当一个节点宕机时,这个节点的 数据就可能同步不到其他节点上,这就会导致数据在节点间不一致


    你可能有疑惑?那Redis不是有哨兵和复制等机制吗?


    但是,问题就是但是,哈哈~这些机制是能提高系统的可用性和容错性,能完全解决吗?


    ~(你没看错,就是完全解决,能吗??)不能吧,自己主观推下也能想到那种万一场景吧!!!






你说既然异步不行,那么我就用同步机制就不好了!!不就是CP了???


~~no!no!NO !哈哈哈,年轻人,想的太简单了哈!


我们看看官网是怎么说的()




  • Redis客户端可以使用WAIT命令请求特定数据进行同步复制

  • 使用WAIT,只能说发生故障时丢失写操作的概率会大大降低,且是在难以触发的故障模式情况下

  • 但是!!



  • WAIT只能确保数据在Redis实例中有指定数量的副本被确认


    不能将一组REdis转换为具有强一制性的CP系统








  • 什么意思?


    故障转移期间,由于Redis持久化配置,当中已确认的写操作,仍然可能会丢失





完结!~



士不可以不弘毅,任重而道远,诸君共勉!~



作者:泊云V
来源:juejin.cn/post/7338721296866574376
收起阅读 »

若依框架——防重复提交自定义注解

防重复提交 1、自定义防重复提交注解 /** * 自定义注解防止表单重复提交 * * @author ruoyi * */ @Inherited @Target(ElementType.METHOD) @Retention(RetentionPol...
继续阅读 »

防重复提交


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
收起阅读 »

一次关键接口设计和优化带来的思考

实习时负责实现一个任务新增的接口,本来以为应该可以轻松拿捏,结果在实现过程中发现还有点小复杂,优化了很多版,并且其中涉及到了很多之前学过的知识点,故记录一下。 接口基本信息 在无人机管理系统中,对无人机执行任务时的监控是非常重要的模块,系统的用户可以为无人机创...
继续阅读 »

实习时负责实现一个任务新增的接口,本来以为应该可以轻松拿捏,结果在实现过程中发现还有点小复杂,优化了很多版,并且其中涉及到了很多之前学过的知识点,故记录一下。


接口基本信息


在无人机管理系统中,对无人机执行任务时的监控是非常重要的模块,系统的用户可以为无人机创建新的飞行任务,除了任务的基本信息外,用户还需要为飞行任务分配负责人,设备,飞手(操作无人机的人),航线,栅栏(任务区域)等信息,而后端实现时需要做好各种校验,对用户数据进行整理转换并插入不同的数据库表中,考虑与系统其他模块的关系(例如航线稽查模块),在系统内通知相关用户,发送邮件给相关用户,另外还要考虑接口幂等性,数据库事务问题,接口的进一步优化。


接口实现



  1. 参数校验

    • 参数非空校验,格式校验,业务上的校验。

    • 其中业务上的校验比较复杂:要保证设备,飞手,航线都存在,且是一 一对应关系;要确保任务的负责人有权限调动相关设备和人员(认证鉴权模块);确保设备,飞手都是可用状态;要检查设备所在位置与任务区域;要检查设备在指定时间内是否已被占用。



  2. 幂等性校验

    • 新增或编辑接口都可能会产生幂等性问题,尤其这种关键的新增接口一般都要保证幂等性。

    • 这里我使用的方案是创建任务时生成一个token保存在redis中,并返回给前端,前端提交任务时在请求中携带token,后端检查到redis中有token证明是第一次访问,删除token并执行后续逻辑(去redis中查并删除token用lua脚本保证原子性),如果请求重复提交则后端查不到token直接返回。

    • 也顺便研究了一下其他幂等性方案,包括前端防重复提交,唯一id限制数据库插入,防重表,全局唯一请求id等,发现还是目前使用redis的这种方案更简单高效。



  3. 生成任务对象,设置任务基本信息,并将下列得到信息赋予任务对象

    • 从线程上下文获取到当前用户信息设为负责人

    • 用设备id,用户id去对应表批量查找对应数据(注意一个任务中设备,飞手,航线是一 一对应,为一个组合,一个任务中可能有多个这种组合)

    • 将航线转化为多个地理点,保存到列表用于后续批量插入任务航线表

    • 为每条航线创建稽查事务对象,保存到列表用于后续批量插入稽查表

    • 将任务区域转化为多个地理点,保存到列表用于后续批量插入任务区域表



  4. 批量插入数据

    • 将任务对象插入任务表,将之前保存的列表分别批量插入到航线表,区域表,稽查表。



  5. 任务创建成功

    • 更新任务状态

    • 通过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过滤器实现


日志:关键的业务代码注意打日志进行监测,方便后续排查异常。


以上是我在设计实现一个重要接口,并对其进行优化时所思考的一些问题,当然上面提到的内容不一定完全正确,可能有很多还没考虑到的地方,有些问题也可能有更成熟的解决方案,但是整个思考过程还是很有收获的,期待能够继续成长。


作者:summer哥
来源:juejin.cn/post/7410601536126795811
收起阅读 »

各种O(PO,BO,DTO,VO等) 是不是人为增加系统复杂度?

在Java和其他编程语言的开发过程中,经常会用到几个以"O"结尾的缩写,比如PO,BO,DTO,VO等等,O在这里是Object的缩写,不同的O代表了不同的数据类型,很多时候这些O看起来都是差不多的,干的事情好像也只是一个简单的封装,那么搞出这么多O出来是不是...
继续阅读 »

在Java和其他编程语言的开发过程中,经常会用到几个以"O"结尾的缩写,比如PO,BO,DTO,VO等等,O在这里是Object的缩写,不同的O代表了不同的数据类型,很多时候这些O看起来都是差不多的,干的事情好像也只是一个简单的封装,那么搞出这么多O出来是不是人为增加了系统的复杂度呢?

各种O都是干什么的?

想要搞清楚标题中的问题,我们首先得了解这些O都是什么东西?这里给大家介绍几种常见的O:

  1. PO (Persistent Object) - 持久化对象。 持久化对象通常对应数据库中的一个表,主要用于表示数据库中存储的数据。PO中的属性通常和数据表的列一一对应,用于ORM(对象关系映射)框架中,如Hibernate,JPA等。
  2. BO (Business Object) - 业务对象。 业务对象主要封装了业务逻辑。它可以包含多个PO,或者是一个PO的扩展,增加了业务处理的逻辑。BO通常在业务层被使用,用于实现业务操作,比如计算、决策等。
  3. VO (Value Object) - 值对象。 值对象是一种用于传输数据的简单对象,它通常不包含业务逻辑,只包含数据属性和get/set方法。值对象主要用于业务层与表示层之间的数据传递,它的数据可能是由多个PO组合而成。
  4. DTO (Data Transfer Object) - 数据传输对象。 数据传输对象类似于VO,它也是用于层与层之间的数据传递。DTO通常用于远程通信,比如Web服务之间的数据传递。DTO通常不包含任何业务逻辑,只是用于在不同层次或不同系统之间传输数据。

有时候我们还会看到DO、POJO等概念,它们又是什么呢?

  1. DO (Domain Object) - 领域对象。 领域对象是指在问题领域内被定义的对象,它可以包含数据和行为,并且通常代表现实世界中的实体。在DDD(领域驱动设计)中,领域对象是核心概念,用于封装业务逻辑和规则。这里需要注意DO和BO的区别,虽然都是搞业务逻辑,DO通常是业务领域中单一实体的抽象,它关注于单个业务实体的属性和行为;而BO则通常涉及到业务流程的实现,可能会协调多个DO来完成一个业务操作。
  2. POJO (Plain Old Java Object) - 简单老式Java对象。 POJO是指没有遵循特定Java对象模型、约定或框架(如EJB)的简单Java对象。POJO通常用于表示数据结构,它们的实例化和使用不依赖于特定的容器或框架。

为什么要划分各种O?

在软件开发中划分不同的O主要是为了实现关注点分离(Separation of Concerns,SoC),提高代码的可维护性、可读性和可扩展性。

关注点分离的典型案例:MVC模式。

下面展开列举了一些划分这些对象的原因:

  1. 明确职责:通过将不同的职责分配给不同的对象,可以使每个对象都有明确的职责,这样代码更容易理解和维护。
  2. 减少耦合:不同层次之间通过定义清晰的接口(如特定的对象)交互,减少了直接的依赖关系,降低了耦合度。
  3. 抽象层次:通过定义不同的对象,可以在不同的抽象层次上操作,比如在数据层处理PO,在业务层处理BO,这样可以在合适的层次上做出决策。
    • 灵活性:当系统需要变更时,由于职责和层次的清晰划分,更容易做出局部的修改而不影响到整个系统。不同的对象可能针对性能有不同的优化,例如PO可能被优化以提高数据库操作的性能。
    • 安全性:通过使用不同的对象,可以控制敏感数据的暴露。例如,可以在DTO中排除一些不应该传输到前端的敏感信息。
    • 测试性:分离的对象使得单元测试变得更加容易,因为可以针对每个对象进行独立的测试。
  1. 交互清晰:在不同的系统组件或层次之间传递数据时,清晰的对象定义可以让数据交互更加清晰,减少数据传递中的错误。

总之,通过划分各种“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”结尾的数据对象:

  1. VO (View Object) - 视图对象。在前端框架中,VO可以代表专门为视图层定制的数据对象。这些对象通常是从后端接口获取的数据经过加工或格式化后,用于在界面上显示的对象。
  2. DTO (Data Transfer Object) - 数据传输对象。虽然DTO通常用于后端服务间的数据传输,但在前端中也可以用来表示从后端接口获取的数据结构。前端的DTO通常是指通过Ajax或Fetch API从服务器获取的原始数据结构。
  3. VMO (ViewModel Object) - 视图模型对象。在MVVM(Model-View-ViewModel)架构中,VMO可以代表视图模型对象,它是模型和视图之间的连接器。在Vue.js中,Vue实例本身就可以被看作是一个VMO,因为它包含了数据和行为,同时也是视图的反映。
  4. SO (State Object) - 状态对象 尽管不是标准的术语,但在使用如Vuex这样的状态管理库时,SO可以用来指代代表应用状态的对象。这些状态对象通常包含了应用的核心数据,如用户信息、应用设置等。

在实际的Vue开发过程中,开发者可能不会严格区分这些概念,而是更多地关注于组件的状态、属性(props)、事件和生命周期。组件内部的数据通常以数据属性(data)的形式存在,而组件间的数据传递通常使用属性(props)和事件(emits)。在处理与后端的数据交互时,开发者可能会定义一些专门的对象来适应后端的接口,但是这些都不是Vue框架强制的概念或规则。


简单地说,这些“O”其实就是帮我们把代码写得更清晰、更有条理,虽然一开始看着很麻烦,但时间一长,你会发现这样做能省下不少力气。就像我们的衣柜,虽然分类放好衣服需要点时间,但每天早上起来挑衣服的时候,不就轻松多了吗?

记住,合适的工具用在合适的地方,能让你事半功倍!

关注萤火架构,加速技术提升!


作者:萤火架构
来源:juejin.cn/post/7336020150867230757
收起阅读 »

如何实现一个通用的接口限流、防重、防抖机制

介绍最近上了一个新项目,考虑到一个问题,在高并发场景下,我们无法控制前端的请求频率和次数,这就可能导致服务器压力过大,响应速度变慢,甚至引发系统崩溃等严重问题。为了解决这些问题,我们需要在后端实现一些机制,如接口限流、防重复提交和接口防抖,而这些是保证接口安全...
继续阅读 »

介绍

最近上了一个新项目,考虑到一个问题,在高并发场景下,我们无法控制前端的请求频率和次数,这就可能导致服务器压力过大,响应速度变慢,甚至引发系统崩溃等严重问题。为了解决这些问题,我们需要在后端实现一些机制,如接口限流、防重复提交和接口防抖,而这些是保证接口安全、稳定提供服务,以及防止错误数据 和 脏数据产生的重要手段。

而AOP适合在在不改变业务代码的情况下,灵活地添加各种横切关注点,实现一些通用公共的业务场景,例如日志记录、事务管理、安全检查、性能监控、缓存管理、限流、防重复提交等功能。这样不仅提高了代码的可维护性,还使得业务逻辑更加清晰专注,关于AOP不理解的可以看这篇文章

接口限流

接口限流是一种控制访问频率的技术,通过限制在一定时间内允许的最大请求数来保护系统免受过载。限流可以在应用的多个层面实现,比如在网关层、应用层甚至数据库层。常用的限流算法有漏桶算法(Leaky Bucket)、令牌桶算法(Token Bucket)等。限流不仅可以防止系统过载,还可以防止恶意用户的请求攻击。

限流框架大概有

  1. spring cloud gateway集成redis限流,但属于网关层限流
  2. 阿里Sentinel,功能强大、带监控平台
  3. srping cloud hystrix,属于接口层限流,提供线程池与信号量两种方式
  4. 其他: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,欢迎关注~


作者:Seven97
来源:juejin.cn/post/7408859165433364490

收起阅读 »

MySQL误删数据怎么办?

一、背景 某天,张三打算操作数据库,删除自己项目的无用数据,但是一不小心数据删多了。被误删的数据,如何恢复呢?本文将介绍相关方法,以及现成的一些工具。 例子: 有一个表 create table person ( id bigint primary k...
继续阅读 »

一、背景


某天,张三打算操作数据库,删除自己项目的无用数据,但是一不小心数据删多了。被误删的数据,如何恢复呢?本文将介绍相关方法,以及现成的一些工具。


例子:


有一个表


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.cnfmy.ini,Linux下MySQL的配置文件目录一般是/etc/mysql)中的[mysqld]部分来开启binlog。如果在配置文件中找到了类似以下的设置,则表示binlog已经开启:


[mysqld]
log-bin=mysql-bin
server-id=1



  1. 修改配置启用了binlog之后,需要重启MySQL服务才能使更改生效

  2. mysql-bin表示binlog文件的前缀

  3. 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.cnfmy.ini,Linux下MySQL的配置文件目录一般是/etc/mysql)中的[mysqld]部分来修改binlog模式。


[mysqld]部分下,添加或修改以下行,将binlog_format设置为想要的模式(ROWSTATEMENTMIXED):


[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语句,重新插入


需要执行以下几个步骤:



  1. 确认insert插入数据的时间,找到对应的binlog文件

  2. 解析该binlog文件,指定时间点,在binlog文件中找到插入数据的位置

  3. 重新解析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'),并且分别在前后的BEGINCOMMIT找到position。


BEGIN往前找有一个position at 219COMMIT往后找有一个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语句。并且分别在前后的BEGINCOMMIT找到position。


BEGIN往前找有一个position at 219COMMIT往后找有一个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



  1. 通过命令,输入用户名、密码、端口号、地址等,并且指定binlog文件

  2. 通过输出,可以看出所有正向操作,以及每个正向操作的时间、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



  1. 命令中,新增一个参数 --flashback,用于指定回滚

  2. 通过输出,可以看出所有逆向操作。并且可以看出相对于正向操作来说,逆向操作的顺序是相反的,按时间从后往前排序



还有其他工具,比如说MyFlash等,大家可以自行研究


MyFlash:GitHub - Meituan-Dianping/MyFlash: flashback mysql data to any point


四、总结



  1. 我们可以通过binlog找回误删的数据,前提是开启了binlog。建议binlog模式为row模式,否则没办法根据正向操作生成逆向操作。

  2. 有一些开源工具可以自动解析binlog,并且生成逆向操作。


作者:掂过碌蔗
来源:juejin.cn/post/7416737238614589503
收起阅读 »

Spring Boot3,启动时间缩短 10 倍!

前面松哥写了一篇文章和大家聊了 Spring6 中引入的新玩意 AOT(见Spring Boot3 新玩法,AOT 优化!)。 文章发出来之后,有小伙伴问松哥有没有做性能比较,老实说,这个给落下了,所以今天再来一篇文章,和小伙伴们梳理比较小当我们利用 Nati...
继续阅读 »

前面松哥写了一篇文章和大家聊了 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 倍!


我画个表格对比一下这两种打包方式:


jarNative Image
包大小18.9MB82MB
编译时间3.7s4分54s
启动时间1.326s0.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已...
继续阅读 »

你好,我是猿java。


1. 网上关于 CMS的文章很多,为什么要重复造车轮?

答:网上很多关于 CMS收集器的文章写得不够具体,有的甚至一知半解,更多的是不假思索的转载,想通过自己对 CMS的理解以及大量资料的佐证,提供更具体形象正确的分析。


2. CMS已经被弃用,为什么还要分析它?

答:首先,CMS收集器依然是面试中的一个高频问题;
其次,CMS作为垃圾收集器的一个里程碑,作为 Java程序员,不了解原理,于情于理说不过去;


3. JVM已经把垃圾回收自动化了,为什么还要讲解 CMS?

答:排查生产环境的各种内存溢出,内存泄漏,垃圾回收导致性能瓶颈等技术问题,如果不懂原理,如何排查和优化?


温馨提示:如果没有特殊说明,本文提及的虚拟机默认为 HotSpot虚拟机。


背景


首先,了解下 HotSpot虚拟机中 9款垃圾回收器的发布时间及其对应的 JDK版本,如下图:


image.png
接着,了解下 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也不可达: 


image.png


关于可达性分析,还有一种方法是引用技术算法,该方法的思路是:在对象中添加一个计数器,增加一次引用计数器 +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可达对象,上述关系可以描绘成下图:


image.png


回收哪里的垃圾?


从 CMS 简介可以知道 CMS是用于老年代的垃圾回收,但是对于这种抽象的文字描述,很多小伙伴肯定还是没有体感, 因此,我们把视角放眼到整个 JVM运行时的内存结构上,从整体上看看垃圾回收器到底回收的是哪些区域的垃圾, CMS 又是回收哪里的垃圾,如下图:


垃圾在哪里?


在了解了“垃圾”在 JVM中是如何定义之后,我们不禁会问到:这些“垃圾”存放在哪里呢?


在回答这个问题之前,我们先来了解 JVM的内存结构,根据 Java虚拟机规范,JVM内存包含以下几个运行时区域,如下图: 


image.png


为了更好地理解 JVM内存结构,这里对各个区域做一个详细的介绍:



  1. 堆空间(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堆内。



  1. 方法区(Method Area):方法区是堆的一个逻辑区域,它是线程共享的,用于存储已被 JVM加载的类结构信息,常量、静态变量、即时编译后的代码缓存等数据。为了和堆区分开来,它也被叫做“非堆(Non-Heap)”。这个区域的回收对象主要是常量池和类型的卸载,而且回收的效果比较差。


关于方法区有一个误区:JDK 8以前,HotSpot虚拟机为了像堆一样管理方法区的垃圾回收,就使用永久代来实现方法区,因此有人就把方法区直接叫做永久代,而其它虚拟机不存在永久代的概念,因此,方法区如何实现属于虚拟机内部的机制,不是 JVM统一规范。另外,HotSpot发现永久代实现方法区这种做法会导致内存溢出,因此从 JDK8开始,把永久代彻底废除,改用和 JRockit一样的元空间。方法区也改用本地内存实现。



  1. 程序计数器(Program Counter Register):这是一个较小的线程私有内存空间,用于存储当前线程执行的字节码的行号指示器。每个线程都有自己的程序计数器,但这部分内存通常不涉及垃圾回收。

  2. 虚拟机栈(Java Virtual Machine Stack):每个 Java方法执行时都会创建一个线程私有的栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口信息等。虚拟机栈在方法执行完毕后会自动清理,因此也不是垃圾回收的重点。

  3. 本地方法栈(Native Method Stack):用于支持本地方法的执行(即通过JNI调用的非Java代码),它是线程私有的。本地方法栈也会在方法执行完毕后自动清理。


通过上述 JVM内存区域的介绍,我们可以发现 JVM各个内存区域都可能产生垃圾,只是程序计算器,本地方法区,虚拟机栈 3个区域随线程而生,随线程而亡,垃圾被自动回收,方法区回收效果比较差,而堆中的“垃圾”才是回收器关注的重点,因此,垃圾收集器重点关注的是 JVM的堆,而 CMS回收的是堆中的老年代,如下图:


image.png


到这里为止,我们已经从 JVM内存结构视角上掌握了垃圾收集器回收的区域以及 CMS 负责的区域。


接下来,分析一下 GC回收常用的几个重要技术点:三色标记法(Tricolor Marking),卡表(Card Table),写屏障(Write Barrier),理解它们可以帮助我们更好地去理解 GC回收的原理。


几个重要技术点


三色标记法


在垃圾收集器中,主要采用三色标记算法来标记对象的可达性:



  • 白色:表示对象尚未被访问。初始状态时,所有的对象都被标记为白色。

  • 灰色:表示对象已经被标记为存活,但其引用的对象还没有全部被扫描。灰色对象可能会引用白色对象。

  • 黑色:表示对象已经被标记为存活,并且该对象的所有引用都已经被扫描过。黑色对象不会引用任何白色对象。


三色标记算法的工作流程大致如下:



  1. 初始化时,所有对象都标记为白色。

  2. 将所有的 GC Roots 对象标记为灰色,并放入灰色集合。

  3. 从集合中选择一个灰色对象,将其标记为黑色,并将其引用的所有白色对象标记为灰色,然后放入灰色集合。

  4. 重复步骤3,直到灰色集合为空。

  5. 最后,所有黑色对象都是活跃的,白色对象都是垃圾。


卡表


对于分代垃圾回收器,势必存在一个跨代引用的问题,通常会使用一种名为记忆集(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,就能判断出其对应的内存区域存在对象跨代引用,卡表和卡页的关系如下图:


image.png


写屏障


在 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个步骤的版本,其实都大差不差,没有本质上的差异):



  1. Initial Mark(初始标记):会Stop The World

  2. Concurrent Marking(并发标记)

  3. Remark(重复标记):会Stop The World

  4. Concurrent Sweep(并发清除)

  5. Resetting(重置)


整个过程可以抽象成下图:


image.png


在讲解回收过程之前,先分析三色标记法,这样可以帮助我们更好地去理解 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的直接关联对象,而是间接关联对象。 整个关联关系可以描绘成下图:


image.png


为什么需要 STW?


为什么初始标记阶段需要 Stop The World?这里主要归纳成两个原因:



  1. 确定 Roots集合:初始标记阶段的主要任务是识别出所有的 GC Roots,这是后续并发标记阶段的起点。 在多线程运行的环境中,如果应用线程和垃圾回收线程同时运行,应用线程可能会改变对象引用关系,导致 Roots集合不准确。 因此,需要暂停应用线程,以确保 GC Roots的准确性和一致性。

  2. 避免并发问题:在初始标记阶段,垃圾回收器需要更新一些共享的数据结构,例如标记位图或者引用队列。 如果应用线程在此时运行,可能会引入并发修改的问题,导致数据不一致。STW可以避免这种情况的发生。


2.并发标记**


这里的并发是指应用线程和 GC线程可以并发执行。


在并发标记阶段主要完成 2个事情:



  1. 遍历对象图,标记从 GC Roots可以追踪到所有可达的存活对象;

  2. 处理并发修改


因为应用线程仍在继续工作,因此老年代的对象可能会发生以下几种变化:



  • 新生代的对象晋升到老年代;

  • 直接在老年代分配对象;

  • 老年代对象的引用关系发生变更;


为了防止这些并发修改被遗漏,CMS 使用了后置写屏障(Write Barrier)机制,确保这些更改会被记录在“卡表(Card Table)”中,同时将相应的卡表条目标记为脏(dirty),以便后续处理。


如下图:从 GC Roots追溯哦所有可达对象,并将它们修改为已标记,即黑色。


image.png


当老年代中,D 到 E到引用被修改时,就会触发写屏障机制,最终 E就会被写进脏页,如下图: 


image.png


并发标记会出现对象可达性误判问题,如下图:假如对象 D对象被标记成黑色,E对象被标记为灰色(图左半部分),这时,工作线程将 E对象修改成不再指向F,并将 D对象指向 F对象(图右半部分),按照三色标记算法,D对象为黑色,不会再往下追溯,所以 F对象就无法被标记从而变成垃圾,“存活”对象凭空消失了,这是很可怕的问题,那么 CMS是如何解决这种问题的呢?


image.png


解决这种问题,通常有两种方案:



  • 增量更新(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,即挂起所有的应用程序线程,该阶段主要完成事情是:



  1. 并发预清理:在重新标记阶段之前,CMS可能会执行一个可选的并发预清理步骤,以尽量减少重新标记阶段的工作量。(该过程在很多文章中会单独成一个大步骤讲解)

  2. 修正标记结果:由于在并发标记阶段导致的并发修改,导致漏标,错标,因此需要暂停应用线程(STW),确保修正这些标记结果。

  3. 处理卡表:检查并发标记阶段修改的这些脏卡,并重新标记引用的对象,以确保所有可达对象都被正确识别。

  4. 处理最终可达对象:处理那些在并发标记阶段被识别出的“最终可达”(Finalizable)对象。这些对象需要执行它们的 finalize方法,finalize方法可能会使对象重新变为可达状态。

  5. 处理弱引用、软引用、幻象引用等:处理各种不同类型的引用,确保它们按照预期被处理。例如,弱引用在 GC后会被清除,软引用在内存不足时会被清除,而幻象引用则在对象被垃圾收集器回收时被放入引用队列。


4.并发清除


这里的并发也是指应用线程和 GC线程可以并发执行,并发清除阶段主要完成 2个事情:



  1. 清除并发标记阶段标记为死亡的对象;

  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


Why does Concurrent-Mark-Sweep (CMS) remark phase need to re-examine the thread-stacks instead of just looking at the mutator’s write-queues?


A Generational Mostly-concurrent Garbage Collector


The JVM Write Barrier - Card Marking


原创好文



作者:猿java
来源:juejin.cn/post/7445517512609447951
收起阅读 »

升级到 Java 21 是值得的

升级到 Java 21 是值得的 又到了一年中的这个时候——New Relic 的年度“State of the Java Ecosystem”调查结果出来了,我一如既往地深入研究了它。虽然我认为该报告做得很好并且提出了很好的问题,但我对有多少 Java 开发...
继续阅读 »

升级到 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.readOutputStream.write 。如果您调用其中一个方法,程序将不会前进到下一行,直到这些方法完成它们正在做的事情并返回。


大多数网络服务都是 I/O 密集的,这意味着它们将大部分时间花在输入和输出方法上,例如 InputStream.readOutputStream.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


作者:xuejianxinokok
来源:juejin.cn/post/7345763454814765083
收起阅读 »

工作中 Spring Boot 五大实用小技巧,来看看你掌握了几个?

0. 引入 Spring Boot 以其简化配置、快速开发和微服务支持等特点,成为了 Java 开发的首选框架。本文将结合我在实际工作中遇到的问题,分享五个高效的 Spring Boot 的技巧。希望这些技巧能对你有所帮助。 1. 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);
}
}

根据测试用例获取的结果,将加密后的字符串替换明文。


image.png



  • 启动程序,验证数据库能否正常连接。



为了防止 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

效果如下:


image.png


image.png


通过分析 jar 包的结构可以得知,jar 包的 “大” 实际上是因为在打包时,会将项目所依赖的 jar 包放在 lib 夹文件中。而这部分依赖在版本迭代稳定后,基本是不会变化的。


上述这种给 jar 包瘦身的方案,实际上是在打包的时候忽略 lib 文件夹中的这些依赖,将这部分不变的依赖提前放到服务器上,打出来的 jar 包就变小了,从而提升发版效率。


参考资料


zhuanlan.zhihu.com/p/646593227


cloud.tencent.com/developer/a…


作者:离开地球表面_99
来源:juejin.cn/post/7424906244215193636
收起阅读 »

go的生态真的一言难尽

前言 标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 Maven 或 Gradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设...
继续阅读 »

前言



标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 MavenGradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设计和利用现有的工具与库,可以实现一个功能强大且灵活的工具。



正文分为两部分:项目本身和如何使用



一、项目本身


1. 项目需求分析


核心需求



  1. 依赖管理

    • 解析和下载 Go 项目的依赖。

    • 支持依赖版本控制和冲突解决。



  2. 构建管理

    • 支持编译 Go 项目。

    • 支持跨平台编译。

    • 支持自定义构建选项。



  3. 发布管理

    • 打包构建结果。

    • 支持发布到不同的平台(如 Docker Hub、GitHub Releases)。



  4. 任务管理

    • 支持定义和执行自定义任务(如运行测试、生成文档)。



  5. 插件系统

    • 支持扩展工具的功能。




可选需求



  • 缓存机制:缓存依赖和构建结果以提升速度。

  • 并行执行:支持并行下载和编译。




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 配置文件格式



  • YAMLTOML:选择一种易于阅读和编写的配置文件格式,如 YAML 或 TOML。




3. 系统架构设计


3.1 模块划分



  1. 依赖管理模块

    • 负责解析和下载项目的依赖。



  2. 构建管理模块

    • 负责编译 Go 项目,支持跨平台编译和自定义构建选项。



  3. 发布管理模块

    • 负责将构建结果打包和发布到不同平台。



  4. 任务管理模块

    • 负责定义和执行自定义任务。



  5. 插件系统

    • 提供扩展点,允许用户编写插件来扩展工具的功能。




3.2 系统流程



  1. 初始化项目:读取配置文件,初始化项目环境。

  2. 依赖管理:解析依赖并下载。

  3. 构建项目:根据配置文件进行项目构建。

  4. 执行任务:执行用户定义的任务(如测试)。

  5. 发布项目:打包构建结果并发布到指定平台。




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 使用安装脚本安装

我将提供一个简单的安装脚本,开发者可以通过 curlwget 安装构建工具。


使用 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 页面手动下载适合你操作系统的二进制文件。



  1. 访问 GitHub Releases 页面。

  2. 下载适合你操作系统的二进制文件:

    • Linux: GoForge-linux-amd64

    • macOS: GoForge-darwin-amd64

    • Windows: GoForge-windows-amd64.exe



  3. 将下载的二进制文件移动到系统的 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: 用户可以定义自定义任务(如 cleantestbuild 等),并可以配置任务依赖关系。

  • 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 项目的依赖、构建过程和发布任务。以下是使用步骤的简要总结:



  1. 安装构建工具:使用安装脚本或手动下载二进制文件。

  2. 配置项目:创建 build.yaml 文件,定义依赖、构建任务和发布任务。

  3. 执行任务:通过命令行执行构建、测试、清理等任务。

  4. 发布项目:将项目的构建产物发布到 GitHub 或其他平台。


作者:justseeit
来源:juejin.cn/post/7431545806085423158
收起阅读 »

不是,哥们,谁教你这样处理生产问题的?

你好呀,我是歪歪。 最近遇到一个生产问题,我负责的一个服务触发了内存使用率预警,收到预警的时候我去看了内存使用率已经到了 80%,看了一眼 GC 又发现还没有触发 FullGC,一次都没有。 基于这个现象,当时推测有两种可能,一种是内存溢出,一种是内存泄漏。 ...
继续阅读 »

你好呀,我是歪歪。


最近遇到一个生产问题,我负责的一个服务触发了内存使用率预警,收到预警的时候我去看了内存使用率已经到了 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…




这个回答下面的评论也很有意思,有兴趣的可以去翻一下,我截取两个我觉得有意思的:





在职场上,甚至在生活中,一个虽然没有解决方案但是可以被绕过的问题,我认为不是问题。


但是这个也得分情况,不是所有问题都能绕开的,假如是一个关键服务,那肯定不能置之不理,硬着头皮也得上。


关键是,我在职场上和生活中遇到过好多人,遇到问题的时候,似乎只会硬着头皮往上冲。


只会硬着头皮往上冲和知道什么时候应该硬着头皮往上冲,是两种截然不同的职场阶段。


所以有时候,遇到问题的时候,不要硬上,也让头皮休息一下,看看能不能绕过去。


作者:why技术
来源:juejin.cn/post/7417842116506058771
收起阅读 »

三行五行的 SQL 只存在于教科书和培训班

教科书中 SQL 例句通常都很简单易懂,甚至可以当英语来读,这就给人造成 SQL 简单易学的印象。 但实际上,这种三行五行的 SQL 只存在于教科书和培训班,我们在现实业务中写的 SQL 不会论行,而是以 K 计的,一条 SQL 几百行 N 层嵌套,写出 3K...
继续阅读 »

教科书中 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())

作者:LAOZEI
来源:juejin.cn/post/7441756894094491689
收起阅读 »

✨Try-Catch✨竟然会影响性能

前言 一朋友问我Try-Catch写多了会不会让程序变慢,我不加思索的回答肯定不会,毕竟曾经研究过Java异常相关的字节码指令,只要被Try-Catch的代码不抛出异常,那么代码执行链路是不会加深的。 可事后我反复思考这个看似简单实则也不复杂的问题,我觉得顺着...
继续阅读 »

你的名字-001




前言


一朋友问我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异常表)我们可知,只有指令05在执行过程中抛出了Exception,才会跳转到指令6开始执行,换言之只要不抛出异常,那么在执行完指令5后方法就结束了,此时和没添加Try-Catch时的代码执行链路是一样的,也就是不抛出异常时,Try-Catch不会影响程序性能。


我们添加Try-Catch,其实就是为了做异常处理,也就是我们天然的认为被Try-Catch的代码就是会抛出异常的,而异常一旦发生,此时程序性能就会受到一定程度的影响,表现在如下两个方面。



  1. 异常对象创建有性能开销。具体表现在异常对象创建时会去爬栈得到方法调用链路信息

  2. 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其实不会影响程序性能,因为在没有异常发生时,代码执行链路不会加深。


但是如果出现异常,那么程序性能就会受到影响,表现在如下两个方面。



  1. 异常对象创建有性能开销。具体表现在异常对象创建时会去爬栈得到方法调用链路信息

  2. Try-Catch捕获到异常后会让代码执行链路变深。


因此在日常开发中,可以适当增加防御性编程来防止JVM抛出异常,也建议尽量将主动的异常抛出替换为提前返回响应,总之就是尽量减少非必要的异常出现。


你的名字-002


作者:半夏之沫
来源:juejin.cn/post/7458929387784077349
收起阅读 »

又整新活,新版 IntelliJ IDEA 2024.1 有点东西!

就在上周,Jetbrains 又迎来了一波大版本更新,这也是 JetBrains 2024首个大动作! JetBrains 为其多款 IDE 发布了 2024 年度首个大版本更新 (2024.1)。 作为旗下重要的产品之一,IntelliJ IDEA当然也不...
继续阅读 »

就在上周,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 的全行代码补全。


全行代码补全.gif


该项功能由无缝集成到 IDE 中的高级深度学习模型来提供支持。它可以基于上下文分析预测和建议整行代码,以助于提高编码效率。


对 Java 22 的支持


IntelliJ IDEA 2024.1 提供了对 2024 年 3 月刚发布的 JDK 22 中的功能集的支持。


对Java22功能的支持.gif


支持覆盖未命名变量与模式的最终迭代、字符串模板与隐式声明的类的第二个预览版,以及实例main方法。 此外,这次更新还引入了对super(...)之前预览状态下的 new 语句支持。


新终端加持


IntelliJ IDEA 2024.1推出了重构后的新终端,具有可视化和功能增强,有助于简化命令行任务。


新终端.gif


此更新为既有工具带来了全新的外观,命令被分为不同的块,扩展的功能集包括块间丝滑导航、命令补全和命令历史记录的轻松访问等。


编辑器中的粘性行


此次新版本更新在编辑器中引入了粘性行,旨在简化大文件的处理和新代码库的探索。滚动时,此功能会将类或方法的开头等关键结构元素固定到编辑器顶部。


这样一来作用域将始终保持在视野中,用户可以点击固定的行快速浏览代码。


编辑器中的粘性行.gif


AI Assistant 改进


在本次新版中,AI Assistant 获得了多项有价值的更新,包括改进的测试生成和云代码补全、提交消息的自定义提示语、从代码段创建文件的功能,以及更新的编辑器内代码生成。


AI_Assistant改进.gif


不过需要注意的事,在这次 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 元素的大小。


用于缩小整个IDE的选项.gif


对Java支持的更新



  • 字符串模板中的语言注入


IntelliJ IDEA 2024.1 引入了将语言注入字符串模板的功能。


字符串模板中的语言注入.gif


用户既可以使用注解(注解会自动选择所需语言),也可以使用 Inject language or reference(注入语言或引用)来从列表中手动选择语言。



  • 改进的日志工作流


由于日志记录是日常开发的重要环节,新版本引入了一系列更新来增强 IntelliJ IDEA 在日志方面的用户体验。


比如现在用户可以从控制台中的日志消息中轻松导航到生成它们的代码。


改进的日志工作流 .gif


此外,IDE会在有需要的位置建议添加记录器,并简化插入记录器语句的操作,即便记录器实例不在作用域内。



  • 新检查与快速修复


新版本为 Java 实现了新的检查和快速修复,帮助用户保持代码整洁无误。


比如,IDE 现在会检测可被替换为对 Long.hashCode() 或 Double.hashCode() 方法的调用的按位操作。



此外,新的快速修复也可以根据代码库的要求简化隐式和显式类声明之间的切换。


新检查与快速修复_1.gif


另一项新检查为匹配代码段建议使用现有 static 方法,使代码可以轻松重用,而无需引入额外 API。此外,IDE现在可以检测并报告永远不会执行的无法访问的代码。


新检查与快速修复_2.gif



  • 重构的 Conflicts Detected(检测到冲突)对话框


这次版本 2024.1 重构了 Conflicts Detected(检测到冲突)对话框以提高可读性。


现在,对话框中的代码反映了编辑器中的内容,使用户可以更清楚地了解冲突,并且 IDE 会自动保存窗口大小调整以供将来使用。



另外,这次还更新了按钮及其行为以简化重构工作流,对话框现在可以完全通过键盘访问,用户可以使用快捷键和箭头键进行无缝交互。



  • Rename(重命名)重构嵌入提示


为了使重命名流程更简单、更直观,新版推出了一个新的嵌入提示,在更改的代码元素上显示。要将代码库中的所有引用更新为新版本,点击此提示并确认更改即可。


Rename重构嵌入提示.gif


版本控制系统改进



  • 编辑器内的代码审查


IntelliJ IDEA 2024.1 为 GitHub 和 GitLab 用户引入了增强的代码审查体验。


编辑器内的代码审查.gif


该功能与编辑器集成,以促进作者与审查者直接互动。在检查拉取/合并请求分支时,审查模式会自动激活,并在装订区域中显示粉色标记,表明代码更改可供审查。


点击这些标记会弹出一个显示原始代码的弹出窗口,这样用户就能快速识别哪些代码已被更改。


装订区域图标可以帮助用户迅速发起新讨论,以及查看和隐藏现有讨论。另外这些图标还可以让用户更方便地访问评论,从而更轻松地完成查看、回复等功能。



  • Log(日志)标签页中显示审查分支更改的选项


新版通过提供分支相关更改的集中视图来简化了代码审查工作流。



对于 GitHub、GitLab 和 Space,用户现在可以在 Git 工具窗口中的单独 Log(日志)标签页中查看具体分支中的更改。用户可以点击 Pull Requests(拉取请求)工具窗口中的分支名称,然后从菜单中选择 Show in Git Log(在 Git 日志中显示)。



  • 对代码审查评论回应的支持


新版开始支持对 GitHub 拉取请求和 GitLab 合并请求的审查评论发表回复,目前已有一组表情符号可供选择。




  • 从推送通知创建拉取/合并请求


成功将更改推送到版本控制系统后,新版IDE将会发布一条通知,提醒用户已成功推送并建议创建拉取/合并请求的操作。




  • 防止大文件提交到仓库


为了帮助用户避免由于文件过大而导致版本控制拒绝,新版IDE现在包含预提交检查,以防止用户提交此类文件并通知用户该限制。



构建工具改进



  • 针对 Maven 项目的打开速度提升


新版 IDEA 现在通过解析 pom.xml 文件构建项目模型。这使得有效项目结构可以在几秒钟内获得,具有所有依赖项的完整项目模型则同时在后台构建,这样一来用户就无需等待完全同步即可开始处理项目。


针对Maven项目的打开速度提升.gif



  • 从快速文档弹出窗口直接访问源文件


快速文档弹出窗口现在提供了一种下载源代码的简单方式。



现在当用户需要查看库或依赖项的文档并需要访问其源代码时,按 F1 即可。


更新后的弹出窗口将提供一个直接链接,用户可以使用它来下载所需的源文件,以简化工作流。



  • Maven 工具窗口中的 Maven 仓库


Maven 仓库列表及其索引编制状态现在直接显示在 Maven 工具窗口中,而不是以前 Maven 设置中的位置。




  • Gradle 版本支持更新


从这个新版本开始,IntelliJ IDEA 将不再支持使用低于 Gradle 版本 4.5 的项目,并且 IDE 不会对带有不支持的 Gradle 版本的项目执行 Gradle 同步。


运行/调试更新



  • 多语句的内联断点


新版IDEA为在包含 lambda 函数或 return 语句的行中的断点设置提供了更方便的工作流。


多语句的内联断点.gif


点击装订区域设置断点后,IDE会自动显示可在其中设置额外断点的内联标记。每个断点都可以独立配置,释放高级调试功能。



  • 条件语句覆盖


2024.1 新版使 IntelliJ IDEA 距离实现全面测试覆盖又近了一步。该项更新的重点是确定测试未完全覆盖代码中的哪些条件语句。


条件语句覆盖.gif


现在,IntelliJ IDEA 既显示哪一行具有未覆盖的条件,还会指定未覆盖的条件分支或变量值。 这项功能默认启用。


框架和技术



  • 针对 Spring 的改进 Bean 补全和自动装配


IntelliJ IDEA Ultimate 现在为应用程序上下文中的所有 Bean 提供自动补全,并自动装配 Bean。


针对 Spring 的改进 Bean 补全和自动装配.gif


如果 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。



作者:CodeSheep
来源:juejin.cn/post/7355389990531907636
收起阅读 »

身份认证的尽头竟然是无密码 ?

概述 几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临...
继续阅读 »

概述


几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。


HTTP 认证


HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。


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 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:



  1. 前端通过表单收集用户的账号和密码

  2. 通过协商的方式发送服务端进行验证的方式。


常见的表单认证页面通常如下:


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 registration


相比于传统的密码,WebAuthn 具有以下优势:



  1. 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。

  2. 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。

  3. 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。


总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。


实现效果


当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:


WebAuthn login


实现原理


WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:


webauthn 交互时序图


登录流程大致可以分为以下步骤:



  1. 用户访问登录页面,填入用户名后即可点击登录按钮。

  2. 服务器返回随机字符串 Challenge、用户 UserID。

  3. 浏览器将 Challenge 和 UserID 转发给验证器。

  4. 验证器提示用户进行认证操作。

  5. 服务端接收到浏览器转发来的被私钥加密的 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 根因和修复方案。


image.png


破案了!


确定 bug 根因后,其他人默默去休息了……


接下来我负责修 bug、测试、打包、发版、验证…… 不知不觉,天空破晓,一直搞到早上 8 点多…… 在线上完成验证,监控曲线恢复正常!bug 修复完成!
image.png


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架构的,大多数手机内存还不小,相对于现在各大厂商提供的云服务器来讲,配置已经很不错了,所以这么好的资源能利用起来还是非常不错的~ 二、工具介绍 目前能用的工具有很...
继续阅读 »

一、前言


随着手机更新换代的加速,每个人都有一些功能正常,但是闲置的手机,其实现在的手机都是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…


image.png


安装完成后,可以执行以下命令更新一下各软件包:


pkg update && pkg upgrade

update.png


4.2 安装openSSH


成功安装Termux之后,虽然手机是可以像服务器一样执行一些操作,但是毕竟手机管理配置起来没有PC方便,所以可以安装SSH服务,方便PC来远程操作。


# 安装openssh
pkg install openssh

# 默认端口为8022,修改端口
sshd -p 8888

# 启动ssh服务
sshd

openssh.png


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

image2.png


五、注意点


5.1 保持服务在线


由于Termux是直接运行到Android手机上的,也是一个APP程序,所以需要注意Termux程序不要退出了。


5.2 内网服务


虽然经过上述方式已经实现了服务器的常规基础配置和操作功能,但是毕竟是在手机上的一个服务,也是受到网络环境限制的,因此如果要保证服务可用,需要保证手机和使用端在同意局域网内。


六、扩展


如果对手机作为网站服务器以及移动无线硬盘相关的内容,欢迎关注,后续会尽快分享相关方法。


作者:一念三千_
来源:juejin.cn/post/7459816593230397494
收起阅读 »

小米正式官宣开源!杀疯了!

最近,和往常一样在刷 GitHub Trending 热榜时,突然看到又一个开源项目冲上了 Trending 榜单。 一天之内就狂揽数千 star,仅仅用两三天时间,star 数就迅速破万,增长曲线都快干垂直了! 出于好奇,点进去看了看。 好家伙,这居然还是...
继续阅读 »

最近,和往常一样在刷 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 米家集成项目,大家怎么看呢?


作者:CodeSheep
来源:juejin.cn/post/7454170332712386572
收起阅读 »

悲惨!刚入职没几天,无意间把数据库删了,很尴尬,原因很奇葩

1. offer收割机,就职新公司 5年前的就业环境非常好,当时面试了很多家公司,收到了很多 offer。最终我决定入职一家互联网教育公司,新的公司福利非常好,各种零食随便吃,据说还能正点下班,一切都超出我的期望,“可算让我找着神仙公司了”,我的心里一阵窃喜。...
继续阅读 »

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脚本,自然不会有问题。


image.png


6. 深刻教训


最终数据被修复了,DBA有测试数据库的备份,然而快照并非实时的,不可避免地还是丢失了一部分数据。


所幸的是出问题的是测试环境,并非线上环境。 否则,我会不会被起诉,也未可知。


后续的改进措施包括



  1. 收回了数据库账户的部分权限,只有管理账户才可以修改数据库表结构。代码中执行 DML语句的账户不允许执行 DDL 语句。

  2. DBA 盘点测试数据库的快照能力,确保快照间隔足够短,另外新增一个调研课题:删库后如何快速恢复,参照下其他公司的方案。

  3. 所有的项目 spring.data.initialize 全部声明为 false。不自动执行 SQL 脚本

  4. SQL脚本一律不许出现 删除表的语句。SQL不能重复执行的问题,想其他办法解决。

  5. 另外的一个项目急需人手,把新来的那谁 调到其他项目上


这可能是程序员们在技术上越来越保守的原因……不经意的一个调整可能引发无法承受的滔天巨浪


作者:五阳
来源:juejin.cn/post/7412490391935893541
收起阅读 »