首页 文章 精选 留言 我的

精选列表

搜索[SpringBoot],共4561篇文章
优秀的个人博客,低调大师

SpringBoot开发案例之整合Spring-data-jpa进阶篇

前言 有人说 从 jdbc->jdbctemplate->hibernation/mybatis 再到 jpa,真当开发人员的学习时间不要钱?我觉得到 h/m 这一级的封装已经有点过了,再往深处走就有病了。 还有人说JPA 很反人类(一个面试官),还举了一个很简单举了例子说:一个数据库如果有 50 个字段,那你写各种条件查询不是要写很多?就是应该用类似 SQL 的方式来查询啊? 其实在我看来,存在即合理,人们总是向着好的方向去发展,学习什么不需要成本,底层语言牛逼倒是去学啊,不还是看不懂,弄不明白。很多知识对于程序员来说,都是一通百通,查询文档就是了,最主要的是能方便以后的开发即可。 对于反人类这一说,只能说 to young to simple,JPA的初衷肯定也不会是让你写一个几十个字段的查询,顶多一到两个而已,非要这么极端?再说JPA也是提供了EntityManager来实现SQL或者HQL语句查询的不是,JPA本质上还是集成了Hibernate的很多优点的。 进阶查询 需求: 学生表(app_student)、班级表(app_class)、当然表结构比较简单,比如这时候我们需要查询学生列表,但是需要同时查询班级表的一些数据,并以JSON或者实体的方式返回给调用者。 本次需求,主要实现JPA的以下几个特性: 封装EntityManager基类 多表查询返回一个List 多表查询返回一个Map 多表查询返回一个实体 Entitymanager的核心概念图: 实现 班级表: @Entity @Table(name = "app_class") public class AppClass { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "id", nullable = false) private Integer id; private String className; private String teacherName; //忽略部分代码 } 学生表: @Entity @Table(name = "app_student") public class AppStudent { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "id", nullable = false) private Integer id; private Integer classId; private String name; private Integer age; //忽略部分代码 } 封装接口 DynamicQuery: /** * 扩展SpringDataJpa, 支持动态jpql/nativesql查询并支持分页查询 * 使用方法:注入ServiceImpl * 创建者 张志朋 * 创建时间 2018年3月8日 */ public interface DynamicQuery { public void save(Object entity); public void update(Object entity); public <T> void delete(Class<T> entityClass, Object entityid); public <T> void delete(Class<T> entityClass, Object[] entityids); /** * 查询对象列表,返回List * @param resultClass * @param nativeSql * @param params * @return List<T> * @Date 2018年3月15日 * 更新日志 * 2018年3月15日 张志朋 首次创建 * */ <T> List<T> nativeQueryList(String nativeSql, Object... params); /** * 查询对象列表,返回List<Map<key,value>> * @param nativeSql * @param params * @return List<T> * @Date 2018年3月15日 * 更新日志 * 2018年3月15日 张志朋 首次创建 * */ <T> List<T> nativeQueryListMap(String nativeSql,Object... params); /** * 查询对象列表,返回List<组合对象> * @param resultClass * @param nativeSql * @param params * @return List<T> * @Date 2018年3月15日 * 更新日志 * 2018年3月15日 张志朋 首次创建 * */ <T> List<T> nativeQueryListModel(Class<T> resultClass, String nativeSql, Object... params); } 封装实现 DynamicQueryImpl: /** * 动态jpql/nativesql查询的实现类 * 创建者 张志朋 * 创建时间 2018年3月8日 */ @Repository public class DynamicQueryImpl implements DynamicQuery { Logger logger = LoggerFactory.getLogger(DynamicQueryImpl.class); @PersistenceContext private EntityManager em; public EntityManager getEntityManager() { return em; } @Override public void save(Object entity) { em.persist(entity); } @Override public void update(Object entity) { em.merge(entity); } @Override public <T> void delete(Class<T> entityClass, Object entityid) { delete(entityClass, new Object[] { entityid }); } @Override public <T> void delete(Class<T> entityClass, Object[] entityids) { for (Object id : entityids) { em.remove(em.getReference(entityClass, id)); } } private Query createNativeQuery(String sql, Object... params) { Query q = em.createNativeQuery(sql); if (params != null && params.length > 0) { for (int i = 0; i < params.length; i++) { q.setParameter(i + 1, params[i]); // 与Hiberante不同,jpa // query从位置1开始 } } return q; } @SuppressWarnings("unchecked") @Override public <T> List<T> nativeQueryList(String nativeSql, Object... params) { Query q = createNativeQuery(nativeSql, params); q.unwrap(SQLQuery.class).setResultTransformer(Transformers.TO_LIST); return q.getResultList(); } @SuppressWarnings("unchecked") @Override public <T> List<T> nativeQueryListModel(Class<T> resultClass, String nativeSql, Object... params) { Query q = createNativeQuery(nativeSql, params);; q.unwrap(SQLQuery.class).setResultTransformer(Transformers.aliasToBean(resultClass)); return q.getResultList(); } @SuppressWarnings("unchecked") @Override public <T> List<T> nativeQueryListMap(String nativeSql, Object... params) { Query q = createNativeQuery(nativeSql, params); q.unwrap(SQLQuery.class).setResultTransformer(Transformers.ALIAS_TO_ENTITY_MAP); return q.getResultList(); } } 业务 IStudentService: public interface IStudentService { /** * 返回List<Object[]> * @Author 科帮网 * @return List<Object[]> * @Date 2018年3月28日 * 更新日志 * 2018年3月28日 科帮网 首次创建 * */ List<Object[]> listStudent(); /** * 返回List<Student> * @Author 科帮网 * @return List<Student> * @Date 2018年3月28日 * 更新日志 * 2018年3月28日 科帮网 首次创建 * */ List<Student> listStudentModel(); /** * List<Map<Object, Object>> * @Author 科帮网 * @return List<Map<Object,Object>> * @Date 2018年3月28日 * 更新日志 * 2018年3月28日 科帮网 首次创建 * */ List<Map<Object, Object>> listStudentMap(); } 业务实现 StudentServiceImpl: @Service public class StudentServiceImpl implements IStudentService { @Autowired private DynamicQuery dynamicQuery; @Override public List<Object[]> listStudent() { String nativeSql = "SELECT s.id AS studentId,c.id AS classId,c.class_name AS className,c.teacher_name AS teacherName,s.name,s.age FROM app_student s,app_class c"; List<Object[]> list = dynamicQuery.nativeQueryList(nativeSql, new Object[]{}); return list; } @Override public List<Student> listStudentModel() { String nativeSql = "SELECT s.id AS studentId,c.id AS classId,c.class_name AS className,c.teacher_name AS teacherName,s.name,s.age FROM app_student s,app_class c"; List<Student> list = dynamicQuery.nativeQueryListModel(Student.class, nativeSql, new Object[]{}); return list; } @Override public List<Map<Object,Object>> listStudentMap() { String nativeSql = "SELECT s.id AS studentId,c.id AS classId,c.class_name AS className,c.teacher_name AS teacherName,s.name,s.age FROM app_student s,app_class c"; List<Map<Object,Object>> list = dynamicQuery.nativeQueryListMap(nativeSql, new Object[]{}); return list; } } 接口测试: @Api(tags ="测试接口") @RestController @RequestMapping("/test") public class StudentController { private final static Logger LOGGER = LoggerFactory.getLogger(StudentController.class); @Autowired private IStudentService studentService; @ApiOperation(value="学生List") @PostMapping("/list") public Result list(HttpServletRequest request){ LOGGER.info("学生List"); List<Object[]> list = studentService.listStudent(); return Result.ok(list); } @ApiOperation(value="学生Map") @PostMapping("/listMap") public Result listMap(HttpServletRequest request){ LOGGER.info("学生Map"); List<Map<Object, Object>> list = studentService.listStudentMap(); return Result.ok(list); } @ApiOperation(value="学生Model") @PostMapping("/listModel") public Result listModel(HttpServletRequest request){ LOGGER.info("学生Model"); List<Student> list = studentService.listStudentModel(); return Result.ok(list); } } Swagger2测试 返回List< Object[] >: 返回List< Map< Object, Object > >: 返回List< Student >: 源码:https://gitee.com/52itstyle/spring-data-jpa 作者: 小柒 出处: https://blog.52itstyle.com 分享是快乐的,也见证了个人成长历程,文章大多都是工作经验总结以及平时学习积累,基于自身认知不足之处在所难免,也请大家指正,共同进步。

优秀的个人博客,低调大师

SpringBoot_异常_01_Caused by: java.lang.BootstrapMethodError: java.lang....

一、异常信息 org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'gsonBuilder' defined in class path resource [org/springframework/boot/autoconfigure/gson/GsonAutoConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.google.gson.GsonBuilder]: Factory method 'gsonBuilder' threw exception; nested exception is java.lang.BootstrapMethodError: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:587) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1250) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1099) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:545) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:502) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:312) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:228) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:310) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:200) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:760) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:868) ~[spring-context-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:549) ~[spring-context-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:140) ~[spring-boot-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) [spring-boot-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:388) [spring-boot-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.SpringApplication.run(SpringApplication.java:327) [spring-boot-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.SpringApplication.run(SpringApplication.java:1246) [spring-boot-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.SpringApplication.run(SpringApplication.java:1234) [spring-boot-2.0.0.RELEASE.jar:2.0.0.RELEASE] at com.ray.alipaywap.AlipayWapApplication.main(AlipayWapApplication.java:10) [classes/:na] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_144] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_144] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_144] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_144] at org.springframework.boot.maven.AbstractRunMojo$LaunchRunner.run(AbstractRunMojo.java:496) [spring-boot-maven-plugin-2.0.0.RELEASE.jar:2.0.0.RELEASE] at java.lang.Thread.run(Thread.java:748) [na:1.8.0_144] Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.google.gson.GsonBuilder]: Factory method 'gsonBuilder' threw exception; nested exception is java.lang.BootstrapMethodError: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:185) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:579) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] ... 24 common frames omitted Caused by: java.lang.BootstrapMethodError: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$StandardGsonBuilderCustomizer.customize(GsonAutoConfiguration.java:96) ~[spring-boot-autoconfigure-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration.lambda$gsonBuilder$0(GsonAutoConfiguration.java:49) ~[spring-boot-autoconfigure-2.0.0.RELEASE.jar:2.0.0.RELEASE] at java.util.ArrayList.forEach(ArrayList.java:1249) ~[na:1.8.0_144] at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration.gsonBuilder(GsonAutoConfiguration.java:49) ~[spring-boot-autoconfigure-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$$EnhancerBySpringCGLIB$$b91c5ca8.CGLIB$gsonBuilder$1(<generated>) ~[spring-boot-autoconfigure-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$$EnhancerBySpringCGLIB$$b91c5ca8$$FastClassBySpringCGLIB$$35feb3c2.invoke(<generated>) ~[spring-boot-autoconfigure-2.0.0.RELEASE.jar:2.0.0.RELEASE] at org.springframework.cglib.proxy.MethodProxy.invokeSuper(MethodProxy.java:228) ~[spring-core-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.context.annotation.ConfigurationClassEnhancer$BeanMethodInterceptor.intercept(ConfigurationClassEnhancer.java:361) ~[spring-context-5.0.4.RELEASE.jar:5.0.4.RELEASE] at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$$EnhancerBySpringCGLIB$$b91c5ca8.gsonBuilder(<generated>) ~[spring-boot-autoconfigure-2.0.0.RELEASE.jar:2.0.0.RELEASE] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_144] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_144] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_144] at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_144] at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:154) ~[spring-beans-5.0.4.RELEASE.jar:5.0.4.RELEASE] ... 25 common frames omitted Caused by: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at java.lang.invoke.MethodHandleNatives.resolve(Native Method) ~[na:1.8.0_144] at java.lang.invoke.MemberName$Factory.resolve(MemberName.java:975) ~[na:1.8.0_144] at java.lang.invoke.MemberName$Factory.resolveOrFail(MemberName.java:1000) ~[na:1.8.0_144] at java.lang.invoke.MethodHandles$Lookup.resolveOrFail(MethodHandles.java:1394) ~[na:1.8.0_144] at java.lang.invoke.MethodHandles$Lookup.linkMethodHandleConstant(MethodHandles.java:1750) ~[na:1.8.0_144] at java.lang.invoke.MethodHandleNatives.linkMethodHandleConstant(MethodHandleNatives.java:477) ~[na:1.8.0_144] ... 39 common frames omitted [WARNING] java.lang.reflect.InvocationTargetException at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.springframework.boot.maven.AbstractRunMojo$LaunchRunner.run(AbstractRunMojo.java:496) at java.lang.Thread.run(Thread.java:748) Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'gsonBuilder' defined in class path resource [org/springframework/boot/autoconfigure/gson/GsonAutoConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.google.gson.GsonBuilder]: Factory method 'gsonBuilder' threw exception; nested exception is java.lang.BootstrapMethodError: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:587) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1250) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1099) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:545) at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:502) at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:312) at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:228) at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:310) at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:200) at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:760) at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:868) at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:549) at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:140) at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:388) at org.springframework.boot.SpringApplication.run(SpringApplication.java:327) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1246) at org.springframework.boot.SpringApplication.run(SpringApplication.java:1234) at com.ray.alipaywap.AlipayWapApplication.main(AlipayWapApplication.java:10) ... 6 more Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.google.gson.GsonBuilder]: Factory method 'gsonBuilder' threw exception; nested exception is java.lang.BootstrapMethodError: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:185) at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:579) ... 24 more Caused by: java.lang.BootstrapMethodError: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$StandardGsonBuilderCustomizer.customize(GsonAutoConfiguration.java:96) at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration.lambda$gsonBuilder$0(GsonAutoConfiguration.java:49) at java.util.ArrayList.forEach(ArrayList.java:1249) at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration.gsonBuilder(GsonAutoConfiguration.java:49) at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$$EnhancerBySpringCGLIB$$b91c5ca8.CGLIB$gsonBuilder$1(<generated>) at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$$EnhancerBySpringCGLIB$$b91c5ca8$$FastClassBySpringCGLIB$$35feb3c2.invoke(<generated>) at org.springframework.cglib.proxy.MethodProxy.invokeSuper(MethodProxy.java:228) at org.springframework.context.annotation.ConfigurationClassEnhancer$BeanMethodInterceptor.intercept(ConfigurationClassEnhancer.java:361) at org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration$$EnhancerBySpringCGLIB$$b91c5ca8.gsonBuilder(<generated>) at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:154) ... 25 more Caused by: java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient()Lcom/google/gson/GsonBuilder; at java.lang.invoke.MethodHandleNatives.resolve(Native Method) at java.lang.invoke.MemberName$Factory.resolve(MemberName.java:975) at java.lang.invoke.MemberName$Factory.resolveOrFail(MemberName.java:1000) at java.lang.invoke.MethodHandles$Lookup.resolveOrFail(MethodHandles.java:1394) at java.lang.invoke.MethodHandles$Lookup.linkMethodHandleConstant(MethodHandles.java:1750) at java.lang.invoke.MethodHandleNatives.linkMethodHandleConstant(MethodHandleNatives.java:477) ... 39 more View Code 二、异常原因 这种异常通常是因为jar包版本冲突而引起的 三、异常解决 删掉了自己引入的gson就好了 四、参考资料 1.Android apk运行时错误java.lang.NoSuchMethodError: com.google.gson.GsonBuilder.setLenient

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第9招 安全默认值要知道

# Spring Boot 默认安全边界:Petclinic 如何从裸奔到可上线 ```text 功能正常 → 默认接口与管理端点开放 → 数据可被匿名篡改 → 无法上线 ``` Petclinic 的页面、表单、数据库读写都已经跑通,开发环境里点几下就能完成宠物和兽医管理。 我是老李,团队正准备把 Petclinic 推到预发环境,却被安全评审拦下:应用没有登录、没有角色、没有授权,任何人只要知道 URL 就能改数据。 为什么一个功能完全正常的应用,依然可能因为默认开放的接口和管理端点无法上线? 是继续沿用 Spring Boot 的默认安全配置,还是显式接管认证与授权? 如果选错,会不会出现“看似有登录、实际接口仍裸奔”的假安全? # 01、故事 ```text 周四 23:40 → 预发评审会议室 → 上线 Petclinic → 安全评审要求补登录与权限 ``` 周四晚上十一点四十,预发评审还在继续。 任务是让 Petclinic 通过上线前安全评审。 安全同事现场打开浏览器,直接访问 `/owners?lastName=`,页面正常返回;再访问 `/actuator/health`,也正常返回。 他追问:现在谁能改宠物信息?谁能看数据库连接信息? 我回答:目前没有登录,所有接口都开放。 评审结论只有一句:功能正常,但安全边界缺失,不能上线。 > 夜深评审灯未熄, > 页面虽通心却虚。 > 接口裸奔无人守, > 上线之门被锁闭。 # 02、问题 ```text 需求变化:本地演示 → 生产上线 → 默认无认证 → 数据与管理端点暴露 ``` Petclinic 原本是教学演示项目,默认不引入 Spring Security。 引入 Security 后,Spring Boot 会自动提供一个默认安全边界:所有请求需要登录,生成一个随机密码,并提供一个表单登录页。 但默认边界只解决“有没有登录”,不解决“谁能做什么”。 普通用户登录后仍可能访问 Actuator 管理端点,也可能修改核心数据。 可验证的验收标准是:匿名用户访问业务页面被重定向到登录页;普通用户只能查询;管理员才能修改核心数据;普通用户访问 Actuator 端点返回 403。 > 功能虽全无门禁, > 默认开放藏风险。 > 登录只是第一道, > 授权才是真护栏。 # 03、原理 ```text Security 进入 classpath → Boot 自动配置 → 默认认证与授权 → 覆盖需显式声明 ``` Spring Boot 检测到 `spring-boot-starter-security` 后,会激活 `SecurityAutoConfiguration`。 默认行为包括:启用 HTTP Basic 与表单登录;创建一个内存用户 `user`;密码为启动时随机生成的 UUID;所有请求都需要认证;Actuator 端点同样被保护。 反直觉的是:默认安全配置并不会区分角色,也不会保护具体 URL 的写操作。 它只保证“未登录不能访问”,不保证“登录后不能越权”。 要覆盖默认行为,必须显式声明 `SecurityFilterChain` Bean,否则 Boot 的默认配置继续生效。 > 自动配置给底线, > 随机密码护门前。 > 若要角色分权限, > 显式声明才安全。 # 04、架构 ```text HTTP 请求 → SecurityFilterChain → 认证/授权 → Controller → Service → Repository ``` 安全层位于 Controller 之前,由一组过滤器组成。 核心过滤器包括:`SecurityContextPersistenceFilter`、`UsernamePasswordAuthenticationFilter`、`AuthorizationFilt er`、`CsrfFilter`。 认证负责确认“你是谁”,授权负责确认“你能做什么”。 本模块的边界是:只处理 HTTP 入口的安全决策,不侵入业务 Service。 收益是安全规则集中管理,代价是每个请求多经过若干过滤器。 适用条件是:所有需要登录和权限控制的 Web 应用。 > 请求先过安全链, > 认证授权分两边。 > 业务逻辑不侵入, > 边界清晰好扩展。 # 05、第一版实战 ```text JDK 17 + Spring Boot 3.2 + Petclinic → 引入 Security → 默认登录 → 验证 → V1 ``` 在 `pom.xml` 中加入依赖: ```xml org.springframework.boot spring-boot-starter-security ``` 启动应用: ```bash ./mvnw spring-boot:run ``` 启动日志中会出现类似内容: ```text Using generated security password: 3f2a1b4c-5d6e-4f8a-9b0c-1d2e3f4a5b6c ``` 访问 `http://localhost:8080/owners?lastName=`,会被重定向到 `/login`。 输入用户名 `user` 和日志中的随机密码,可以进入页面。 访问 `http://localhost:8080/actuator/health`,同样需要登录。 V1 成功标准:匿名访问被拦截,登录后可访问业务页面。 未在当前环境实测,以下为预期结果。 > 引入依赖一瞬间, > 默认登录现眼前。 > 随机密码藏日志, > 匿名访问被阻拦。 # 06、排查 ```text 现象:登录后仍可改数据 → 怀疑授权缺失 → 检查 SecurityFilterChain → 确认默认配置 → 修复 ``` 问题一:普通用户登录后仍能访问新增、修改、删除接口。 怀疑是默认配置没有角色区分。 检查启动日志,确认没有自定义 `SecurityFilterChain`。 证据:访问 `/owners/new` 返回 200,而不是 403。 根因:Boot 默认配置只要求认证,不要求授权。 问题二:普通用户能访问 Actuator 端点。 怀疑是 Actuator 安全规则未单独配置。 检查请求:登录普通用户后访问 `/actuator/env`,返回 200。 根因:默认安全配置对所有请求一视同仁,没有对管理端点单独授权。 **错误尝试:** 只修改 `application.properties` 中的 `spring.security.user.roles`,以为能限制 Actuator,结果普通用户仍可访问。 > 登录只是第一关, > 授权缺失仍可钻。 > 默认配置不区分, > 越权访问在眼前。 # 07、优化 ```text V1 默认安全 → 证据:越权访问 → V2 显式 SecurityFilterChain + 角色授权 ``` 新增 `SecurityConfig` 类,显式声明安全规则: ```java @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/login", "/webjars/**", "/resources/**").permitAll() .requestMatchers("/owners/new", "/owners/*/edit", "/pets/new", "/visits/new").hasRole("ADMIN") .requestMatchers("/actuator/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/", true) ) .logout(logout -> logout.logoutSuccessUrl("/")) .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")); return http.build(); } @Bean public UserDetailsService userDetailsService() { UserDetails admin = User.withUsername("admin") .password(passwordEncoder().encode("admin123")) .roles("ADMIN") .build(); UserDetails user = User.withUsername("user") .password(passwordEncoder().encode("user123")) .roles("USER") .build(); return new InMemoryUserDetailsManager(admin, user); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } ``` 同时新增自定义登录页 `login.html`,表单提交到 `/login`,并携带 CSRF token。 > 显式声明安全链, > 角色授权分两边。 > 密码编码用 BCrypt, > 管理端点只许管。 # 08、演进 ```text V2 角色授权 → 能力:最小权限 → 遗留:内存用户与静态规则 ``` 用 V1 同一组请求验证: 匿名访问 `/owners?lastName=`,重定向到 `/login`。 用 `user` 登录后访问 `/owners/new`,返回 403。 用 `admin` 登录后访问 `/owners/new`,返回 200。 用 `user` 登录后访问 `/actuator/health`,返回 403。 用 `admin` 登录后访问 `/actuator/health`,返回 200。 收益是核心写操作和管理端点只对管理员开放。 代价是用户和角色目前硬编码在内存中,重启后丢失。 遗留问题是:CSRF 对 API 路径直接关闭,后续需要更细粒度策略。 > 普通用户只能看, > 管理端点只许管。 > 内存用户待持久, > 静态规则待扩展。 # 09、洞见 ```text 从“默认安全”到“显式安全” ``` ## 默认安全不是完整安全 ```text 默认配置 → 只认证 → 不授权 → 假安全 ``` 反直觉判断:引入 Spring Security 后应用并不会自动变安全。 默认配置只保证未登录不能访问,不保证登录后不能越权。 真正的安全边界必须显式声明角色和 URL 授权规则。 ## 管理端点必须单独授权 ```text Actuator 暴露 → 默认同权 → 普通用户可看 → 信息泄露 ``` 反直觉判断:Actuator 端点不会因为“看起来是管理功能”就自动只对管理员开放。 必须显式配置 `/actuator/**` 的访问规则,否则普通用户登录后同样可以查看环境变量、配置信息。 ## 密码编码器必须显式声明 ```text 不声明 PasswordEncoder → 启动报错 → 无法登录 ``` Spring Security 5 之后要求必须显式声明 `PasswordEncoder`。 如果使用 `{noop}` 前缀可以绕过,但生产环境必须使用 BCrypt 等强哈希。 ## CSRF 不能一刀切关闭 ```text 关闭 CSRF → 写操作暴露 → 跨站攻击 ``` 对纯 API 可以关闭 CSRF,但对表单提交必须保留。 一刀切关闭会让所有写操作暴露在跨站请求伪造风险中。 > 默认只是起跑线, > 显式声明才安全。 > 管理端点要单独, > 密码编码不能免。 # 10、系统落地 ```text 浏览器 → SecurityFilterChain → Controller → Service → Repository → 数据库 ``` 原来 Petclinic 没有任何安全控制,所有页面和接口匿名可访问。 本篇增加了 Spring Security 依赖、显式安全过滤链、角色授权、密码编码、自定义登录页、Actuator 安全规则。 现在系统能区分匿名用户、普通用户和管理员,核心写操作和管理端点只对管理员开放。 下一篇还缺:用户与角色持久化到数据库、方法级安全、CSRF 细粒度策略、审计日志。 官方文档可参考 Spring Security 官方文档首页:https://docs.spring.io/spring-security/reference/index.html > 安全边界已落地, > 角色授权分清晰。 > 管理端点有护栏, > 上线评审可开启。 # 11、小结 ```text 三问三答:默认开放 → 显式接管 → 最小权限 ``` 第一问:为什么功能正常仍无法上线? 因为默认开放的接口和管理端点没有认证与授权,任何人都能改数据、看配置。 第二问:继续默认还是显式接管? 必须显式接管。默认配置只提供登录底线,不提供角色和 URL 授权。 第三问:选错会怎样? 会出现“有登录但无授权”的假安全,普通用户仍可越权修改数据或访问管理端点。 当前状态:Petclinic 已具备基于角色的最小权限安全边界,核心写操作和 Actuator 仅管理员可访问。 > 功能正常非终点, > 安全边界是底线。 > 显式接管才可靠, > 最小权限保上线。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:Spring Boot 默认安全配置到底做了什么? 默认配置启用表单登录和 HTTP Basic,生成随机密码,所有请求需要认证。 它不区分角色,不保护具体 URL 的写操作,也不单独限制 Actuator。 ## 实战题:如何让普通用户只能查询,管理员才能修改核心数据? 显式声明 `SecurityFilterChain`,对 `/owners/new`、`/owners/*/edit` 等写操作路径配置 `hasRole("ADMIN")`。 查询路径配置 `authenticated()` 或 `hasAnyRole("ADMIN","USER")`。 同时提供 `UserDetailsService` 和 `PasswordEncoder`。 ## 排障题:普通用户登录后仍能访问 `/actuator/env`,怎么排查? 先检查是否显式声明了 `SecurityFilterChain`。 如果没有,说明 Boot 默认配置生效,所有登录用户都能访问 Actuator。 如果有,检查 `/actuator/**` 的授权规则是否配置为 `hasRole("ADMIN")`。 ## 架构判断题:把安全规则写在 Controller 里用 if 判断,是否可行? 不可行。安全规则应集中在 `SecurityFilterChain` 中,在请求进入 Controller 之前完成认证与授权。 写在 Controller 里会导致规则分散、容易遗漏,且无法保护静态资源和 Actuator 端点。 > 理解默认知边界, > 实战显式写安全。 > 排障先查过滤链, > 架构集中才稳健。 # 13、升华 ```text 安全不是功能,是上线前提 ``` Petclinic 从“功能正常”到“可上线”,差的不是业务代码,而是安全边界。 Spring Boot 的默认安全配置是起点,不是终点。 只有显式声明认证、授权、密码编码、CSRF 和 Actuator 规则,才能把教学项目变成生产级应用。 下一篇将把内存用户替换为数据库持久化,并引入方法级安全,让权限控制更贴近真实业务。 > 功能之上有安全, > 默认只是起跑线。 > 显式声明筑护栏, > 生产上线才心安。 **系列项目:** Spring 官方 Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、数据层、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置、容器构建 **本篇:** 引入 Spring Security,显式接管登录、角色、URL 授权、密码编码、CSRF 与 Actuator 安全 **下一篇:** 用户与角色持久化到数据库,引入方法级安全与审计日志 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第14招 缓存不是一个注解

# 缓存不是 SET/GET:Petclinic 读多写少场景的缓存工程实战 ```text 旧方案:每次请求直查数据库,Vet 列表与 Owner 查询响应稳定但重复消耗连接与 CPU 冲突:读多写少场景下,数据库 20ms 查询看似不慢,但并发一上来连接池与延迟同时恶化 后果:多实例部署后本地缓存各存一份,数据不一致、失效不及时、故障无降级 ``` Petclinic 的 Vet 列表和 Owner 查询是典型读多写少场景。 Vet 数据很少变化,Owner 查询频繁但写入分散。 当前实现每次请求都直接访问数据库,单次查询 20ms 左右,看起来并不慢。 我是老李,今天要带团队把缓存真正工程化。不是简单加一个 @Cacheable 就完事,而是把一致性、失效、容量和故障降级一起考虑进去。 数据库查询明明只有 20ms,是否真的值得加 Redis? 本地缓存和 Redis 之间,应该先上哪一个,还是两个都要? 如果缓存 Key 设计错了、缓存没有失效、或者 Redis 挂了,选错方案会带来什么后果? # 01、故事 ```text 周二 14:30 → 生产环境 Petclinic 集群 → 上线 Vet 列表缓存 → 两台实例缓存不一致 ``` 周二下午两点半,生产环境两台 Petclinic 实例刚刚完成滚动发布。任务是给 Vet 列表加上缓存,降低数据库压力。运维同事在监控屏前突然喊了一声:两台实例返回的 Vet 列表不一样。 一台返回 6 个兽医,另一台返回 7 个。刷新几次,结果还在变。前端用户已经截图发到群里,说页面数据跳来跳去。 我打开日志,发现两台实例各自维护本地缓存,一台缓存了旧数据,一台缓存了新数据。没有共享,没有失效通知。 > 午后集群两处忙, > 缓存各自守空房。 > 一台旧来一台新, > 用户屏前数据慌。 # 02、问题 ```text 需求变化:单实例直查数据库 → 多实例加本地缓存 → 缓存不一致 冲突:本地缓存只在本机生效,实例间无共享、无失效传播 后果:Vet 列表在集群中返回不同结果,Owner 查询命中旧数据,用户体验受损 ``` 本地缓存失效的第一个原因是数据变更只更新了数据库,没有通知其他实例清缓存。第二个原因是缓存 Key 设计没有考虑参数组合,Owner 查询按姓名、城市、电话等条件组合时容易冲突。 影响范围不只是 Vet 列表。Owner 查询如果缓存了分页结果,翻页参数不同却命中同一个 Key,会返回错误数据。 可验证验收标准:两台实例同时请求 Vet 列表,返回结果必须一致;修改 Owner 后 5 秒内所有实例都能读到新数据;Redis 故障时服务不崩溃。 > 本地缓存各自安, > 数据变更无人传。 > 集群两处不同面, > 缓存失效成空谈。 # 03、原理 ```text Spring Cache 抽象 → CacheManager → Cache → 实际 Provider(本地/Redis) @Cacheable:先查缓存,未命中再执行方法并回填 @CacheEvict:删除缓存,支持 beforeInvocation 控制时机 Key 生成:默认 SimpleKeyGenerator,参数组合决定缓存键 TTL:由 Provider 控制,Spring Cache 抽象本身不定义 TTL ``` Spring Cache 的核心是 CacheManager 和 Cache 两个接口。 应用代码只依赖注解,不直接依赖 Redis 或 Caffeine。 CacheManager 决定缓存存在哪里,Cache 负责实际的 get、put、evict。 @Cacheable 的执行逻辑是:先根据 Key 查缓存,命中就直接返回,不执行方法体;未命中才执行方法,并把返回值放入缓存。 一个反直觉判断:缓存命中率高不代表缓存设计正确。如果 Key 设计错误,命中率可能很高,但返回的是错误数据。 另一个反直觉判断:TTL 不是解决一致性的手段,只是兜底。把 TTL 设短可以降低不一致窗口,但无法消除。 > 注解只是表面光, > 抽象之下有文章。 > 命中虽高莫轻信, > 键错数据也敢当。 # 04、架构 ```text 请求 → Controller → Service(@Cacheable/@CacheEvict)→ CacheManager → Cache Provider V1:本地 Caffeine Cache,单实例快,多实例不共享 V2:Redis Cache,集群共享,需处理序列化、TTL、故障降级 边界:缓存只加速读,写路径必须保证失效;缓存不可用时回源数据库 ``` V1 架构在 Service 层加缓存注解,使用 Caffeine 作为本地缓存 Provider。优点是零网络开销,单实例命中延迟极低。缺点是集群环境下各实例缓存独立,数据变更无法互相通知。 V2 架构把缓存 Provider 换成 Redis,所有实例共享同一份缓存。优点是数据一致,缺点是增加网络开销,且 Redis 故障时必须有降级策略。 缓存层必须明确边界:缓存不是数据源,数据库才是。缓存不可用时,服务必须能回源数据库,而不是直接报错。 适用条件:读多写少、数据变更频率低、查询耗时或数据库压力大。如果数据每次请求都变,缓存反而增加复杂度。 > 本地缓存快如风, > 集群共享却成空。 > 换得 Redis 同声气, > 故障降级记心中。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Petclinic 官方代码 + Caffeine 本地缓存 配置:pom.xml 加 spring-boot-starter-cache 和 caffeine 代码:VetService 加 @Cacheable,OwnerService 加 @Cacheable 和 @CacheEvict 启动:mvn spring-boot:run 验证:curl 请求 Vet 列表和 Owner 查询,观察日志与响应 V1:单实例缓存生效,多实例不一致 ``` 第一步,在 pom.xml 中加入缓存依赖。 ```xml org.springframework.boot spring-boot-starter-cache com.github.ben-manes.caffeine caffeine ``` 第二步,在启动类或配置类上开启缓存。 ```java @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager("vets", "owners"); manager.setCaffeine(Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(10))); return manager; } } ``` 第三步,在 VetService 中给查询方法加缓存注解。 ```java @Service public class VetService { private final VetRepository vetRepository; public VetService(VetRepository vetRepository) { this.vetRepository = vetRepository; } @Cacheable(cacheNames = "vets", key = "'all'") public List findAllVets() { return vetRepository.findAll(); } } ``` 第四步,在 OwnerService 中给查询和修改方法加缓存注解。 ```java @Service public class OwnerService { private final OwnerRepository ownerRepository; public OwnerService(OwnerRepository ownerRepository) { this.ownerRepository = ownerRepository; } @Cacheable(cacheNames = "owners", key = "#ownerId") public Owner findOwnerById(Integer ownerId) { return ownerRepository.findById(ownerId).orElse(null); } @CacheEvict(cacheNames = "owners", key = "#owner.id") public Owner saveOwner(Owner owner) { return ownerRepository.save(owner); } } ``` 第五步,启动应用并验证。 ```bash mvn spring-boot:run ``` ```bash curl http://localhost:8080/vets curl http://localhost:8080/owners/1 ``` 第一次请求会执行数据库查询,日志中能看到 SQL。第二次请求直接命中缓存,不再打印 SQL。示例输出: ```text 第一次请求:Hibernate: select v1_0.id,v1_0.first_name,v1_0.last_name from vets v1_0 第二次请求:无 SQL 输出,响应时间明显缩短 ``` V1 成功标准:单实例内第二次请求不再查数据库,响应时间下降。但多实例部署时,各实例各自维护缓存,数据不一致问题暴露。 > 依赖引入三两行, > 注解一加缓存忙。 > 单机命中无 SQL, > 集群共享却无方。 # 06、排查 ```text 诊断树: 多实例缓存不一致 ├── 怀疑:缓存未共享 │ ├── 检查:各实例 CacheManager 类型 │ │ └── 证据:均为 CaffeineCacheManager,本地缓存 │ └── 根因:本地缓存无跨实例通信 ├── 怀疑:缓存未失效 │ ├── 检查:修改 Owner 后其他实例是否清缓存 │ │ └── 证据:其他实例仍返回旧数据 │ └── 根因:@CacheEvict 只清本机缓存 └── 怀疑:Key 冲突 ├── 检查:不同参数是否命中同一缓存 │ └── 证据:Owner 列表按不同条件查询返回相同结果 └── 根因:Key 生成未包含全部参数 ``` 第一个问题:多实例缓存不一致。怀疑是缓存没有共享。检查各实例的 CacheManager 类型。 ```bash curl http://instance1:8080/actuator/cache curl http://instance2:8080/actuator/cache ``` 示例输出显示两个实例都是 caffeine 类型的本地缓存,各自独立。根因确认:本地缓存无法跨实例共享。 第二个问题:修改 Owner 后缓存未失效。怀疑是 @CacheEvict 只清本机缓存。在实例 1 上修改 Owner,然后请求实例 2。 ```bash curl -X POST http://instance1:8080/owners/1 -H "Content-Type: application/json" -d '{"firstName":"New","lastName":"Name"}' curl http://instance2:8080/owners/1 ``` 实例 2 仍返回旧数据。根因确认:@CacheEvict 只清本机缓存,其他实例不知道数据已变。 第三个问题:Key 冲突。怀疑是 Owner 列表查询的 Key 生成不完整。检查缓存 Key。 ```bash curl http://localhost:8080/actuator/cache/owners ``` 示例输出显示多个不同查询条件命中了同一个 Key。根因确认:Key 生成未包含全部参数。 **错误尝试:** 曾尝试把 TTL 设成 1 秒来掩盖不一致问题。结果不一致窗口缩小了,但没消除,而且缓存命中率大幅下降,数据库压力回升。 > 诊断先从共享查, > 本地缓存各安家。 > 失效只清本机键, > 键错数据也敢拿。 # 07、优化 ```text V1:Caffeine 本地缓存 → 多实例不一致、失效不传播、Key 冲突 证据:Actuator 显示各实例独立缓存,修改后其他实例返回旧数据 V2:Redis 共享缓存 + 完整 Key 设计 + 失效策略 + 故障降级 ``` 第一步,引入 Redis 依赖。 ```xml org.springframework.boot spring-boot-starter-data-redis ``` 第二步,配置 Redis 连接。 ```yaml spring: data: redis: host: localhost port: 6379 timeout: 2s ``` 第三步,替换 CacheManager 为 RedisCacheManager。 ```java @Configuration @EnableCaching public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } } ``` 第四步,修复 Key 设计。Vet 列表用固定 Key,Owner 查询用完整参数组合。 ```java @Cacheable(cacheNames = "vets", key = "'all'") public List findAllVets() { return vetRepository.findAll(); } @Cacheable(cacheNames = "owners", key = "#ownerId") public Owner findOwnerById(Integer ownerId) { return ownerRepository.findById(ownerId).orElse(null); } @Cacheable(cacheNames = "ownerList", key = "#lastName + ':' + #city + ':' + #page") public List findOwnersByCriteria(String lastName, String city, int page) { return ownerRepository.findByCriteria(lastName, city, page); } ``` 第五步,修改失效策略。写操作同时清缓存,并设置 TTL 兜底。 ```java @CacheEvict(cacheNames = "owners", key = "#owner.id") @CacheEvict(cacheNames = "ownerList", allEntries = true) public Owner saveOwner(Owner owner) { return ownerRepository.save(owner); } ``` 第六步,增加故障降级。Redis 不可用时回源数据库。 ```java @Service public class VetService { private final VetRepository vetRepository; private final CacheManager cacheManager; public VetService(VetRepository vetRepository, CacheManager cacheManager) { this.vetRepository = vetRepository; this.cacheManager = cacheManager; } public List findAllVets() { Cache cache = cacheManager.getCache("vets"); if (cache != null) { try { List cached = cache.get("all", List.class); if (cached != null) { return cached; } } catch (Exception e) { // 缓存故障,回源数据库 } } return vetRepository.findAll(); } } ``` > 换得 Redis 共享层, > 键要完整才敢称。 > 失效双管齐下稳, > 故障回源不硬撑。 # 08、演进 ```text V2:Redis 共享缓存 + 完整 Key + 失效策略 + 故障降级 能力:多实例数据一致、缓存失效传播、Key 冲突消除、Redis 故障不崩溃 遗留:缓存穿透、缓存雪崩、缓存击穿、容量规划、监控告警 ``` 用 V1 同一组请求验证 V2。先启动两个实例,都连接同一个 Redis。 ```bash curl http://instance1:8080/vets curl http://instance2:8080/vets ``` 两个实例返回相同结果。修改 Owner 后再次请求。 ```bash curl -X POST http://instance1:8080/owners/1 -H "Content-Type: application/json" -d '{"firstName":"New","lastName":"Name"}' curl http://instance2:8080/owners/1 ``` 实例 2 返回新数据,缓存失效传播生效。 收益:多实例数据一致,缓存失效及时,Key 冲突消除,Redis 故障时服务仍可用。 代价:增加 Redis 网络开销,序列化与反序列化消耗 CPU,运维复杂度上升。 适用条件:多实例部署、读多写少、数据变更频率低。单实例且数据量小时,本地缓存可能更简单。 遗留问题:缓存穿透(查询不存在的数据每次都打数据库)、缓存雪崩(大量 Key 同时过期)、缓存击穿(热点 Key 过期瞬间大量请求打数据库)尚未处理。 > 共享缓存数据同, > 失效传播不再空。 > 键全故障皆可降, > 穿透雪崩待后攻。 # 09、洞见 ```text 从现象到判断:缓存命中率高 ≠ 缓存设计正确;TTL 是兜底不是一致性方案;缓存故障必须降级 ``` ## 缓存工程真正困难的是失效,不是存取 ```text SET/GET 简单 → 失效复杂 → 一致性难 ``` Redis 的 SET 和 GET 命令谁都会写,但什么时候删缓存、删哪些缓存、删的顺序是什么,才是缓存工程的核心难题。V1 的 @CacheEvict 只清本机缓存,就是失效设计不完整的典型。 ## 反直觉判断:缓存命中率高不代表缓存正确 ```text 命中率高 → 可能 Key 冲突 → 返回错误数据 ``` 如果 Key 设计错误,不同参数命中同一个缓存,命中率可能很高,但返回的是错误数据。V1 的 Owner 列表查询就是这个问题。判断缓存是否正确,要看返回数据是否正确,而不是看命中率。 ## 反直觉判断:TTL 不能解决一致性 ```text TTL 短 → 不一致窗口小 → 但无法消除 ``` 把 TTL 设短只能缩小不一致窗口,无法消除。真正的一致性要靠失效策略:写操作主动清缓存,TTL 只是兜底。V1 曾尝试用 1 秒 TTL 掩盖问题,结果命中率下降,数据库压力回升。 ## 缓存故障必须降级,不能硬撑 ```text Redis 挂 → 缓存异常 → 回源数据库 ``` 缓存不是数据源,数据库才是。Redis 故障时,服务必须能回源数据库,而不是直接报错。V2 的降级逻辑就是为此设计。 > 存取简单失效难, > 命中虽高莫轻看。 > TTL 只是兜底策, > 故障回源才平安。 # 10、系统落地 ```text 入口:HTTP 请求 → Controller → Service → CacheManager → Redis/数据库 本模块:Spring Cache 抽象 + Redis Provider + 失效策略 + 故障降级 存储:Redis 缓存 + MySQL 数据库 未实现:缓存穿透、雪崩、击穿防护,缓存监控告警 ``` 原来 Petclinic 只有数据库直查,没有缓存层。本篇增加了 Spring Cache 抽象,V1 用 Caffeine 本地缓存,V2 换成 Redis 共享缓存。 现在系统能做什么:多实例共享缓存,数据一致,写操作主动失效,Redis 故障时回源数据库。 下一篇缺什么:缓存穿透、缓存雪崩、缓存击穿的防护,以及缓存命中率、延迟、容量的监控告警。 官方链接:Spring Petclinic 主页 https://github.com/spring-projects/spring-petclinic ,Spring Boot 文档 https:/ /docs.spring.io/spring-boot/documentation.html 。 > 缓存一层落系统, > 共享失效皆已通。 > 穿透雪崩犹未解, > 监控告警待后攻。 # 11、小结 ```text 三问三答: 数据库 20ms 是否值得加 Redis?→ 单次 20ms 不慢,但并发和连接池压力下值得加 本地缓存和 Redis 先上哪个?→ 多实例必须 Redis,单实例可先本地 选错后果?→ 数据不一致、Key 冲突、Redis 故障无降级 ``` 数据库查询 20ms 是否值得加 Redis?单次 20ms 确实不慢,但并发上来后连接池和延迟同时恶化,读多写少场景值得加缓存。 本地缓存和 Redis 先上哪个?多实例部署必须上 Redis,本地缓存无法共享。单实例可以先用本地缓存,但迟早要面对集群问题。 选错后果是什么?数据不一致、Key 冲突返回错误数据、Redis 故障时服务崩溃。 当前状态:V2 已实现 Redis 共享缓存、完整 Key 设计、写操作失效、故障降级。缓存穿透、雪崩、击穿防护尚未实现。 > 三问三答已分明, > 缓存工程不轻松。 > 共享失效皆已稳, > 防护监控待后行。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:@Cacheable 和 @CacheEvict 的执行时机分别是什么? @Cacheable 在方法执行前先查缓存,命中则直接返回,不执行方法体; 未命中才执行方法,并把返回值放入缓存。 @CacheEvict 默认在方法执行后删除缓存,可以通过 beforeInvocation=true 改为方法执行前删除。 ## 实战题:给 Petclinic 的 Pet 查询加上缓存,并设计完整的 Key 和失效策略 在 PetService 中给 findPetById 加 @Cacheable(cacheNames="pets", key="#petId"),给 savePet 加 @CacheEvict(ca cheNames="pets", key="#pet.id")。 如果 Pet 列表查询存在,Key 要包含 ownerId 和分页参数。 ## 排障题:生产环境两台实例缓存不一致,如何定位和修复? 先检查各实例的 CacheManager 类型,确认是否本地缓存。 如果是本地缓存,换成 Redis 共享缓存。 再检查写操作是否加了 @CacheEvict,确保失效传播。 最后检查 Key 设计是否完整,避免不同参数命中同一缓存。 ## 架构判断题:单实例部署的 Petclinic 是否应该直接上 Redis? 单实例部署时,本地缓存 Caffeine 更简单,零网络开销,延迟更低。Redis 增加网络开销和运维复杂度,收益有限。但如果未来可能扩展为多实例,可以提前上 Redis,避免后期迁移。 > 理解实战排障全, > 架构判断记心间。 > 缓存工程非易事, > 步步为营才周全。 # 13、升华 ```text 工程结论:缓存的价值不在 SET/GET,而在一致性、失效、容量和故障降级 ``` 缓存工程真正困难的是一致性、失效、容量和故障降级,而不是 SET/GET。 V1 的本地缓存在单实例下工作良好,但多实例部署时暴露了共享和失效问题。 V2 的 Redis 共享缓存解决了这些问题,但引入了网络开销和运维复杂度。 有依据的结论:多实例部署必须用共享缓存;Key 设计必须完整;写操作必须主动失效;缓存故障必须降级。 下一篇将处理缓存穿透、缓存雪崩、缓存击穿,并增加缓存监控告警,让缓存层真正可观测、可排障。 > 缓存工程路漫长, > 存取之外有文章。 > 一致失效容量降, > 步步为营方成章。 **系列项目:** Spring Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全、数据访问、事务、远程调用、数据库迁移、缓存基础 **本篇:** Spring Cache 抽象、本地缓存到 Redis 共享缓存演进、Key 设计、失效策略、故障降级 **下一篇:** 缓存穿透、雪崩、击穿防护与缓存监控告警

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第21招 可观测要串成证据链

# Petclinic 统一诊断体系:从 P99 三秒沿证据链定位慢点 ```text 旧方案:日志、指标、链路三套独立,靠人肉 grep 与经验猜 冲突:P99 从 100ms 涨到 3s,告警响了却不知道先看哪 后果:MTTR 从分钟级拖到小时级,回滚靠运气,复盘无证据 ``` Petclinic 已经接入了 Actuator、Prometheus 抓取、基础 JVM 指标,但每次线上变慢,团队还是先打开日志翻 stack trace,再切到 Grafana 看 CPU,最后 才想起链路系统。 我是老李,这次被拉进一个真实故障:Owner 查询接口 P99 从 100ms 涨到 3s,用户投诉、老板催、监控告警同时响。 日志里没有异常,CPU 没打满,数据库连接池也没满,那这 3 秒到底花在哪? 是继续加日志重新发布,还是先建立一条从 HTTP 到 SQL、Redis、远程调用的统一证据链? 如果选错,会不会在错误方向上优化半天,最后发现慢在第三方保险报价服务? # 01、故事 ```text 周二 14:30 → 生产环境 → 任务:定位 Owner 查询 P99 三秒 → 冲突:日志无异常、CPU 不高、连接池没满 ``` 周二下午两点半,生产环境告警群突然刷屏:petclinic-owner-service 的 HTTP P99 超过 3 秒。 任务是半小时内给出初步结论,但打开 Kibana 只有正常请求日志,打开 Grafana 看到 CPU 不到 40%,Hikari 活跃连接只有 5 个。 我盯着三块屏幕,感觉像在三个互不相通的房间里找一只猫。 > 告警声里翻日志, > 三块屏幕各说各话。 > 猫在哪个房间, > 没人递来一根线。 # 02、问题 ```text 需求变化:业务新增保险报价远程调用 → 冲突:慢请求无统一上下文 → 后果:无法判断慢在 JVM、线程、SQL、Redis 还是远程 HTTP ``` Owner 查询原本只查数据库和缓存,最近为了给宠物主展示保险报价,新增了一次对第三方保险服务的远程调用。 慢请求出现后,日志、指标、链路三套体系各自为政:日志有 traceId 但没和指标关联,指标有 P99 但不知道是哪个 SQL 拖的,链路有 span 但没和 JVM 线程池指标对齐。 可验证的验收标准是:任意一个慢请求,能在 5 分钟内从 HTTP 入口一路看到 JVM 线程、Hikari、SQL、Redis、远程 HTTP 的耗时分布。 > 三套体系三座岛, > 桥没架好船没到。 > 慢请求像一阵风, > 吹过无痕找不到。 # 03、原理 ```text Observation = 统一上下文 + 低基数标签 + 高基数标签 + 事件 + 计时 ``` Micrometer Observation 把一次操作抽象成一个 Observation,同时向日志、指标、链路三个出口输出。 一个 Observation 包含低基数标签(如服务名、接口名、结果状态)和高基数标签(如 ownerId、traceId),低基数进 Prometheus,高基数进链路系统。 反直觉的是:不是所有标签都该进指标,高基数标签会撑爆 Prometheus 时间序列,但高基数标签恰恰是排障时最需要的线索。 Observation 的 Timer 和 LongTaskTimer 是两回事:Timer 记录已完成操作的耗时分布,LongTaskTimer 记录正在执行的长任务数量,慢请求排查要先用 Time r 定位,再用 LongTaskTimer 看是否堆积。 > 一根线穿过三座岛, > 低基数进指标, > 高基数进链路, > 反直觉处见分晓。 # 04、架构 ```text HTTP 入口 → ObservationFilter → OwnerService(Observation) → Hikari/SQL/Redis/Remote HTTP → ObservationRegistry → Logging/Metrics/Tracing ``` 在 Petclinic 中,Owner 查询的调用链是:HTTP 入口先创建根 Observation,OwnerService 在方法上声明 Observation,内部对 SQL、Redis、远程 HTTP 分别创建子 Observation。 所有 Observation 注册到同一个 ObservationRegistry,由它分发给 LoggingHandler、MeterHandler、TracingHandler。 边界是:低基数标签只进 Meter,高基数标签只进 Tracing,日志只拿 traceId 和关键事件。 收益是排障时能沿一条证据链走到底;代价是每个关键操作都要埋点,代码侵入性比纯 AOP 高。 > 入口一根线, > 内部三根线, > 注册中心分三路, > 证据链上见。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Petclinic → 配置:micrometer-tracing + observation → 代码:OwnerService 埋点 → 启动 → 验证:/owners/{id} → V1:只有 HTTP 和 SQL 两个 Observation ``` 第一版先给 Owner 查询加最基础的 Observation。 在 `pom.xml` 中加入 Micrometer Tracing 和 Observation 自动配置: ```xml io.micrometer micrometer-tracing-bridge-otel io.micrometer micrometer-observation ``` 在 `OwnerController` 中注入 `ObservationRegistry`,给查询方法创建 Observation: ```java @GetMapping("/owners/{ownerId}") public OwnerDto getOwner(@PathVariable Long ownerId) { return Observation.createNotStarted("owner.find", registry) .lowCardinalityKeyValue("api", "getOwner") .highCardinalityKeyValue("ownerId", ownerId.toString()) .observe(() -> ownerService.findOwner(ownerId)); } ``` 在 `OwnerService` 中给 SQL 查询加子 Observation: ```java public OwnerDto findOwner(Long ownerId) { return Observation.createNotStarted("owner.find.sql", registry) .lowCardinalityKeyValue("db", "petclinic") .observe(() -> ownerRepository.findById(ownerId) .map(this::toDto) .orElseThrow(() -> new ResourceNotFoundException("Owner not found"))); } ``` 启动应用后,用 curl 验证: ```bash curl -s http://localhost:8080/owners/1 | jq . ``` 示例输出: ```json { "id": 1, "firstName": "George", "lastName": "Franklin", "pets": [] } ``` 访问 Actuator 的 Prometheus 端点验证指标: ```bash curl -s http://localhost:8080/actuator/prometheus | grep owner_find ``` 示例输出: ```text owner_find_seconds_count{api="getOwner",error="none"} 12 owner_find_seconds_sum{api="getOwner",error="none"} 0.84 ``` V1 成功标准:HTTP 和 SQL 两个 Observation 都有计时指标,traceId 出现在日志中。 > 第一版埋下两根线, > HTTP 和 SQL 各一边。 > 指标里有了计数, > 日志里有了 traceId。 # 06、排查 ```text 诊断树:P99 三秒 → 是 HTTP 慢还是 SQL 慢? → 是线程等待还是执行慢? → 是远程调用还是本地资源? ``` V1 上线后,P99 还是 3 秒,但这次有了证据链。 第一个问题:HTTP 总耗时 3 秒,但 SQL 子 Observation 只有 80ms,说明慢点不在数据库。 检查命令: ```bash curl -s http://localhost:8080/actuator/prometheus | grep -E 'owner_find_seconds|owner_find_sql_seconds' ``` 示例输出: ```text owner_find_seconds_sum 3.1 owner_find_sql_seconds_sum 0.08 ``` 第二个问题:HTTP 和 SQL 之间差了 2.9 秒,怀疑是线程等待或远程调用。 检查 Tomcat 线程指标: ```bash curl -s http://localhost:8080/actuator/prometheus | grep tomcat_threads ``` 示例输出: ```text tomcat_threads_busy 18 tomcat_threads_current 200 tomcat_threads_config_max 200 ``` 线程没满,排除线程池耗尽。 **错误尝试:** 一开始怀疑是 Hikari 连接池等待,加了 `hikaricp_connections_pending` 指标,发现 pending 一直是 0,浪费了 20 分钟。 第三个问题:V1 没有给远程 HTTP 调用埋点,所以看不到保险报价服务的耗时。 根因:Owner 查询新增的保险报价远程调用没有纳入 Observation,导致证据链在 HTTP 和 SQL 之间断了。 > 先看总再看分, > SQL 只要八十毫秒。 > 线程没满池没等, > 断点藏在远程调用。 # 07、优化 ```text V1:HTTP + SQL 两个 Observation → 证据:HTTP 3s、SQL 80ms、线程未满 → V2:补上 Redis、远程 HTTP、Hikari 的 Observation ``` V2 给远程 HTTP 调用、Redis 缓存、Hikari 连接获取都加上 Observation。 在 `InsuranceClient` 中给远程调用埋点: ```java public QuoteDto getQuote(Long ownerId) { return Observation.createNotStarted("owner.insurance.remote", registry) .lowCardinalityKeyValue("remote", "insurance-service") .highCardinalityKeyValue("ownerId", ownerId.toString()) .observe(() -> restClient.get() .uri("/quotes?ownerId=" + ownerId) .retrieve() .body(QuoteDto.class)); } ``` 在 `OwnerService` 中给 Redis 缓存查询埋点: ```java public OwnerDto findOwner(Long ownerId) { return Observation.createNotStarted("owner.find.redis", registry) .lowCardinalityKeyValue("cache", "redis") .observe(() -> { String cached = redisTemplate.opsForValue().get("owner:" + ownerId); if (cached != null) { return objectMapper.readValue(cached, OwnerDto.class); } OwnerDto dto = findOwnerFromDb(ownerId); redisTemplate.opsForValue().set("owner:" + ownerId, objectMapper.writeValueAsString(dto)); return dto; }); } ``` 在 `application.yaml` 中开启 Hikari 指标: ```yaml spring: datasource: hikari: register-mbeans: true management: metrics: enable: hikaricp: true ``` 重新启动后,用同一组请求验证: ```bash for i in $(seq 1 20); do curl -s -o /dev/null http://localhost:8080/owners/1; done curl -s http://localhost:8080/actuator/prometheus | grep -E 'owner_insurance_remote_seconds|owner_find_redis_seconds' ``` 示例输出: ```text owner_insurance_remote_seconds_sum 2.85 owner_find_redis_seconds_sum 0.02 ``` 证据链闭合:HTTP 3 秒,远程保险报价 2.85 秒,SQL 80ms,Redis 20ms。 > 补上远程和缓存, > 证据链终于闭合。 > 三秒里两秒八五, > 慢在第三方报价服务。 # 08、演进 ```text V2:完整 Observation 证据链 → 能力:5 分钟定位慢点 → 遗留:远程服务内部不可见 ``` V2 用同一组请求前后验证:V1 时只能看到 HTTP 3 秒和 SQL 80ms,V2 后能看到远程保险报价占 2.85 秒。 收益是 MTTR 从小时级降到分钟级,排障不再靠猜。 代价是每个关键操作都要手动埋点,代码量增加约 15%。 适用条件是:调用链清晰、关键操作可枚举;不适用于动态生成的大量细粒度操作。 遗留问题是:远程保险服务内部慢在哪,Petclinic 侧看不到,需要对方也接入 Observation 或提供 span 导出。 > 从三秒到两秒八五, > 证据链上走一遍。 > 代价是埋点十五行, > 遗留是远程内部看不见。 # 09、洞见 ```text 从现象到判断:P99 三秒不是单点问题,是证据链断裂问题 ``` ## 低基数与高基数标签的边界是排障效率的分水岭 ```text 低基数 → Prometheus 聚合 高基数 → 链路系统检索 混用 → 时间序列爆炸 ``` 反直觉判断:把 ownerId 放进 Prometheus 指标会让时间序列数量随用户数线性增长,最终拖垮监控系统;但排障时没有 ownerId 又无法定位具体慢请求。 正确做法是低基数进指标、高基数进链路,两者通过 traceId 关联。 ## Timer 和 LongTaskTimer 回答的是两个不同的问题 ```text Timer → 完成了多少、平均多慢 LongTaskTimer → 现在有多少在跑、跑了多久 ``` 反直觉判断:P99 涨到 3 秒时,先看 Timer 的 count 和 sum,再看 LongTaskTimer 的 active 数量; 如果 active 数量突增,说明请求堆积,根因可能在入口而非下游。 ## 证据链的价值不在采集,而在关联 ```text 日志有 traceId → 指标有标签 → 链路有 span 三者不关联 → 三座孤岛 ``` Observation 的核心不是多采几个指标,而是让日志、指标、链路共享同一个上下文,排障时能从一个点跳到另一个点。 ## 远程调用必须作为一等公民埋点 ```text 本地资源 → 可观测 远程依赖 → 必须可观测 ``` Petclinic 的教训是:新增远程调用时没有同步埋点,导致证据链断裂;远程依赖的观测成本要计入功能开发成本。 > 低基数进指标, > 高基数进链路。 > Timer 看完成, > LongTask 看堆积。 # 10、系统落地 ```text 入口:HTTP /owners/{id} → 服务:OwnerService → 本模块:Observation 证据链 → 存储:Prometheus + 链路后端 → 未实现:远程服务内部观测 ``` 原来 Petclinic 有 Actuator 基础指标和分散的日志,但没有统一诊断体系。 本篇增加了 Micrometer Observation 统一上下文,覆盖 HTTP、SQL、Redis、远程 HTTP、Hikari 五个关键环节。 现在能做什么:任意慢请求可在 5 分钟内沿证据链定位到具体环节。 下一篇缺什么:远程保险服务内部不可见,需要接入 OpenTelemetry Collector 或要求对方导出 span。 官方文档可参考 Spring Boot 文档首页和 Micrometer 文档首页。 > 入口到服务到存储, > 五环埋点一条线。 > 本篇补上证据链, > 下篇补远程内部。 # 11、小结 ```text 三问三答:先建证据链再排障;低基数进指标高基数进链路;选错会在错误方向优化 ``` 第一问:是继续加日志重新发布,还是先建立统一证据链?答案是先建证据链,因为日志无异常时,缺的是关联而非更多日志。 第二问:低基数和高基数标签怎么分?答案是低基数进 Prometheus,高基数进链路系统,通过 traceId 关联。 第三问:选错会怎样?选错会在错误方向优化半天,比如先优化 SQL,最后发现慢在远程调用。 当前状态:Petclinic 已具备从 HTTP 到 SQL、Redis、远程 HTTP 的完整 Observation 证据链。 > 先建线再排障, > 低高基数分开走。 > 选错方向优化半天, > 不如先看证据链。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么高基数标签不能进 Prometheus 指标? 高基数标签会让时间序列数量随标签值数量线性增长,比如 ownerId 有 10 万个用户,每个接口指标都会乘以 10 万,Prometheus 内存和查询性能会崩溃。 ## 实战题:给 Petclinic 的 Owner 查询补上 Redis 缓存的 Observation,并验证指标输出。 在 OwnerService 中给 Redis 查询创建 Observation,设置低基数标签 cache=redis,启动后用 curl 访问 /owners/1,再访问 /actuator/pr ometheus 验证 owner_find_redis_seconds 指标存在。 ## 排障题:HTTP P99 涨到 3 秒,但 SQL 和 Redis 指标都正常,下一步查什么? 下一步查远程 HTTP 调用的 Observation 指标,如果远程调用耗时接近 3 秒,根因在第三方服务; 如果远程调用也正常,再查 Tomcat 线程和 Hikari 连接池的 LongTaskTimer 是否堆积。 ## 架构判断题:是否应该给每个 Service 方法都加 Observation? 不应该。Observation 应该加在关键路径和远程依赖上,给每个方法都加会导致代码噪音大、指标过多、维护成本高;应该先梳理调用链,只给边界和远程调用埋点。 > 理解高基数, > 实战补缓存。 > 排障看远程, > 架构不滥埋。 # 13、升华 ```text 工程结论:统一诊断体系的价值不在采集,而在关联;证据链闭合才能把 MTTR 从小时级降到分钟级 ``` Petclinic 的 P99 三秒故障证明:日志、指标、链路三套独立时,排障靠猜;用 Observation 统一上下文后,排障靠证据。 有依据的结论是:远程依赖的观测成本必须计入功能开发成本,新增远程调用时同步埋点,否则证据链断裂。 下一篇将把远程保险服务内部也纳入观测,通过 OpenTelemetry Collector 接收对方导出的 span,让证据链延伸到第三方系统内部。 > 三秒故障一根线, > 证据链上走一遍。 > 远程依赖要埋点, > 下篇延伸到对面。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web/安全/数据/事务、远程调用/数据库迁移/缓存/并发、日志/测试/Testcontainers/Docker Compose、Actuator/可观测基础 **本篇:** Micrometer Observation 统一诊断体系,覆盖 HTTP、SQL、Redis、远程 HTTP、Hikari 的证据链 **下一篇:** OpenTelemetry Collector 接入与远程服务内部观测延伸

优秀的个人博客,低调大师

《SpringBoot4进阶实战》第23招 包和容器怎么真正构建

# 从可执行 Jar 到 OCI 镜像:Petclinic 容器构建的工程冲突与分层价值 ```text 旧方案:COPY target/app.jar app.jar → 镜像层巨大 → 每次发布全量传输 冲突:开发频繁、带宽有限、发布窗口短 后果:构建慢、推送慢、回滚慢、排障难 ``` Spring Petclinic 已经能通过 `mvn package` 打出可执行 Jar,本地 `java -jar` 也能启动。 但到了容器发布阶段,团队发现一个尴尬事实:Jar 能跑,不代表镜像构建方案成熟。 我是老李,负责把 Petclinic 推到测试环境。运维催镜像,开发催发布,网络只有 10Mbps 专线。 为什么 `COPY target/app.jar app.jar` 能运行,却仍然不是一个成熟的 Java 容器构建方案? 是继续用传统 Dockerfile 手写分层,还是切换到 Cloud Native Buildpacks 自动构建 OCI 镜像? 如果选错,会不会让每次发布都变成几百 MB 的全量推送,最终拖垮交付节奏? # 01、故事 ```text 周四 23:40 → 测试环境发布机 → 推送 Petclinic 镜像 → 带宽占满被运维叫停 ``` 晚上十一点四十分,我还在测试环境发布机前。任务是推送 Petclinic 新镜像到 Harbor。 开发刚修完一个登录 Bug,催我赶紧发。运维在群里说专线流量异常,让我先停。 我打开 Dockerfile,只有一行 `COPY target/petclinic.jar app.jar`。镜像 680MB,推送进度条像卡住一样。 > 夜深镜像推不动, > 专线告急催更凶。 > 一行拷贝虽能跑, > 全量传输路难通。 # 02、问题 ```text 需求变化:每周多次发布 → 镜像全量构建 → 推送与部署耗时剧增 冲突:Jar 可运行但镜像不可增量 后果:发布阻塞、回滚缓慢、存储膨胀 ``` 传统 Dockerfile 把整个 Fat Jar 放进一层。任何业务代码改动都会导致该层哈希变化。 Docker 构建缓存失效后,后续层全部重建。推送时,客户端与仓库之间传输的是完整层数据。 在 10Mbps 专线下,680MB 镜像推送需要约 9 分钟。一次发布至少构建、推送、部署三步串行。 可验证验收标准:业务代码只改一个 Controller 时,镜像推送增量应小于 5MB,而不是全量 680MB。 > 一层打包百兆重, > 每次发布全网送。 > 缓存虽在难复用, > 交付节奏被打痛。 # 03、原理 ```text Boot Loader 启动 → 解析 Jar 内依赖 → 按 Layer Index 分层 → 镜像层缓存复用 ``` Spring Boot 可执行 Jar 由 Boot Loader 负责启动。它先读取 `META-INF/MANIFEST.MF` 中的 `Main-Class`。 然后通过 `LaunchedURLClassLoader` 加载 `BOOT-INF/lib` 下的依赖 Jar,再加载 `BOOT-INF/classes` 下的业务类。 这种结构天然适合分层:依赖变化频率低,业务类变化频率高。把变化频率相近的内容放同一层,能最大化缓存复用。 反直觉判断:镜像体积大不一定慢,层变化频繁才慢。一个 680MB 但分层合理的镜像,可能比 300MB 单层镜像发布更快。 > 启动加载有顺序, > 依赖业务本分离。 > 分层若按频率切, > 缓存命中省力气。 # 04、架构 ```text 源码 → Maven 构建 → 可执行 Jar → 分层索引 → OCI 镜像 → Registry ``` 本模块位于构建与发布之间。输入是 Maven 产出的可执行 Jar,输出是符合 OCI 规范的容器镜像。 分层边界包括:`dependencies`、`spring-boot-loader`、`snapshot-dependencies`、`application`。 收益是构建缓存复用、推送增量传输、回滚时只拉变化层。代价是需要维护分层策略或引入 Buildpacks 工具链。 适用条件:应用依赖稳定、发布频繁、网络带宽受限。如果应用从不更新,分层收益可以忽略。 > 输入 Jar 包输出镜像, > 分层边界要划清。 > 依赖稳定业务变, > 缓存复用才可行。 # 05、第一版实战 ```text 环境:JDK 17 + Maven 3.9 + Docker 24 → 配置 spring-boot-maven-plugin → 构建 Jar → 传统 Dockerfile → 验证运行 → V1 ``` 当前 Petclinic 使用 Spring Boot 3.x,构建工具为 Maven。先确认 `pom.xml` 中插件版本。 ```xml org.springframework.boot spring-boot-maven-plugin ${spring-boot.version} ``` 执行打包命令,生成可执行 Jar。 ```bash mvn clean package -DskipTests ``` 查看 Jar 内部结构,确认 Boot Loader 与依赖位置。 ```bash jar tf target/petclinic.jar | head -30 ``` 示例输出: ```text META-INF/MANIFEST.MF org/springframework/boot/loader/Launcher.class BOOT-INF/lib/spring-core-6.1.x.jar BOOT-INF/classes/org/springframework/samples/petclinic/PetClinicApplication.class ``` 编写传统 Dockerfile。 ```dockerfile FROM eclipse-temurin:17-jre COPY target/petclinic.jar app.jar ENTRYPOINT ["java", "-jar", "/app.jar"] ``` 构建镜像并运行。 ```bash docker build -t petclinic:v1 . docker run -p 8080:8080 petclinic:v1 ``` 验证请求: ```bash curl http://localhost:8080/owners?lastName= ``` 返回 200 与 HTML 页面,说明 V1 可运行。成功标准是容器能启动并响应 HTTP 请求。 > 插件打包 Jar 已成, > 一行拷贝镜像生。 > 容器启动能响应, > 初版虽通隐患增。 # 06、排查 ```text 诊断树:推送慢 → 层是否复用?→ 否 → 单层是否过大?→ 是 → 根因:全量 Jar 单层 ``` 第一个问题:推送慢。怀疑镜像层没有复用。检查镜像构建历史。 ```bash docker history petclinic:v1 ``` 示例输出: ```text IMAGE CREATED SIZE abc123 2 minutes ago 680MB ``` 证据显示只有一个 680MB 的大层。任何代码改动都会让该层哈希变化,缓存完全失效。 第二个问题:镜像体积大。怀疑基础镜像包含不必要的 JDK。检查镜像内 Java 版本。 ```bash docker run --rm petclinic:v1 java -version ``` 示例输出: ```text openjdk version "17.0.9" ``` 根因是使用 `eclipse-temurin:17-jre` 但 Jar 内已包含全部依赖,基础镜像仍有 200MB 以上运行时开销。 **错误尝试:** 我曾尝试用 `docker save` 压缩镜像再传输,结果压缩耗时比推送还长,且解压后仍需完整存储。 > 历史一层六百八, > 缓存失效全白搭。 > 基础镜像也偏大, > 压缩传输更抓瞎。 # 07、优化 ```text V1 单层全量 → 证据:docker history 显示 680MB 单层 → V2 使用 Buildpacks 自动分层 ``` 改用 Spring Boot Maven Plugin 的 `build-image` 目标,底层使用 Cloud Native Buildpacks。 ```bash mvn spring-boot:build-image -Dspring-boot.build-image.imageName=petclinic:v2 ``` 该命令会自动完成:分析 Jar 结构、生成 Layer Index、调用 Paketo Buildpacks 构建 OCI 镜像。 查看生成的镜像分层。 ```bash docker history petclinic:v2 ``` 示例输出: ```text IMAGE CREATED SIZE COMMENT abc456 1 minute ago 2.1MB application abc455 1 minute ago 0B spring-boot-loader abc454 1 minute ago 45MB snapshot-dependencies abc453 1 minute ago 210MB dependencies abc452 1 minute ago 180MB base image ``` 业务代码改动只影响 `application` 层,推送时只需传输 2.1MB 增量。 > 构建镜像换工具, > 自动分层有依据。 > 业务改动只传小, > 推送时间降到底。 # 08、演进 ```text V2 分层镜像 → 能力:增量推送、缓存复用 → 遗留:基础镜像与 JVM 参数仍需治理 ``` 用 V1 同一组请求验证 V2 镜像。 ```bash docker run -p 8080:8080 petclinic:v2 curl http://localhost:8080/owners?lastName= ``` 返回 200,功能与 V1 一致。收益是推送增量从 680MB 降到 2.1MB,构建时间因缓存复用缩短。 代价是引入 Buildpacks 工具链,本地首次构建需要拉取构建器镜像,约 500MB。 适用场景:频繁发布、依赖稳定、网络受限。遗留问题:容器默认以 root 运行,JVM 内存参数未显式设置。 > 功能不变收益显, > 增量推送省时间。 > 首次构建有代价, > 用户权限待完善。 # 09、洞见 ```text 从现象到判断:镜像慢不是体积问题,是层变化问题 ``` ## 分层比瘦身更重要 ```text 单层 300MB vs 多层 680MB → 多层发布更快 ``` 反直觉判断:镜像总体积小不一定发布快。多层镜像虽然总体积大,但业务改动只触发小层变化,推送增量远小于单层瘦身镜像。 ## 可运行不等于可发布 ```text java -jar 成功 → 容器启动成功 → 但发布效率可能极低 ``` `COPY app.jar` 能跑通功能,但每次发布都是全量传输。成熟方案必须考虑构建缓存、推送增量、回滚粒度。 ## 工具链切换有隐藏成本 ```text Buildpacks 首次构建 → 拉取构建器 → 本地缓存 → 后续才快 ``` 反直觉判断:切换到 Buildpacks 后第一次构建可能比传统 Dockerfile 更慢。因为要下载构建器镜像和依赖元数据,但第二次开始收益显著。 ## 分层策略依赖变化频率 ```text 依赖稳定 → 分层收益大;依赖频繁变 → 分层收益小 ``` 如果项目每周都升级大量依赖,`dependencies` 层也会频繁变化,分层缓存收益会打折扣。 > 镜像快慢看层变, > 能跑未必能上线。 > 工具切换有成本, > 分层收益看频变。 # 10、系统落地 ```text 入口:mvn spring-boot:build-image → 服务:Buildpacks 分析 Jar → 本模块:分层镜像 → 存储:Registry → 未实现:容器用户与 JVM 参数治理 ``` 原来 Petclinic 只有可执行 Jar,容器构建靠手写 Dockerfile 单层拷贝。 本篇增加 Buildpacks 自动分层构建 OCI 镜像能力,实现依赖层、快照层、应用层分离。 现在能做什么:业务代码改动只推送 2MB 左右增量,构建缓存跨发布复用。 下一篇缺什么:容器内用户权限、JVM 内存与 GC 参数、镜像体积进一步优化。 官方文档可参考 Spring Boot Maven Plugin 文档首页与 Paketo Buildpacks 文档首页。 > 入口插件构建镜像, > 服务分析 Jar 内层。 > 本模块分层落地, > 存储推送增量轻。 # 11、小结 ```text 三问三答:为什么单层不成熟?→ 全量传输;选 Dockerfile 还是 Buildpacks?→ 分层优先;选错后果?→ 发布阻塞 ``` 第一问:`COPY app.jar` 能运行但不成熟,因为单层导致任何改动都全量传输,缓存失效。 第二问:传统 Dockerfile 手写分层可行但易错,Buildpacks 自动分层更适合频繁发布的 Java 应用。 第三问:选错会导致每次发布推送几百 MB,专线占满,回滚缓慢,交付节奏被打乱。 当前状态:Petclinic 已具备分层 OCI 镜像构建能力,推送增量从 680MB 降到 2.1MB。 > 单层全量不成熟, > 自动分层解忧愁。 > 选错方案发布堵, > 当前增量已轻舟。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 Spring Boot 可执行 Jar 适合分层? 因为 Boot Loader 将依赖放在 `BOOT-INF/lib`,业务类放在 `BOOT-INF/classes`,两者变化频率不同。分层后依赖层可长期缓存,业务层每次发布只更新小层。 ## 实战题:用 Buildpacks 构建 Petclinic 镜像并验证分层 执行 `mvn spring-boot:build-image`,然后用 `docker history` 查看分层。修改一个 Controller 后重新构建,对比推送增量是否小于 5MB。 ## 排障题:Buildpacks 构建时拉取构建器镜像失败 检查网络是否能访问 `ghcr.io` 或配置的镜像仓库。可设置 `DOCKER_HOST` 或代理。若公司网络受限,需配置私有构建器镜像缓存。 ## 架构判断题:依赖频繁变化的项目是否适合 Buildpacks 分层? 不适合。依赖层频繁变化会导致缓存命中率低,分层收益被抵消。此时应优先稳定依赖版本,或考虑将应用拆分为更小服务。 > 理解分层看频率, > 实战构建验增量。 > 排障网络查代理, > 架构判断看依赖。 # 13、升华 ```text 工程结论:容器构建的成熟度不在能跑,而在能增量、能缓存、能治理 ``` 有依据的结论:分层是 Java 容器构建的核心工程手段,Buildpacks 将分层自动化,降低维护成本。 下一篇将治理容器内用户权限与 JVM 参数,让镜像不仅构建快,运行也更安全、更可控。 > 能跑只是第一步, > 增量缓存才成熟。 > 分层自动化落地, > 安全可控待下篇。 **系列项目:** Spring Petclinic 宠物医院管理系统 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层、安全层、数据层、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置 **本篇:** 从可执行 Jar 到 OCI 镜像的分层构建能力 **下一篇:** 容器用户权限与 JVM 参数治理 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。

资源下载

更多资源
Mario

Mario

马里奥是站在游戏界顶峰的超人气多面角色。马里奥靠吃蘑菇成长,特征是大鼻子、头戴帽子、身穿背带裤,还留着胡子。与他的双胞胎兄弟路易基一起,长年担任任天堂的招牌角色。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

WebStorm

WebStorm

WebStorm 是jetbrains公司旗下一款JavaScript 开发工具。目前已经被广大中国JS开发者誉为“Web前端开发神器”、“最强大的HTML5编辑器”、“最智能的JavaScript IDE”等。与IntelliJ IDEA同源,继承了IntelliJ IDEA强大的JS部分的功能。

用户登录
用户注册