注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

“新E代弯道王”MAZDA EZ-6鹭羽白内饰焕新

今日,“新E代弯道王”MAZDA EZ-6(以下称EZ-6)宣布鹭羽白内饰焕新,现在购车可享补贴后9.98万起。新车在现有的赤麂棕色高配内饰色之外,新增兼具时尚气质和高级质感的鹭羽白浅色内饰,不仅快速回应了部分用户对于浅色系内饰的需求,更为用户带来“增色不加价...
继续阅读 »

今日,“新E代弯道王”MAZDA EZ-6(以下称EZ-6)宣布鹭羽白内饰焕新,现在购车可享补贴后9.98万起。新车在现有的赤麂棕色高配内饰色之外,新增兼具时尚气质和高级质感的鹭羽白浅色内饰,不仅快速回应了部分用户对于浅色系内饰的需求,更为用户带来“增色不加价”的新选择。

EZ-6自推出补贴后9.98万起售的超高智价比购车模式以来,市场热度持续攀升。在春日出游季到来之际,长安马自达精准捕捉用户对浅色高质感内饰的喜好,将纤细轻柔,丝般细腻,又蓬松似云的鹭羽白色融入座椅工艺,与那些追求色泽明快简约大气的用户相得益彰,彰显出他们对高品质生活的高雅品味。

EZ-6的座椅采用了和MAZDA CX-90相同的菱形衍缝工艺,包裹性极强。Nappa真皮工艺,经鞣制后软度大幅提升,冬暖夏凉的亲肤感让身体一秒沦陷,配合10向电动调节,3档座椅通风&加热,能够满足各种身材驾驶者对理想坐姿的需求和温度需求。此外,EZ-6内饰材质均通过EPD环保产品声明、VEGAN「素食」产品、OEKO-TEX Standard 100婴儿级生态产品三大权威认证,打造让用户安全、安心更健康的乘坐体验。

作为合资B级电动轿车市场唯一同时提供增程和纯电动力选择的车型,EZ-6满足了用户全场景、全工况的出行需求。线性流畅的加速、自信安心的刹车、舒适愉悦的过弯、精准稳定的转向、迅捷的车身响应,EZ-6在电动化时代,依然能够为用户带来「人马一体」的驾乘愉悦。

目前,购EZ-6全系可享至高40,000元补贴,包括至高20,000元置换国补+15,000元置换/增购厂补+5,000元保险补贴;选择金融购车的用户可享100,000元尾款6年0息(和置换厂补二选一),在安全领域,长安马自达再次送出价值7,999元不限车主、不限里程终身零燃权益,彻底消除用户的后顾之忧无论是你的第一辆车之选,还是家庭之选,都能享受高品质的新能源出行乐趣。现在,登录长安马自达悦马星空」APP或小程序预约试驾,或亲临全国授权经销商门店试驾,即可解锁EZ-6全场景驾控乐趣

收起阅读 »

为什么把私钥写在代码里是一个致命错误

为什么把私钥写在代码里是一个致命错误 在技术社区经常能看到一些开发者分享的教训,前几天就有人发帖讲述一位Java开发者因同事将私钥直接硬编码在代码里而感到愤怒的事情。这种情况虽然听起来可笑,但在开发团队中却相当常见,尤其对于经验不足的程序员来说。 为什么把私钥...
继续阅读 »

为什么把私钥写在代码里是一个致命错误


在技术社区经常能看到一些开发者分享的教训,前几天就有人发帖讲述一位Java开发者因同事将私钥直接硬编码在代码里而感到愤怒的事情。这种情况虽然听起来可笑,但在开发团队中却相当常见,尤其对于经验不足的程序员来说。


为什么把私钥写在代码里如此危险?


1. 代码会被分享和同步


代码通常会提交到Git或SVN等版本控制系统中。一旦私钥被提交,团队中的每个人都能看到这些敏感信息。即使后来删除了私钥,在历史记录中依然可以找到。有开发者就分享过真实案例:团队成员意外将AWS密钥提交到GitHub,结果第二天账单暴增数千元——有人利用泄露的密钥进行了挖矿活动。


2. 违反安全和职责分离原则


在规范的开发流程中,密钥管理和代码开发应该严格分离。通常由运维团队负责密钥管理,而开发人员则不需要(也不应该)直接接触生产环境的密钥。这是基本的安全实践。


3. 环境迁移的噩梦


当应用从开发环境迁移到测试环境,再到生产环境时,如果密钥硬编码在代码中,每次环境切换都需要修改代码并重新编译。这不仅效率低下,还容易出错。


正确的做法


业内已有多种成熟的解决方案:



  • 使用环境变量存储敏感信息

  • 采用专门的配置文件(确保加入.gitignore)

  • 使用AWS KMS、HashiCorp Vault等专业密钥管理系统

  • 在CI/CD流程中动态注入密钥


有开发团队就曾经花费两周时间清理代码中的硬编码密钥。其中甚至发现了一个已离职员工留下的"临时"数据库密码,注释中写着"临时用,下周改掉"——然而那个"下周"已经过去五年了。


作为专业开发者,应当始终保持良好的安全习惯。将私钥硬编码进代码,就像把家门钥匙贴在门上一样不可理喻。


这个教训值得所有软件工程师引以为戒。


作者:Asthenian
来源:juejin.cn/post/7489043337290203163
收起阅读 »

双Token无感刷新方案

提醒一下 双Token机制并没有从根本上解决安全性的问题,本文章只是提供一个思路,具体是否选择请大家仔细斟酌考虑,笔者水平有限,非常抱歉对你造成不好的体验。 token有效期设置问题 最近在做用户认证模块的后端功能开发,之前就有一个问题困扰了我好久,就是如何设...
继续阅读 »

提醒一下


双Token机制并没有从根本上解决安全性的问题,本文章只是提供一个思路,具体是否选择请大家仔细斟酌考虑,笔者水平有限,非常抱歉对你造成不好的体验。


token有效期设置问题


最近在做用户认证模块的后端功能开发,之前就有一个问题困扰了我好久,就是如何设置token的过期时间,前端在申请后端登录接口成功之后,会返回一个token值,存储在用户端本地,用户要访问后端的其他接口必须通过请求头带上这个token值,但是这个token的有效期应该设置为多少?



  1. 如果设置的太短,比如1小时,那么用户一小时之后。再访问其他接口,需要再次重新登录,对用户的体验极差

  2. 如果设置为一个星期,那么在这个时间内







      • 一旦token泄露,攻击者可长期冒充用户身份,直到token过期,服务端无法限制其访问用户数据

      • 虽然可以依赖黑名单机制,但会增加系统复杂度,还要进行系统监测

      • 如果在这段时间恶意用户利用未过期的条款持续调用后端API将会导致资源耗尽或产生巨额费用






所以有没有两者都兼顾的方案呢?


双token无感刷新方案


传统的token方案要么频繁要求用户重新登录,要么面临长期有效的安全风险


但是双token无感刷新机制,通过组合设计,在保证安全性的情况下,实现无感知的认证续期


核心设计



  1. access_token:访问令牌,有效期一般设置为15~30分钟,主要用于对后端请求API的交互

  2. refresh_token:刷新令牌,一般设置为一个星期到一个月,主要用于获取新的access_token


大致的执行流程如下


用户登录之后,后端返回access_tokenrefresh_token响应给前端,前端将两个token存储在用户本地



在用户端发起前端请求,访问后端接口,在请求头中携带上access_token



前端会对access_token的过期时间进行检测,当access_token过期前一分钟,前端通过refresh_token向后端发起请求,后端判断refresh_token是否有效,有效则重新获取新的access_token,返回给前端替换掉之前的access_token存储在用户本地,无效则要求用户重新认证



这样的话对于用户而言token的刷新是无感知的,不会影响用户体验,只有当refresh_token失效之后,才需要用户重新进行登录认证,同时,后端可以通过对用户refresh_token的管理来限制用户对后端接口的请求,大大提高了安全性


有了这个思路,写代码就简单了


@Service
public class LoginServiceImpl implements LoginService {

@Autowired
private JwtUtils jwtUtils;

// token过期时间
private static final Integer TOKEN_EXPIRE_DAYS =5;
// token续期时间

private static final Integer TOKEN_RENEWAL_MINUTE =15;

@Override
public boolean verify(String refresh_token) {
Long uid = jwtUtils.getUidOrNull(refresh_token);
if (Objects.isNull(uid)) {
return false;
}
String key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN,uid);
String realToken = RedisUtils.getStr(key);
return Objects.equals(refresh_token, realToken);
}

@Override
public void renewalTokenIfNecessary(String refresh_token) {
Long uid = jwtUtils.getUidOrNull(refresh_token);
if (Objects.isNull(uid)) {
return;
}
String refresh_key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN, uid);
long expireSeconds = RedisUtils.getExpire(refresh_key, TimeUnit.SECONDS);
if (expireSeconds == -2) { // key不存在,refresh_token已过期
return;
}
String access_key = RedisKey.getKey(RedisKey.USER_ACCESS_TOKEN, uid);
RedisUtils.expire(access_key, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
}

@Override
@Transactional(rollbackFor = Exception.class)
@RedissonLock(key = "#uid")
public LoginTokenResponse login(Long uid) {
String refresh_key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN, uid);
String access_key = RedisKey.getKey(RedisKey.USER_ACCESS_TOKEN, uid);
String refresh_token = RedisUtils.getStr(refresh_key);
String access_token;
if (StrUtil.isNotBlank(refresh_token)) { //刷新令牌不为空
access_token = jwtUtils.createToken(uid);
RedisUtils.set(access_key, access_token, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
return LoginTokenResponse.builder()
.refresh_token(refresh_token).access_token(access_token)
.build();
}
refresh_token = jwtUtils.createToken(uid);
RedisUtils.set(refresh_key, refresh_token, TOKEN_EXPIRE_DAYS, TimeUnit.DAYS);
access_token = jwtUtils.createToken(uid);
RedisUtils.set(access_key, access_token, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
return LoginTokenResponse.builder()
.refresh_token(refresh_token).access_token(access_token)
.build();
}
}}

注意事项



  1. 安全存储Refresh Token时,优先使用HttpOnly+Secure Cookie而非LocalStorage

  2. 在颁发新Access Token时,重置旧Token的生存周期(滑动过期)而非简单续期

  3. 针对高敏感操作(如支付、改密),建议强制二次认证以突破Token机制的限制


安全问题


双Token机制并没有从根本上解决安全性的问题,它只是尝试通过改进设计,优化用户体验,全面的安全策略需要多层防护,分别针对不同类型的威胁和风险,而不仅仅依赖于Token的管理方式或数量


安全是一个持续对抗的过程,关键在于提高攻击者的成本,而非追求绝对防御。


"完美的认证方案不存在,但聪明的权衡永远存在。"


本笔者水平有限,望各位海涵


如果文章中有不对的地方,欢迎大家指正。


作者:昔年种柳
来源:juejin.cn/post/7486782063422717962
收起阅读 »

程序员,你使用过灰度发布吗?

大家好呀,我是猿java。 在分布式系统中,我们经常听到灰度发布这个词,那么,什么是灰度发布?为什么需要灰度发布?如何实现灰度发布?这篇文章,我们来聊一聊。 1. 什么是灰度发布? 简单来说,灰度发布也叫做渐进式发布或金丝雀发布,它是一种逐步将新版本应用到生产...
继续阅读 »

大家好呀,我是猿java


在分布式系统中,我们经常听到灰度发布这个词,那么,什么是灰度发布?为什么需要灰度发布?如何实现灰度发布?这篇文章,我们来聊一聊。


1. 什么是灰度发布?


简单来说,灰度发布也叫做渐进式发布金丝雀发布,它是一种逐步将新版本应用到生产环境中的策略。相比于一次性全量发布,灰度发布可以让我们在小范围内先行测试新功能,监控其表现,再决定是否全面推开。这样做的好处是显而易见的:



  1. 降低风险:新版本如果存在 bug,只影响少部分用户,减少了对整体用户体验的冲击。

  2. 快速回滚:在小范围内发现问题,可以更快地回到旧版本。

  3. 收集反馈:可以在真实环境中收集用户反馈,优化新功能。


2. 原理解析


要理解灰度发布,我们需要先了解一下它的基本流程:



  1. 准备阶段:在生产环境中保留旧版本,同时引入新版本。

  2. 小范围发布:将新版本先部署到一小部分用户,例如1%-10%。

  3. 监控与评估:监控新版本的性能和稳定性,收集用户反馈。

  4. 逐步扩展:如果一切正常,将新版本逐步推广到更多用户。

  5. 全面切换:当确认新版本稳定后,全面替换旧版本。


在这个过程中,关键在于如何切分流量,确保新旧版本平稳过渡。常见的切分方式包括:



  • 基于用户ID:根据用户的唯一标识,将部分用户指向新版本。

  • 基于地域:先在特定地区进行发布,观察效果后再扩展到其他地区。

  • 基于设备:例如,先在Android或iOS用户中进行发布。


3. 示例演示


为了更好地理解灰度发布,接下来,我们通过一个简单的 Java示例来演示基本的灰度发布策略。假设我们有一个简单的 Web应用,有两个版本的登录接口/login/v1/login/v2,我们希望将百分之十的流量引导到v2,其余流量继续使用v1


3.1 第一步:引入灰度策略


我们可以通过拦截器(Interceptor)来实现流量的切分。以下是一个基于Spring Boot的简单实现:


import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Random;

@Component
public class GrayReleaseInterceptor implements HandlerInterceptor {

private static final double GRAY_RELEASE_PERCENT = 0.1; // 10% 流量

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
if ("/login".equals(uri)) {
if (isGrayRelease()) {
// 重定向到新版本接口
response.sendRedirect("/login/v2");
return false;
} else {
// 使用旧版本接口
response.sendRedirect("/login/v1");
return false;
}
}
return true;
}

private boolean isGrayRelease() {
Random random = new Random();
return random.nextDouble() < GRAY_RELEASE_PERCENT;
}
}

3.2 第二步:配置拦截器


在Spring Boot中,我们需要将拦截器注册到应用中:


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Autowired
private GrayReleaseInterceptor grayReleaseInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(grayReleaseInterceptor).addPathPatterns("/login");
}
}

3.3 第三步:实现不同版本的登录接口


import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/login")
public class LoginController {

@GetMapping("/v1")
public String loginV1(@RequestParam String username, @RequestParam String password) {
// 旧版本登录逻辑
return "登录成功 - v1";
}

@GetMapping("/v2")
public String loginV2(@RequestParam String username, @RequestParam String password) {
// 新版本登录逻辑
return "登录成功 - v2";
}
}

在上面三个步骤之后,我们就实现了登录接口地灰度发布:



  • 当用户访问/login时,拦截器会根据设定的灰度比例(10%)决定请求被重定向到/login/v1还是/login/v2

  • 大部分用户会体验旧版本接口,少部分用户会体验新版本接口。


3.4 灰度发布优化


上述示例,我们只是一个简化的灰度发布实现,实际生产环境中,我们可能需要更精细的灰度策略,例如:



  1. 基于用户属性:不仅仅是随机切分,可以根据用户的地理位置、设备类型等更复杂的条件。

  2. 动态配置:通过配置中心动态调整灰度比例,无需重启应用。

  3. 监控与告警:集成监控系统,实时监控新版本的性能指标,异常时自动回滚。

  4. A/B 测试:结合A/B测试,进一步优化用户体验和功能效果。


grayscale-release.png


4. 为什么需要灰度发布?


在实际工作中,为什么我们要使用灰度发布?这里我们总结了几个重要的原因。


4.1 降低发布风险


每次发布新版本,尤其是功能性更新或架构调整,都会伴随着一定的风险。即使经过了充分的测试,实际生产环境中仍可能出现意想不到的问题。灰度发布通过将新版本逐步推向部分用户,可以有效降低全量发布可能带来的风险。


举个例子,假设你上线了一个全新的支付功能,直接面向所有用户开放。如果这个功能存在严重 bug,可能导致大量用户无法完成支付,甚至影响公司声誉。而如果采用灰度发布,先让10%的用户体验新功能,发现问题后只需影响少部分用户,修复起来也更为迅速和容易。


4.2 快速回滚


在传统的全量发布中,一旦发现问题,回滚到旧版本可能需要耗费大量时间和精力,尤其是在高并发系统中,数据状态的同步与恢复更是复杂。而灰度发布由于新版本只覆盖部分流量,问题定位和回滚变得更加简单和快速。


比如说,你在灰度发布阶段发现新版本的某个功能在某些特定条件下会导致系统崩溃,立即可以停止向新用户推送这个版本,甚至只针对受影响的用户进行回滚操作,而不用影响全部用户的正常使用。


4.3 实时监控与反馈


灰度发布让你有机会在真实的生产环境中监控新版本的表现,并收集用户的反馈。这些数据对于评估新功能的实际效果至关重要,有助于做出更明智的决策。


举个具体的场景,你新增了一个推荐算法,希望提升用户的点击率。在灰度发布阶段,你可以监控新算法带来的点击率变化、服务器负载情况等指标,确保新算法确实带来了预期的效果,而不是引入了新的问题。


4.4 提升用户体验


通过灰度发布,你可以在推出新功能时,逐步优化用户体验。先让一部分用户体验新功能,收集他们的使用反馈,根据反馈不断改进,最终推出一个更成熟、更符合用户需求的版本。


举个例子,你开发了一项新的用户界面设计,直接全量发布可能会让一部分用户感到不适应或不满意。灰度发布允许你先让一部分用户体验新界面,收集他们的意见,进行必要的调整,再逐步扩大使用范围,确保最终发布的版本能获得更多用户的认可和喜爱。


4.5 支持A/B测试


灰度发布是实现A/B测试的基础。通过将用户随机分配到不同的版本,你可以比较不同版本的表现,选择最优方案进行全面推行。这对于优化产品功能和提升用户体验具有重要意义。


比如说,你想测试两个不同的推荐算法,看哪个能带来更高的转化率。通过灰度发布,将用户随机分配到使用算法A和算法B的版本,比较它们的表现,最终选择效果更好的算法进行全面部署。


4.6 应对复杂的业务需求


在一些复杂的业务场景中,全量发布可能无法满足灵活的需求,比如分阶段推出新功能、针对不同用户群体进行差异化体验等。灰度发布提供了更高的灵活性和可控性,能够更好地适应多变的业务需求。


例如,你正在开发一个面向企业用户的新功能,希望先让部分高价值客户试用,收集他们的反馈后再决定是否全面推广。灰度发布让这一过程变得更加顺畅和可控。


5. 总结


本文,我们详细地分析了灰度发布,它是一种强大而灵活的部署策略,能有效降低新版本上线带来的风险,提高系统的稳定性和用户体验。作为Java开发者,掌握灰度发布的原理和实现方法,不仅能提升我们的技术能力,还能为团队的项目成功保驾护航。


对于灰度发布,如果你有更多的问题或想法,欢迎随时交流!


6. 学习交流


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


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

URL地址末尾加不加”/“有什么区别

URL 结尾是否带 / 主要影响的是 服务器如何解析请求 以及 相对路径的解析方式,具体区别如下: 1. 基础概念 URL(统一资源定位符) :用于唯一标识互联网资源,如网页、图片、API等。 目录 vs. 资源: 以 / 结尾的 URL 通常表示目录,...
继续阅读 »

URL 结尾是否带 / 主要影响的是 服务器如何解析请求 以及 相对路径的解析方式,具体区别如下:




1. 基础概念



  • URL(统一资源定位符) :用于唯一标识互联网资源,如网页、图片、API等。

  • 目录 vs. 资源



    • / 结尾的 URL 通常表示目录,例如:


      https://example.com/folder/


    • 不以 / 结尾的 URL 通常指向具体的资源(如文件),例如:


      https://example.com/file







2. / 和不带 / 的具体区别


(1)目录 vs. 资源



  • https://example.com/folder/



    • 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如 index.html)。



  • https://example.com/folder



    • 服务器可能会将其视为 文件,如果 folder 不是文件,而是目录,服务器可能会返回 301 重定向到 folder/




📌 示例





(2)相对路径解析


URL 末尾是否有 / 会影响相对路径的解析


假设 HTML 页面包含以下 <img> 标签:


<img src="image.png">

📌 示例:



原因:



  • / 结尾的 URL,浏览器会认为它是一个目录,相对路径会基于 folder/ 解析。

  • 不带 /,浏览器可能认为 folder文件,相对路径解析可能会出现错误。




(3)SEO 影响


搜索引擎对 https://example.com/folder/https://example.com/folder 可能会视为两个不同的页面,导致 重复内容问题,影响 SEO 排名。因此:





(4)API 请求


对于 RESTful API,带 / 和不带 / 可能导致不同的行为:



一些 API 服务器对 / 非常敏感,因此最好遵循 API 文档的规范。




3. 总结


URL 形式作用影响
https://example.com/folder/目录通常返回 folder/ 下的默认文件,如 index.html,相对路径解析基于 folder/
https://example.com/folder资源(或重定向)可能被解析为文件,或者服务器重定向到 folder/,相对路径解析可能错误
https://api.example.com/data/API 路径可能与 https://api.example.com/data 表现不同,具体由 API 设计决定

如果你在开发网站,建议:



  1. 统一 URL 规则,例如所有目录都加 / 或者所有请求都不加 /,然后用 301 重定向 确保一致性。

  2. 测试 API 的行为,确认带 / 和不带 / 是否影响请求结果。


作者:Chiyamin
来源:juejin.cn/post/7468112128928350242
收起阅读 »

公司来的新人用字符串存储日期,被组长怒怼了...

在日常的软件开发工作中,存储时间是一项基础且常见的需求。无论是记录数据的操作时间、金融交易的发生时间,还是行程的出发时间、用户的下单时间等等,时间信息与我们的业务逻辑和系统功能紧密相关。因此,正确选择和使用 MySQL 的日期时间类型至关重要,其恰当与否甚至可...
继续阅读 »

在日常的软件开发工作中,存储时间是一项基础且常见的需求。无论是记录数据的操作时间、金融交易的发生时间,还是行程的出发时间、用户的下单时间等等,时间信息与我们的业务逻辑和系统功能紧密相关。因此,正确选择和使用 MySQL 的日期时间类型至关重要,其恰当与否甚至可能对业务的准确性和系统的稳定性产生显著影响。


本文旨在帮助开发者重新审视并深入理解 MySQL 中不同的时间存储方式,以便做出更合适项目业务场景的选择。


不要用字符串存储日期


和许多数据库初学者一样,笔者在早期学习阶段也曾尝试使用字符串(如 VARCHAR)类型来存储日期和时间,甚至一度认为这是一种简单直观的方法。毕竟,'YYYY-MM-DD HH:MM:SS' 这样的格式看起来清晰易懂。


但是,这是不正确的做法,主要会有下面两个问题:



  1. 空间效率:与 MySQL 内建的日期时间类型相比,字符串通常需要占用更多的存储空间来表示相同的时间信息。

  2. 查询与计算效率低下

    • 比较操作复杂且低效:基于字符串的日期比较需要按照字典序逐字符进行,这不仅不直观(例如,'2024-05-01' 会小于 '2024-1-10'),而且效率远低于使用原生日期时间类型进行的数值或时间点比较。

    • 计算功能受限:无法直接利用数据库提供的丰富日期时间函数进行运算(例如,计算两个日期之间的间隔、对日期进行加减操作等),需要先转换格式,增加了复杂性。

    • 索引性能不佳:基于字符串的索引在处理范围查询(如查找特定时间段内的数据)时,其效率和灵活性通常不如原生日期时间类型的索引。




DATETIME 和 TIMESTAMP 选择


DATETIMETIMESTAMP 是 MySQL 中两种非常常用的、用于存储包含日期和时间信息的数据类型。它们都可以存储精确到秒(MySQL 5.6.4+ 支持更高精度的小数秒)的时间值。那么,在实际应用中,我们应该如何在这两者之间做出选择呢?


下面我们从几个关键维度对它们进行对比:


时区信息


DATETIME 类型存储的是字面量的日期和时间值,它本身不包含任何时区信息。当你插入一个 DATETIME 值时,MySQL 存储的就是你提供的那个确切的时间,不会进行任何时区转换。


这样就会有什么问题呢? 如果你的应用需要支持多个时区,或者服务器、客户端的时区可能发生变化,那么使用 DATETIME 时,应用程序需要自行处理时区的转换和解释。如果处理不当(例如,假设所有存储的时间都属于同一个时区,但实际环境变化了),可能会导致时间显示或计算上的混乱。


TIMESTAMP 和时区有关。存储时,MySQL 会将当前会话时区下的时间值转换成 UTC(协调世界时)进行内部存储。当查询 TIMESTAMP 字段时,MySQL 又会将存储的 UTC 时间转换回当前会话所设置的时区来显示。


这意味着,对于同一条记录的 TIMESTAMP 字段,在不同的会话时区设置下查询,可能会看到不同的本地时间表示,但它们都对应着同一个绝对时间点(UTC 时间)。这对于需要全球化、多时区支持的应用来说非常有用。


下面实际演示一下!


建表 SQL 语句:


CREATE TABLE `time_zone_test` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`date_time` datetime DEFAULT NULL,
`time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

插入一条数据(假设当前会话时区为系统默认,例如 UTC+0)::


INSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW());

查询数据(在同一时区会话下):


SELECT date_time, time_stamp FROM time_zone_test;

结果:


+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 09:53:32 |
+---------------------+---------------------+

现在,修改当前会话的时区为东八区 (UTC+8):


SET time_zone = '+8:00';

再次查询数据:


# TIMESTAMP 的值自动转换为 UTC+8 时间
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 17:53:32 |
+---------------------+---------------------+

扩展:MySQL 时区设置常用 SQL 命令


# 查看当前会话时区
SELECT @@session.time_zone;
# 设置当前会话时区
SET time_zone = 'Europe/Helsinki';
SET time_zone = "+00:00";
# 数据库全局时区设置
SELECT @@global.time_zone;
# 设置全局时区
SET GLOBAL time_zone = '+8:00';
SET GLOBAL time_zone = 'Europe/Helsinki';

占用空间


下图是 MySQL 日期类型所占的存储空间(官方文档传送门:dev.mysql.com/doc/refman/…):



在 MySQL 5.6.4 之前,DateTime 和 TIMESTAMP 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 58 字节,TIMESTAMP 的范围是 47 字节。


表示范围


TIMESTAMP 表示的时间范围更小,只能到 2038 年:



  • DATETIME:'1000-01-01 00:00:00.000000' 到 '9999-12-31 23:59:59.999999'

  • TIMESTAMP:'1970-01-01 00:00:01.000000' UTC 到 '2038-01-19 03:14:07.999999' UTC


性能


由于 TIMESTAMP 在存储和检索时需要进行 UTC 与当前会话时区的转换,这个过程可能涉及到额外的计算开销,尤其是在需要调用操作系统底层接口获取或处理时区信息时。虽然现代数据库和操作系统对此进行了优化,但在某些极端高并发或对延迟极其敏感的场景下,DATETIME 因其不涉及时区转换,处理逻辑相对更简单直接,可能会表现出微弱的性能优势。


为了获得可预测的行为并可能减少 TIMESTAMP 的转换开销,推荐的做法是在应用程序层面统一管理时区,或者在数据库连接/会话级别显式设置 time_zone 参数,而不是依赖服务器的默认或操作系统时区。


数值时间戳是更好的选择吗?


除了上述两种类型,实践中也常用整数类型(INTBIGINT)来存储所谓的“Unix 时间戳”(即从 1970 年 1 月 1 日 00:00:00 UTC 起至目标时间的总秒数,或毫秒数)。


这种存储方式的具有 TIMESTAMP 类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。


时间戳的定义如下:



时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间。



数据库中实际操作:


-- 将日期时间字符串转换为 Unix 时间戳 (秒)
mysql> SELECT UNIX_TIMESTAMP('2020-01-11 09:53:32');
+---------------------------------------+
| UNIX_TIMESTAMP('2020-01-11 09:53:32') |
+---------------------------------------+
| 1578707612 |
+---------------------------------------+
1 row in set (0.00 sec)

-- 将 Unix 时间戳 (秒) 转换为日期时间格式
mysql> SELECT FROM_UNIXTIME(1578707612);
+---------------------------+
| FROM_UNIXTIME(1578707612) |
+---------------------------+
| 2020-01-11 09:53:32 |
+---------------------------+
1 row in set (0.01 sec)

PostgreSQL 中没有 DATETIME


由于有读者提到 PostgreSQL(PG) 的时间类型,因此这里拓展补充一下。PG 官方文档对时间类型的描述地址:http://www.postgresql.org/docs/curren…


PostgreSQL 时间类型总结


可以看到,PG 没有名为 DATETIME 的类型:



  • PG 的 TIMESTAMP WITHOUT TIME ZONE在功能上最接近 MySQL 的 DATETIME。它存储日期和时间,但不包含任何时区信息,存储的是字面值。

  • PG 的TIMESTAMP WITH TIME ZONE (或 TIMESTAMPTZ) 相当于 MySQL 的 TIMESTAMP。它在存储时会将输入值转换为 UTC,并在检索时根据当前会话的时区进行转换显示。


对于绝大多数需要记录精确发生时间点的应用场景,TIMESTAMPTZ是 PostgreSQL 中最推荐、最健壮的选择,因为它能最好地处理时区复杂性。


总结


MySQL 中时间到底怎么存储才好?DATETIME?TIMESTAMP?还是数值时间戳?


并没有一个银弹,很多程序员会觉得数值型时间戳是真的好,效率又高还各种兼容,但是很多人又觉得它表现的不够直观。


《高性能 MySQL 》这本神书的作者就是推荐 TIMESTAMP,原因是数值表示时间不够直观。下面是原文:



每种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:


类型存储空间日期格式日期范围是否带时区信息
DATETIME5~8 字节YYYY-MM-DD hh:mm:ss[.fraction]1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999]
TIMESTAMP4~7 字节YYYY-MM-DD hh:mm:ss[.fraction]1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999]
数值型时间戳4 字节全数字如 15787076121970-01-01 00:00:01 之后的时间

选择建议小结:



  • TIMESTAMP 的核心优势在于其内建的时区处理能力。数据库负责 UTC 存储和基于会话时区的自动转换,简化了需要处理多时区应用的开发。如果应用需要处理多时区,或者希望数据库能自动管理时区转换,TIMESTAMP 是自然的选择(注意其时间范围限制,也就是 2038 年问题)。

  • 如果应用场景不涉及时区转换,或者希望应用程序完全控制时区逻辑,并且需要表示 2038 年之后的时间,DATETIME 是更稳妥的选择。

  • 如果极度关注比较性能,或者需要频繁跨系统传递时间数据,并且可以接受可读性的牺牲(或总是在应用层转换),数值时间戳是一个强大的选项。


作者:JavaGuide
来源:juejin.cn/post/7488927722774937609
收起阅读 »

websocket和socket有什么区别?

WebSocket 和 Socket 的区别 WebSocket 和 Socket 是两种不同的网络通信技术,它们在使用场景、协议、功能等方面有显著的差异。以下是它们之间的主要区别: 1. 定义 Socket:Socket 是一种网络通信的工具,可以实现不同...
继续阅读 »

WebSocket 和 Socket 的区别


WebSocket 和 Socket 是两种不同的网络通信技术,它们在使用场景、协议、功能等方面有显著的差异。以下是它们之间的主要区别:


1. 定义



  • Socket:Socket 是一种网络通信的工具,可以实现不同计算机之间的数据交换。它是操作系统提供的 API,广泛应用于 TCP/IP 网络编程中。Socket 可以是流式(TCP)或数据报(UDP)类型的,用于低层次的网络通信。

  • WebSocket:WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它允许服务器和客户端之间实时地交换数据。WebSocket 是建立在 HTTP 协议之上的,主要用于 Web 应用程序,以实现实时数据传输。


2. 协议层次



  • Socket:Socket 是一种底层通信机制,通常与 TCP/IP 协议一起使用。它允许开发者通过编程语言直接访问网络接口。

  • WebSocket:WebSocket 是一种应用层协议,建立在 HTTP 之上。在初始握手时,使用 HTTP 协议进行连接,之后切换到 WebSocket 协议进行数据传输。


3. 连接方式



  • Socket:Socket 通常需要手动管理连接的建立和关闭。通过调用相关的 API,开发者需要处理连接的状态,确保数据的可靠传输。

  • WebSocket:WebSocket 的连接管理相对简单。建立连接后,不需要频繁地进行握手,可以保持持久连接,随时进行数据交换。


4. 数据传输模式



  • Socket:Socket 可以实现单向或双向的数据传输,但通常需要在发送和接收之间进行明确的控制。

  • WebSocket:WebSocket 支持全双工通信,客户端和服务器之间可以随时互相发送数据,无需等待响应。这使得实时通信变得更加高效。


5. 适用场景



  • Socket:Socket 常用于需要高性能、低延迟的场景,如游戏开发、文件传输、P2P 网络等。由于其底层特性,Socket 适合对网络性能有严格要求的应用。

  • WebSocket:WebSocket 主要用于 Web 应用程序,如即时聊天、实时通知、在线游戏等。由于其易用性和高效性,WebSocket 特别适合需要实时更新和交互的前端应用。


6. 数据格式



  • Socket:Socket 发送的数据通常是二进制流或文本流,需要开发者自行定义数据格式和解析方式。

  • WebSocket:WebSocket 支持多种数据格式,包括文本(如 JSON)和二进制(如 Blob、ArrayBuffer)。WebSocket 的数据传输格式非常灵活,易于与 JavaScript 进行交互。


7. 性能



  • Socket:Socket 对于大量并发连接的处理性能较高,但需要开发者进行优化和管理。

  • WebSocket:WebSocket 在建立连接后可以保持长连接,减少了握手带来的延迟,适合高频率的数据交换场景。


8. 安全性



  • Socket:Socket 的安全性取决于使用的协议(如 TCP、UDP)和应用层的实现。开发者需要自行处理安全问题,如加密和身份验证。

  • WebSocket:WebSocket 支持通过 WSS(WebSocket Secure)进行加密,提供更高层次的安全保障。它可以很好地与 HTTPS 集成,确保数据在传输过程中的安全性。


9. 浏览器支持



  • Socket:Socket 是底层的网络通信技术,通常不直接在浏览器中使用。Web 开发者需要通过后端语言(如 Node.js、Java、Python)来实现 Socket 通信。

  • WebSocket:WebSocket 是专为 Web 应用设计的,所有现代浏览器均支持 WebSocket 协议,开发者可以直接在客户端使用 JavaScript API 进行通信。


10. 工具和库



  • Socket:使用 Socket 进行开发时,开发者通常需要使用底层网络编程库,如 BSD Sockets、Java Sockets、Python's socket 模块等。

  • WebSocket:WebSocket 提供了简单的 API,开发者可以使用原生 JavaScript 或第三方库(如 Socket.IO)轻松实现 WebSocket 通信。


结论


总结来说,WebSocket 是一种为现代 Web 应用量身定制的协议,具有实时、双向通信的优势,而 Socket 是一种底层的网络通信机制,提供更灵活的使用方式。选择使用哪种技术取决于具体的应用场景和需求。对于需要实时交互的 Web 应用,WebSocket 是更合适的选择;而对于底层或高性能要求的网络通信,Socket 提供了更多的控制和灵活性。


作者:Riesenzahn
来源:juejin.cn/post/7485631488114278454
收起阅读 »

完蛋,被扣工资了,都是JSON惹的祸

JSON是一种轻量级的数据交换格式,基于ECMAScript的一个子集设计,采用完全独立于编程语言的文本格式来表示数据。它易于人类阅读和编写,同时也便于机器解析和生成,这使得JSON在数据交换中具有高效性。‌ JSON也就成了每一个程序员每天都要使用一个小类库...
继续阅读 »

JSON是一种轻量级的数据交换格式,基于ECMAScript的一个子集设计,采用完全独立于编程语言的文本格式来表示数据。它易于人类阅读和编写,同时也便于机器解析和生成,这使得JSON在数据交换中具有高效性。‌


JSON也就成了每一个程序员每天都要使用一个小类库。无论你使用的谷歌的gson,阿里巴巴的fastjson,框架自带的jackjson,还是第三方的hutool的json等。总之,每天都要和他打交道。


但是,却在阴沟里翻了船。


1、平平无奇的接口


 /**
* 获取vehicleinfo 信息
*
* @RequestParam vehicleId
* @return Vehicle的json字符串
*/

String loadVehicleInfo(Integer vehicleId);

该接口就是通过一个vehicleId参数获取Vehicle对象,返回的数据是Vehicle的JSON字符串,也就是将获取的对象信息序列化成JSON字符串了。


2、无懈可击的引用


String jsonStr = auctVehicleService.loadVehicleInfo(freezeDetail.getVehicle().getId());
if (StringUtils.isNotBlank(jsonStr)) {
Vehicle vehicle = JSON.parseObject(jsonStr, Vehicle.class);
if (vehicle != null) {
// 后续省略 ...
}
}

看似无懈可击的引用,隐藏着魔鬼。为什么无懈可击,因为做了健壮性的判断,非空字符串、非空对象等的判断,根除了空指针异常。


但是,魔鬼隐藏在哪里呢?


3、故障引发



线上直接出现类似的故障(此报错信息为线下模拟)。


现在测试为什么没有问题:主要的测试了基础数据,测试的数据中恰好没有Date 类型的数据,所以线下没有测出来。


4、故障原因分析


从报错日志可以看出,是因为日期类型的参数导致的。Mar 24, 2025 1:23:10 PM 这样的日期格式无法使用Fastjson解析。


深入代码查看:


@Override
public String loadVehicleInfo(Integer vehicleId) {
String key = VEHICLE_KEY + vehicleId;
Object obj = cacheService.get(key);

if (null != obj && StringUtils.isNotEmpty(obj.toString())
&& !"null".equals(obj.toString())) {
String result = (String)obj;
return result;
}

String json = null;
try {
Vehicle vInfo = overrideVehicleAttributes(vehicleId);
// 使用了Gson序列化对象
json = gson.toJson(vInfo);
cacheService.setExpireSec(key, gson.toJson(vInfo), 5 * 60);
} catch (Exception e) {
cacheService.setExpireSec(key, "", 1 * 60);
} finally {
}

return json;
}

原来接口的实现里面采用了谷歌的Gson对返回的对象做了序列化。调用的地方又使用了阿里巴巴的Fastjson发序列化,导致参数解析异常。



完蛋,上榜是要被扣工资的!!!


5、小结


问题虽小,但是影响却很大。坊间一直讨论着,程序员为什么不能写出没有bug的程序。这也许是其中的一种答案吧。


肉疼,被扣钱了!!!


--END--




喜欢就点赞收藏,也可以关注我的微信公众号:编程朝花夕拾


作者:SimonKing
来源:juejin.cn/post/7485560281955958794
收起阅读 »

JDK 24 发布,新特性解读!

真快啊!Java 24 这两天已经正式发布啦!这是自 Java 21 以来的第三个非长期支持版本,和 Java 22、Java 23一样。 下一个长期支持版是 Java 25,预计今年 9 月份发布。 Java 24 带来的新特性还是蛮多的,一共 24 个。J...
继续阅读 »

真快啊!Java 24 这两天已经正式发布啦!这是自 Java 21 以来的第三个非长期支持版本,和 Java 22Java 23一样。


下一个长期支持版是 Java 25,预计今年 9 月份发布。


Java 24 带来的新特性还是蛮多的,一共 24 个。Java 23 和 Java 23 都只有 12 个,Java 24的新特性相当于这两次的总和了。因此,这个版本还是非常有必要了解一下的。


下图是从 JDK8 到 JDK 24 每个版本的更新带来的新特性数量和更新时间:



我在昨天晚上详细看了一下 Java 24 的详细更新,并对其中比较重要的新特性做了详细的解读,希望对你有帮助!


本文内容概览



JEP 478: 密钥派生函数 API(预览)


密钥派生函数 API 是一种用于从初始密钥和其他数据派生额外密钥的加密算法。它的核心作用是为不同的加密目的(如加密、认证等)生成多个不同的密钥,避免密钥重复使用带来的安全隐患。 这在现代加密中是一个重要的里程碑,为后续新兴的量子计算环境打下了基础


通过该 API,开发者可以使用最新的密钥派生算法(如 HKDF 和未来的 Argon2):


// 创建一个 KDF 对象,使用 HKDF-SHA256 算法
KDF hkdf = KDF.getInstance("HKDF-SHA256");

// 创建 Extract 和 Expand 参数规范
AlgorithmParameterSpec params =
HKDFParameterSpec.ofExtract()
.addIKM(initialKeyMaterial) // 设置初始密钥材料
.addSalt(salt) // 设置盐值
.thenExpand(info, 32); // 设置扩展信息和目标长度

// 派生一个 32 字节的 AES 密钥
SecretKey key = hkdf.deriveKey("AES", params);

// 可以使用相同的 KDF 对象进行其他密钥派生操作

JEP 483: 提前类加载和链接


在传统 JVM 中,应用在每次启动时需要动态加载和链接类。这种机制对启动时间敏感的应用(如微服务或无服务器函数)带来了显著的性能瓶颈。该特性通过缓存已加载和链接的类,显著减少了重复工作的开销,显著减少 Java 应用程序的启动时间。测试表明,对大型应用(如基于 Spring 的服务器应用),启动时间可减少 40% 以上。


这个优化是零侵入性的,对应用程序、库或框架的代码无需任何更改,启动也方式保持一致,仅需添加相关 JVM 参数(如 -XX:+ClassDataSharing)。


JEP 484: 类文件 API


类文件 API 在 JDK 22 进行了第一次预览(JEP 457),在 JDK 23 进行了第二次预览并进一步完善(JEP 466)。最终,该特性在 JDK 24 中顺利转正。


类文件 API 的目标是提供一套标准化的 API,用于解析、生成和转换 Java 类文件,取代过去对第三方库(如 ASM)在类文件处理上的依赖。


// 创建一个 ClassFile 对象,这是操作类文件的入口。
ClassFile cf = ClassFile.of();
// 解析字节数组为 ClassModel
ClassModel classModel = cf.parse(bytes);

// 构建新的类文件,移除以 "debug" 开头的所有方法
byte[] newBytes = cf.build(classModel.thisClass().asSymbol(),
classBuilder -> {
// 遍历所有类元素
for (ClassElement ce : classModel) {
// 判断是否为方法 且 方法名以 "debug" 开头
if (!(ce instanceof MethodModel mm
&& mm.methodName().stringValue().startsWith("debug"))) {
// 添加到新的类文件中
classBuilder.with(ce);
}
}
});

JEP 485: 流收集器


流收集器 Stream::gather(Gatherer) 是一个强大的新特性,它允许开发者定义自定义的中间操作,从而实现更复杂、更灵活的数据转换。Gatherer 接口是该特性的核心,它定义了如何从流中收集元素,维护中间状态,并在处理过程中生成结果。


与现有的 filtermapdistinct 等内置操作不同,Stream::gather 使得开发者能够实现那些难以用标准 Stream 操作完成的任务。例如,可以使用 Stream::gather 实现滑动窗口、自定义规则的去重、或者更复杂的状态转换和聚合。 这种灵活性极大地扩展了 Stream API 的应用范围,使开发者能够应对更复杂的数据处理场景。


基于 Stream::gather(Gatherer) 实现字符串长度的去重逻辑:


var result = Stream.of("foo", "bar", "baz", "quux")
.gather(Gatherer.ofSequential(
HashSet::new, // 初始化状态为 HashSet,用于保存已经遇到过的字符串长度
(set, str, downstream) -> {
if (set.add(str.length())) {
return downstream.push(str);
}
return true; // 继续处理流
}
))
.toList();// 转换为列表

// 输出结果 ==> [foo, quux]

JEP 486: 永久禁用安全管理器


JDK 24 不再允许启用 Security Manager,即使通过 java -Djava.security.manager命令也无法启用,这是逐步移除该功能的关键一步。虽然 Security Manager 曾经是 Java 中限制代码权限(如访问文件系统或网络、读取或写入敏感文件、执行系统命令)的重要工具,但由于复杂性高、使用率低且维护成本大,Java 社区决定最终移除它。


JEP 487: 作用域值 (第四次预览)


作用域值(Scoped Values)可以在线程内和线程间共享不可变的数据,优于线程局部变量,尤其是在使用大量虚拟线程时。


final static ScopedValue<...> V = new ScopedValue<>();

// In some method
ScopedValue.where(V, <value>)
.run(() -> { ... V.get() ... call methods ... });

// In a method called directly or indirectly from the lambda expression
... V.get() ...

作用域值允许在大型程序中的组件之间安全有效地共享数据,而无需求助于方法参数。


JEP 491: 虚拟线程的同步而不固定平台线程


优化了虚拟线程与 synchronized 的工作机制。 虚拟线程在 synchronized 方法和代码块中阻塞时,通常能够释放其占用的操作系统线程(平台线程),避免了对平台线程的长时间占用,从而提升应用程序的并发能力。 这种机制避免了“固定 (Pinning)”——即虚拟线程长时间占用平台线程,阻止其服务于其他虚拟线程的情况。


现有的使用 synchronized 的 Java 代码无需修改即可受益于虚拟线程的扩展能力。 例如,一个 I/O 密集型的应用程序,如果使用传统的平台线程,可能会因为线程阻塞而导致并发能力下降。 而使用虚拟线程,即使在 synchronized 块中发生阻塞,也不会固定平台线程,从而允许平台线程继续服务于其他虚拟线程,提高整体的并发性能。


JEP 493:在没有 JMOD 文件的情况下链接运行时镜像


默认情况下,JDK 同时包含运行时镜像(运行时所需的模块)和 JMOD 文件。这个特性使得 jlink 工具无需使用 JDK 的 JMOD 文件就可以创建自定义运行时镜像,减少了 JDK 的安装体积(约 25%)。


说明:



  • Jlink 是随 Java 9 一起发布的新命令行工具。它允许开发人员为基于模块的 Java 应用程序创建自己的轻量级、定制的 JRE。

  • JMOD 文件是 Java 模块的描述文件,包含了模块的元数据和资源。


JEP 495: 简化的源文件和实例主方法(第四次预览)


这个特性主要简化了 main 方法的的声明。对于 Java 初学者来说,这个 main 方法的声明引入了太多的 Java 语法概念,不利于初学者快速上手。


没有使用该特性之前定义一个 main 方法:


public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}

使用该新特性之后定义一个 main 方法:


class HelloWorld {
void main() {
System.out.println("Hello, World!");
}
}

进一步简化(未命名的类允许我们省略类名)


void main() {
System.out.println("Hello, World!");
}

JEP 497: 量子抗性数字签名算法 (ML-DSA)


JDK 24 引入了支持实施抗量子的基于模块晶格的数字签名算法 (Module-Lattice-Based Digital Signature Algorithm, ML-DSA),为抵御未来量子计算机可能带来的威胁做准备。


ML-DSA 是美国国家标准与技术研究院(NIST)在 FIPS 204 中标准化的量子抗性算法,用于数字签名和身份验证。


JEP 498: 使用 sun.misc.Unsafe 内存访问方法时发出警告


JDK 23(JEP 471) 提议弃用 sun.misc.Unsafe 中的内存访问方法,这些方法将来的版本中会被移除。在 JDK 24 中,当首次调用 sun.misc.Unsafe 的任何内存访问方法时,运行时会发出警告。


这些不安全的方法已有安全高效的替代方案:



  • java.lang.invoke.VarHandle :JDK 9 (JEP 193) 中引入,提供了一种安全有效地操作堆内存的方法,包括对象的字段、类的静态字段以及数组元素。

  • java.lang.foreign.MemorySegment :JDK 22 (JEP 454) 中引入,提供了一种安全有效地访问堆外内存的方法,有时会与 VarHandle 协同工作。


这两个类是 Foreign Function & Memory API(外部函数和内存 API) 的核心组件,分别用于管理和操作堆外内存。Foreign Function & Memory API 在 JDK 22 中正式转正,成为标准特性。


import jdk.incubator.foreign.*;
import java.lang.invoke.VarHandle;

// 管理堆外整数数组的类
class OffHeapIntBuffer {

// 用于访问整数元素的VarHandle
private static final VarHandle ELEM_VH = ValueLayout.JAVA_INT.arrayElementVarHandle();

// 内存管理器
private final Arena arena;

// 堆外内存段
private final MemorySegment buffer;

// 构造函数,分配指定数量的整数空间
public OffHeapIntBuffer(long size) {
this.arena = Arena.ofShared();
this.buffer = arena.allocate(ValueLayout.JAVA_INT, size);
}

// 释放内存
public void deallocate() {
arena.close();
}

// 以volatile方式设置指定索引的值
public void setVolatile(long index, int value) {
ELEM_VH.setVolatile(buffer, 0L, index, value);
}

// 初始化指定范围的元素为0
public void initialize(long start, long n) {
buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start,
ValueLayout.JAVA_INT.byteSize() * n)
.fill((byte) 0);
}

// 将指定范围的元素复制到新数组
public int[] copyToNewArray(long start, int n) {
return buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start,
ValueLayout.JAVA_INT.byteSize() * n)
.toArray(ValueLayout.JAVA_INT);
}
}

JEP 499: 结构化并发(第四次预览)


JDK 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent,目前处于孵化器阶段。


结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。


结构化并发的基本 API 是StructuredTaskScope,它支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。


StructuredTaskScope 的基本用法如下:


    try (var scope = new StructuredTaskScope<Object>()) {
// 使用fork方法派生线程来执行子任务
Future<Integer> future1 = scope.fork(task1);
Future<String> future2 = scope.fork(task2);
// 等待线程完成
scope.join();
// 结果的处理可能包括处理或重新抛出异常
... process results/exceptions ...
} // close

结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。


Java 新特性系列解读


如果你想系统了解 Java 8 以及之后版本的新特性,可以在 JavaGuide 上阅读对应的文章:



比较推荐这几篇:



作者:JavaGuide
来源:juejin.cn/post/7483478667143626762
收起阅读 »

年少不知自增好,错把UUID当个宝!!!

在 MySQL 中,使用 UUID 作为主键 在大表中可能会导致性能问题,尤其是在插入和修改数据时效率较低。以下是详细的原因分析,以及为什么修改数据会导致索引刷新,以及字符主键为什么效率较低。 1. UUID 作为主键的问题 (1)UUID 的特性 UUI...
继续阅读 »

在 MySQL 中,使用 UUID 作为主键 在大表中可能会导致性能问题,尤其是在插入和修改数据时效率较低。以下是详细的原因分析,以及为什么修改数据会导致索引刷新,以及字符主键为什么效率较低。




1. UUID 作为主键的问题


(1)UUID 的特性



  • UUID 是一个 128 位的字符串,通常表示为 36 个字符(例如:550e8400-e29b-41d4-a716-446655440000)。

  • UUID 是全局唯一的,适合分布式系统中生成唯一标识。


(2)UUID 作为主键的缺点


1. 索引效率低


  • 索引大小:UUID 是字符串类型,占用空间较大(36 字节),而整型主键(如 BIGINT)仅占用 8 字节。索引越大,存储和查询的效率越低。

  • 索引分裂:UUID 是无序的,插入新数据时,可能会导致索引树频繁分裂和重新平衡,影响性能。


2. 插入性能差


  • 随机性:UUID 是无序的,每次插入新数据时,新记录可能会插入到索引树的任意位置,导致索引树频繁调整。

  • 页分裂:InnoDB 存储引擎使用 B+ 树作为索引结构,随机插入会导致页分裂,增加磁盘 I/O 操作。


3. 查询性能差


  • 比较效率低:字符串比较比整型比较慢,尤其是在大表中,查询性能会显著下降。

  • 索引扫描范围大:UUID 索引占用的空间大,导致索引扫描的范围更大,查询效率降低。




2. 修改数据导致索引刷新的原因


(1)索引的作用



  • 索引是为了加速查询而创建的数据结构(如 B+ 树)。

  • 当数据被修改时,索引也需要同步更新,以保持数据的一致性。


(2)修改数据对索引的影响



  • 更新主键



    • 如果修改了主键值,MySQL 需要删除旧的主键索引记录,并插入新的主键索引记录。

    • 这个过程会导致索引树的调整,增加磁盘 I/O 操作。



  • 更新非主键列



    • 如果修改的列是索引列(如唯一索引、普通索引),MySQL 需要更新对应的索引记录。

    • 这个过程也会导致索引树的调整。




(3)UUID 主键的额外开销



  • 由于 UUID 是无序的,修改主键值时,新值可能会插入到索引树的不同位置,导致索引树频繁调整。

  • 相比于有序的主键(如自增 ID),UUID 主键的修改操作代价更高。




3. 字符主键导致效率降低的原因


(1)存储空间大



  • 字符主键(如 UUID)占用的存储空间比整型主键大。

  • 索引的大小直接影响查询性能,索引越大,查询时需要的磁盘 I/O 操作越多。


(2)比较效率低



  • 字符串比较比整型比较慢,尤其是在大表中,查询性能会显著下降。

  • 例如,WHERE id = '550e8400-e29b-41d4-a716-446655440000' 的效率低于 WHERE id = 12345


(3)索引分裂



  • 字符主键通常是无序的,插入新数据时,可能会导致索引树频繁分裂和重新平衡,影响性能。




4. 如何优化 UUID 主键的性能


(1)使用有序 UUID



  • 使用有序 UUID(如 UUIDv7),减少索引分裂和页分裂。

  • 有序 UUID 的生成方式可以基于时间戳,保证插入顺序。


(2)将 UUID 存储为二进制



  • 将 UUID 存储为 BINARY(16) 而不是 CHAR(36),减少存储空间。


    CREATE TABLE users (
    id BINARY(16) PRIMARY KEY,
    name VARCHAR(255)
    );



(3)使用自增主键 + UUID



  • 使用自增主键作为物理主键,UUID 作为逻辑主键。


    CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    uuid CHAR(36) UNIQUE,
    name VARCHAR(255)
    );



(4)分区表



  • 对大表进行分区,减少单个索引树的大小,提高查询性能。




~Summary



  • UUID 作为主键的缺点



    • 索引效率低,插入和查询性能差。

    • 修改数据时,索引需要频繁刷新,导致性能下降。



  • 字符主键效率低的原因



    • 存储空间大,比较效率低,索引分裂频繁。



  • 优化建议



    • 使用有序 UUID 或二进制存储。

    • 结合自增主键和 UUID。

    • 对大表进行分区。




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

如何限制一个账号只能在一处登录

如何限制一个账号只能在一处登录要实现账号单点登录(一处登录限制),需结合 会话管理、实时状态同步 和 冲突处理机制。以下是完整技术方案:一、核心实现方案服务端会话控制(推荐)// 用户登录时生成唯一令牌并记录 public Str...
继续阅读 »

如何限制一个账号只能在一处登录

要实现账号单点登录(一处登录限制),需结合 会话管理实时状态同步 和 冲突处理机制。以下是完整技术方案:

一、核心实现方案

  1. 服务端会话控制(推荐)
// 用户登录时生成唯一令牌并记录
public String login(String username, String password) {
// 1. 验证账号密码
User user = userService.authenticate(username, password);

// 2. 生成新令牌并失效旧会话
String newToken = UUID.randomUUID().toString();
redis.del("user:" + user.getId() + ":token"); // 清除旧token
redis.setex("user:" + user.getId() + ":token", 3600, newToken);

// 3. 返回新令牌
return newToken;
}
  1. WebSocket实时踢出(增强体验)
// 前端建立长连接
const socket = new WebSocket(`wss://api.example.com/ws?token=${token}`);

socket.onmessage = (event) => {
if (event.data === 'force_logout') {
alert('您的账号在其他设备登录');
location.href = '/logout';
}
};
  1. 登录设备指纹识别
// 生成设备指纹(前端)
function generateDeviceFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = "14px Arial";
ctx.fillText("BrowserFingerprint", 2, 2);
return canvas.toDataURL().hashCode(); // 简化示例
}

// 服务端校验
if (storedFingerprint != currentFingerprint) {
forceLogout(storedToken);
}

二、多端适配策略

客户端类型实现方案
Web浏览器JWT令牌 + Redis黑名单
移动端APP设备ID绑定 + FCM/iMessage推送踢出
桌面应用硬件指纹 + 本地令牌失效检测
微信小程序UnionID绑定 + 服务端订阅消息

三、关键代码实现

  1. JWT令牌增强方案
// 生成带设备信息的JWT
public String generateToken(User user, String deviceId) {
return Jwts.builder()
.setSubject(user.getId())
.claim("device", deviceId) // 绑定设备
.setExpiration(new Date(System.currentTimeMillis() + 3600000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}

// 校验令牌时检查设备
public boolean validateToken(String token, String currentDevice) {
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
return claims.get("device").equals(currentDevice);
}
  1. Redis实时状态管理
# 使用Redis Hash存储登录状态
def login(user_id, token, device_info):
# 删除该用户所有活跃会话
r.delete(f"user_sessions:{user_id}")

# 记录新会话
r.hset(f"user_sessions:{user_id}",
mapping={
"token": token,
"device": device_info,
"last_active": datetime.now()
})
r.expire(f"user_sessions:{user_id}", 3600)

# 中间件校验
def check_token(request):
user_id = get_user_id_from_token(request.token)
stored_token = r.hget(f"user_sessions:{user_id}", "token")
if stored_token != request.token:
raise ForceLogoutError()

四、异常处理机制

场景处理方案
网络延迟冲突采用CAS(Compare-And-Swap)原子操作更新令牌
令牌被盗用触发二次验证(短信/邮箱验证码)
多设备同时登录后登录者优先,前会话立即失效(可配置为保留第一个登录)

五、性能与安全优化

  1. 会话同步优化

    # Redis Pub/Sub 跨节点同步
    PUBLISH user:123 "LOGOUT"
  2. 安全增强

    // 前端敏感操作二次确认
    function sensitiveOperation() {
    if (loginTime < lastServerCheckTime) {
    showReauthModal();
    }
    }
  3. 监控看板

    指标报警阈值
    并发登录冲突率>5%/分钟
    强制踢出成功率<99%

六、行业实践参考

  1. 金融级方案

    • 每次操作都验证设备指纹
    • 异地登录需视频人工审核
  2. 社交应用方案

    • 允许最多3个设备在线
    • 分设备类型控制(手机+PC+平板)
  3. ERP系统方案

    • 绑定特定MAC地址
    • VPN网络白名单限制

通过以上方案可实现:

  • 严格模式:后登录者踢出前会话(适合银行系统)
  • 宽松模式:多设备在线但通知告警(适合社交应用)
  • 混合模式:关键操作时强制单设备(适合电商系统)

部署建议:

  1. 根据业务需求选择合适严格度
  2. 关键系统增加异地登录二次验证
  3. 用户界面明确显示登录设备列表

作者:Epicurus
来源:juejin.cn/post/7485384798569250868

收起阅读 »

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
收起阅读 »