chap2.5:HTTP调用的超时、重试、并发问题
1-超时时间设置
- 连接超时 ConnectTimeout:建立连接阶段的最大等待时长
- 读取超时 ReadTimeout:读取套接字数据的最大等待时长(服务端请求处理时长)
配置Feign的超时时间,需要同时配置两个参数
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
Feign的超时时间配置优先级 > Ribbon的超时时间配置
2-Ribbon的自动请求重试
Ribbon在处理GET请求时默认最大重试次数为1,GET请求超时时自动重试一次(可能导致如短信重复发送的问题)
可通过设置 ribbon.MaxAutoRetriesNextServer=0 属性解决
3-HttpClient的默认最大请求数限制
使用 PoolingHttpClientConnectionManager 构造的HttpClient,默认 defaultMaxPerRoute=2,maxTotal=20,即
- 单一域名最大并发请求数为2
- 所有主机的最大并发连接数量为20
Feign中默认单一域名并发连接数为50,所有连接数为200
4-Java开发中的容量限制问题
- 操作系统/物理机/容器限制:带宽、文件句柄、内存、CPU等
- Web容器:最大连接、最大线程数
- 数据库连接池:最大连接数
- HttpClient:单一域名最大连接数、所有域名并发连接数
- 业务线程池
chap2.6:Spring声明式事务处理
排查事务处理时可能需要开启debug日志,如配置logging.level.org.springframework.orm.jpa=DEBUG为JPA开启debug日志
1-导致事务不生效的几种常见原因
- 在private方法上使用@Transactional声明事务
不生效原因:声明式事务通过切面在运行时注入代理类(通过CGLib实现动态织入),事务执行时通过调用代理类中的方法捕获异常进行事务回滚。调用 this.privateMethod 时调用的不是代理类的方法,所以事务不生效
解决方法:将private方法改为public,并且不要通过this.method调用自己;或使用AspectJ配置在编译时就静态织入切面(需要解决lombok的问题)
- 没有正确抛出异常
不生效原因:在@Transactional声明的事务方法中捕获异常但没有再次抛出,导致代理类感知不到异常发生。代理类默认只针对 RuntimeException 和 Error 类型的异常进行回滚,不会对受检异常(如IOException)进行回滚
2-事务传播
主子任务中如果需要控制异常回滚,需要设置@Transactional注解中的propagation属性
3-编程式事务控制
使用 TransactionTemplate
chap2.7:数据库索引
1-数据存储结构
- InnoDB引擎采用页而不是行的粒度来保存数据
- InnoDB会自动使用主键作为聚簇索引的索引键。聚簇索引的特点是索引和数据行物理存储顺序完全一致,叶子节点直接存储数据行本身,而不是指向数据行的指针
2-索引失效的情况
- 索引只能匹配列前缀(like name% 可以走索引,like %name不行)
- 条件涉及函数操作无法走索引(对索引进行运算)
- 联合索引只能匹配左边的列。(name和score建了联合索引,仅按score查询无法走索引)
3-数据库基于成本决定是否走索引
- IO成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的IO成本常数是1(也就是读取1个页成本是1)
- CPU成本,是检测数据是否满足条件和排序等CPU操作的成本。默认情况下,检测记录的成本是0.2
- MySQL 5.6及之后的版本中,可以使用optimizer trace功能查看优化器生成执行计划的整个过程
SET optimizer_trace="enabled=on";
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-01-24 05:00:00';
SELECT * FROM information_schema.OPTIMIZER_TRACE;
SET optimizer_trace="enabled=off";
chap2.8:判等问题
1-equals 和 ==
- 对象使用 equals 判等,基础类型使用 == 判等
- Integer 等包装类型存在缓存(享元模式),String 类型存在字符串常量池
- String字面量会存储在字符串常量池(StringTable)中,字符串常量池是一个哈希表,其bucket数量可能会影响字符串创建的效率。可通过
-XX:+PrintStringTableStatistic参数设置bucket大小
2-equals, hashCode 和 compareTo 需要配套实现
- equals 和 hashCode 方法的默认关系可见 Objects 类中的方法注释
Objects.hash()方法可快速重写 hashCode 方法- compareTo 的语义需要与 equals 和 hashCode 保持一致
3-Lombok
@Data注解包含了@EqualsAndHashCode注解,默认会实现equals和hashCode方法- 子类默认不会继承父类中通过
@Data注解自动生成的equals和hashCode方法 @EqualsAndHashCode注解默认会为所有非static、非transient的字段生成hashcode和equals,且不考虑父类属性@EqualsAndHashCode(callSuper = true)设置callSuper属性为true可以包含父类属性(调用父类的equals和hashCode)
chap2.9:精度问题
- BigDecimal中,scale表示小数点右边的位数,而precision表示精度,也就是有效数字的长度
- 不要使用Double类型创建BigDecimal对象,同样会损失精度;需要使用 Double.toString 转换成字符串
- BigDecimal的
equals()方法比较的是BigDecimal的value和scale,值相同但精度不同的数据使用equals进行比较时会返回false。如果希望只比较BigDecimal的value,可以使用compareTo方法 new BigDecimal("1.000").stripTrailingZeros())stripTrailingZeros可以去除末尾的0- 防止计算溢出:使用Math类的addExact、subtractExact等xxExact方法,出现溢出时会抛出ArithmeticException异常
- 防止Long转换溢出:BigInteger的longValueExact方法,转换溢出时会抛出ArithmeticException异常
chap2.10:List操作
1-Arrays.asList
- Arrays.asList得到的是Arrays的内部类ArrayList,
- 不能直接使用Arrays.asList来转换基本类型数组(如
int[]) - Arrays.asList返回的List不支持增删操作(返回的是Arrays的内部类)
- 对原始数组的修改会影响到我们获得的那个List
// Arrays.asList()
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}
// Arrays的内部类
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private static final long serialVersionUID = -2764017481108945198L;
private final E[] a;
// 构造方法用到了原数组,对原数组的操作会影响到返回的List
ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}
// ...
}
2-List.subList
- List.subList得到的是ArrayList的内部类SubList
- subList方法会保留原List的引用,导致原List无法被回收
- 修改子List中的元素会影响到原始List
- 修改原始List增加再遍历子List,会出现ConcurrentModificationException
public List<E> subList(int fromIndex, int toIndex) {
subListRangeCheck(fromIndex, toIndex, size);
return new SubList(this, 0, fromIndex, toIndex);
}
// ArrayList 的内部类 SubList
private class SubList extends AbstractList<E> implements RandomAccess {
private final AbstractList<E> parent;
private final int parentOffset;
private final int offset;
int size;
// SubList保留传入的parent的引用,使用 offset 和 index 进行索引
SubList(AbstractList<E> parent,
int offset, int fromIndex, int toIndex) {
this.parent = parent;
this.parentOffset = fromIndex;
this.offset = offset + fromIndex;
this.size = toIndex - fromIndex;
this.modCount = ArrayList.this.modCount;
}
// ...
}
3-数据结构的选择
- 使用HashMap替代List可以优化查询速度
- 除了对头尾节点进行增删操作外,LinkedList 的性能可能远不如 ArrayList
chap2.11:空值处理:null和空指针问题
1-常用的处理null值的方法
- Integer判空:
Optional.ofNullable.orElse - String和字面量的比较:
"字面量".equals(s)或使用Objects.equals() - 级联调用如
controller.service.method()可以使用Optional.ofNullable.map.ifPresent等处理 - 处理返回结果中可能为null的list:
Optional.ofNullable.orElse(Collections.emptyList()) - Map数据结构
| 数据结构 | key是否支持null值 | value是否支持null值 |
|---|---|---|
| HashMap | 支持 | 支持 |
| ConcurrentHashMap (线程安全) | 不支持 | 不支持 |
| HashTable (线程安全) | 不支持 | 不支持 |
| TreeMap | 不支持 (key要排序) | 支持 |
2-POJO中的null处理
- DTO和PO最好分开使用,DTO处理客户端传入值,PO只处理数据库中需要更改的字段
- DTO中的null可能有两种含义:1-用户希望赋值为null;2-用户不希望更改现有字段。可以在DTO中使用
Optional<String>进行处理:
@Data
public UserDTO {
Optional<String> name;
}
// 客户端未传name:Optional<String> 为null,不做更新
// 客户端将name赋值为null:Optional<String> 不为null,orElse中使用空字符串替换
if (userDto.getName() != null) {
userPo.setName(userDto.getName().orElse(""));
}
3-MySQL中null值字段的处理
- sum函数没统计到任何记录时,会返回null而不是0,可以使用
IFNULL(SUM(score), 0)把null转换为0 - count不统计null值,需要使用
COUNT(*)统计所有记录 - 判断字段为null时,要使用
IS NULL而不是 = null
4-不同ORM框架对动态sql的支持
- Hibernate中可以使用 @DynamicUpdate 实现字段的动态更新
- MyBatis支持动态sql:
<if test="xxx != null"> xxx = #{xxx} </if> - MyBatisPlus 可以通过 @TableField 中的 insertStrategy, updateStrategy, whereStrategy 属性实现
chap2.12:异常处理
classDiagram
class Error {
不应被捕获和处理
}
class Exception {
受检异常,需要显式try...catch或在方法签名中声明
}
class RuntimeException {
不需要显式try...catch或在方法签名中声明
}
Throwable <| -- Error : extends
Throwable <| -- Exception : extends
Exception <| -- RuntimeException : extends
1-异常处理规范
- 异常应该被log记录或重新抛出,如
try {
// ...
} catch (Exception e) {
// 1-完整记录异常消息、堆栈、类型等信息
log.error(e);
// 2-或重新抛出
Throw new RuntimeException("msg", e);
}
- 尽量在业务代码层处理异常,不要仅依靠框架实现异常捕获和处理
- Repo层的异常可以忽略或降级
- Service层往往涉及事务控制,不适合捕获异常
- Controller层在处理异常时需要向用户提供友好信息
2-finally中的异常
- 一个方法无法抛出两个异常,finally中的异常会覆盖try中的异常
- 可以在finally中使用try...catch捕获和处理异常
- 可以使用 addSuppressed 方法把finally中的异常附加到主异常上(try-with-resources中的做法)
3-JVML性能优化可能导致堆栈信息丢失
- JVM的C2编译器优化会导致异常的堆栈信息丢失
- 解决方法:1-使用 -XX:-OmitStackTraceInFastThrow
4-不能把异常定义为静态变量
@GetMapping("wrong")
public void wrong() {
try {
createOrderWrong();
} catch (Exception ex) {
log.error("createOrder got error", ex);
}
try {
// 取消订单时抛出的异常实际时创建订单异常时的异常
cancelOrderWrong();
} catch (Exception ex) {
log.error("cancelOrder got error", ex);
}
}
private void createOrderWrong() {
//这里有问题
throw Exceptions.ORDEREXISTS;
}
private void cancelOrderWrong() {
//这里有问题
throw Exceptions.ORDEREXISTS;
}
public class Exceptions {
public static BusinessException ORDEREXISTS = new BusinessException("订单已经存在", 3001);
}
4-线程池中的异常处理
-
以 execute 方式向线程池提交任务时,异常会导致线程退出,线程池中的线程得不到复用
-
以 submit 方式向线程池提交任务时,需要对返回的Future对象使用get方法获得执行结果或异常
-
以 execute 方式向线程池提交任务时,可以对异常进行如下处理:
- 1-在任务内部做好异常处理
- 2-定义线程池时指定异常处理逻辑
new ThreadFactoryBuilder()
.setNameFormat(prefix+"%d")
.setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
.get()
- Future的get方法:
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
chap2.13:日志记录
1-日志配置问题
- 以logback为例
- logger的继承关系导致日志记录重复。自定义的logger会继承root中配置的appender,可以设置自定义的looger中additivity属性为false,避免appender重复导致的日志重复记录
<loggers>
<logger name="logger_com.sankuai.rocs.cas.general.rebrush.log" level="info" additivity="false">
<appender-ref ref="AsyncScribeAppender_com.sankuai.rocs.cas.general.rebrush.log"/>
</logger>
<root level="info">
<appender-ref ref="infoAppender"/>
<appender-ref ref="warnAppender"/>
<appender-ref ref="errorAppender"/>
<appender-ref ref="catAppender"/>
<!--日志中心接入-->
<appender-ref ref="ScribeAsyncAppender"/>
</root>
</loggers>
- LevelFilter 和 ThresholdFilter 的使用
- ThresholdFilter中,如果日志级别大于等于配置的级别时返回NEUTRAL,继续调用过滤器链上下一个过滤器的方法,否则返回DENY直接拒绝记录日志
- LevelFilter中,如果日志级别匹配则返回onMatch属性,反之返回onMissmatch属性。所以在配置level属性后要相应的配置onMatch和onMissmatch方法属性
<appenders>
<!--只记录info级别日志-->
<XMDFile name="infoAppender" fileName="info.log" sizeBasedTriggeringSize="512M" rolloverMax="30">
<Filters>
<ThresholdFilter level="warn" onMatch="DENY" onMismatch="NEUTRAL"/>
<ThresholdFilter level="info" onMatch="ACCEPT" onMismatch="DENY"/>
</Filters>
</XMDFile>
</appenders>
- EvaluatorFilter
- 根据配置的marker属性为条件过滤需要记录的日志
<appenders>
<filter class="...filter.EvaluatorFilter">
<evaluator class="....OnMarkerEvaluator">
<marker>time</marker>
</evaluator>
<onMismatch>DENY</onMismatch>
<onMatch>ACCEPT</onMatch>
</filter>
</appenders>
使用:
Marker timeMarker = MarkerFactory.getMarker("time");
log.info(timeMarker, "test");
2-异步日志记录
- 使用 AsyncAppender 进行异步日志记录,几个关键参数:
-
queueSize:阻塞队列长度。默认值256过小,但设置过大可能会导致OOM
-
discardingThredshold:队列容量剩余空间小于设置值时,会丢弃新添加的任务
-
neverBlock:默认值为false,意味着提交日志记录时可能会发生阻塞(会通过put而不是offer方法向阻塞队列中添加任务)
-
3-占位符和日志级别判断
- 处理耗时较长的日志任务时,可以先判断当前日志级别,再进行日志记录,如:
if (log.isDebugEnabled()) {
log.debug("{}", slowMethod());
}
- 使用
Log4j2日志门面时,可以通过传入lambda表达式,将方法延迟到真正需要记录日志时再进行调用
log.debug("{}", () -> slowMethod());
4-MDC
- MDC: mapped diagnostic context,本质上是ThreadLocal
- 将MDC注册为Spring容器中的Filter后,可以在日志记录中直接使用MDC中的属性
chap2.14:文件IO
- 如果需要读写字符流,那么需要确保文件中字符的字符集和字符流的字符集是一致的,否则可能产生乱码。
- 使用Files类的一些流式处理操作,注意使用try-with-resources包装Stream,确保底层文件资源可以释放,避免产生too many open files的问题。
- 进行文件字节流操作的时候,一般情况下不考虑进行逐字节操作,使用缓冲区进行批量读写减少IO次数,性能会好很多。一般可以考虑直接使用缓冲输入输出流BufferedXXXStream,追求极限性能的话可以考虑使用FileChannel进行流转发。
chap2.15:序列化
Redis序列化与反序列化
- 序列化和反序列化需要确保算法一致。如RedisTemplate和StringRedisTemplate不能混用,因为前者默认使用JDK的序列化方式,而后者使用String的序列化方式
- 可以自定义RedisTemplate的Key和Value的序列化方式
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// key使用String序列化,value使用Jackson提供的JSON序列化
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.json());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
redisTemplate.setHashValueSerializer(RedisSerializer.json());
return redisTemplate;
}
MyBatisPlus读取List json字段
- MyBatis读取
List<T>时由于范型擦除的问题,会将List<T>读取为List<LinkedHashMap>,在进行类型转换时会报错 - 解决方案:提供自定义的TypeHandler
public class ListTypeHandler<T> extends BaseTypeHandler<List<T>> {
// ...
}
ObjectMapper
- 在开发Spring Web应用程序时,如果自定义了 ObjectMapper,并把它注册成了Bean,那很可能会导致Spring Web使用的ObjectMapper也被替换
- 不建议自定义ObjectMapper,可以修改默认ObjectMapper参数设置或定义Jackson2ObjectMapperBuilderCustomizer启动新特性
# 使用索引序列化枚举值
spring.jackson.serialization.write_enums_using_index=true
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer(){
// 使用索引序列化枚举值
return builder -> builder.featuresToEnable(SerializationFeature.WRITE_ENUMS_USING_INDEX);
}
反序列化时要小心类的构造方法
- 默认情况下,在反序列化的时候,Jackson框架只会调用无参构造方法创建对象
- 对于需要序列化的POJO考虑尽量不要自定义构造方法
枚举类不适合作为API接口参数
- 客户端和服务端的枚举定义不一致时,客户端反序列化时会出异常
- 枚举序列化反序列化实现自定义的字段非常麻烦
chap2.16:时间日期
@Test
public void testZonedDateTime() {
// 时间-字符串
String stringDate = "2020-01-02 22:00:00";
// 时区
ZoneId timeZoneShanghai = ZoneId.of("Asia/Shanghai");
// 日期格式
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 带有时区信息的时间 ZonedDateTime
ZonedDateTime zonedDateTime = ZonedDateTime.of(LocalDateTime.parse(stringDate, dateTimeFormatter), timeZoneShanghai);
//使用DateTimeFormatter格式化时间,可以通过withZone方法直接设置格式化使用的时区
DateTimeFormatter outputFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z");
// Asia/Shanghai 2020-01-02 22:00:00 +0800
System.out.println(timeZoneShanghai.getId() + " " + outputFormat.withZone(timeZoneShanghai).format(zonedDateTime));
}
@Test
public void testDateTimeFormatter() {
DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR).appendLiteral(" ")
.appendValue(ChronoField.MONTH_OF_YEAR).appendLiteral(" ")
.appendValue(ChronoField.DAY_OF_MONTH).appendLiteral(" ")
.appendValue(ChronoField.HOUR_OF_DAY).appendLiteral(":")
.appendValue(ChronoField.MINUTE_OF_HOUR)
.toFormatter();
LocalDateTime localDateTime = LocalDateTime.parse("2025 12 20 12:00", dateTimeFormatter);
System.out.println(localDateTime.format(dateTimeFormatter));
}
chap2.17:OOM问题
-
在进行容量评估时,我们不能认为一份数据在程序内存中也是一份。例如100M的数据加载到程序内存中,变为Java的数据结构就已经占用了200M堆内存;这些数据经过JDBC、MyBatis等框架其实是加载了2份,然后领域模型、DTO再进行转换可能又加载了2次;最终,占用的内存达到了200M*4=800M。
-
即使使用WeakHashMap,不释放key的引用也会出现OOM问题。如以User为key,UserProfile为value,value中包含了对key的引用,即使代码中没有直接使用key的引用,value中引用了key也会导致Entry对象不能被回收
-
Tomcat参数配置不合理导致OOM:Http11InputBuffer其中一个初始化方法会分配headerBufferSize+readBuffer大小的内存,其中readBuffer默认是8192字节,如果headerBufferSize参数调整至过大,有可能导致OOM问题(request header过大有可能导致默认的headerBufferSize不够,需要合理调整headerBufferSize参数,满足业务需求的同时避免OOM)
chap2.18:反射、范型、注解和OOP
- 反射获取类成员,需要注意getXXX和getDeclaredXXX方法的区别,其中XXX包括Methods、Fields、Constructors、Annotations。这两类方法,针对不同的成员类型XXX和对象,在实现上都有一些细节差异
- getDeclaredMethods方法无法获得父类定义的方法,而getMethods方法可以,只是差异之一
- 泛型因为类型擦除会导致泛型方法T占位符被替换为Object,子类如果使用具体类型覆盖父类实现,编译器会生成桥接方法。这样既满足子类方法重写父类方法的定义,又满足子类实现的方法有具体的类型。使用反射来获取方法清单时,需要特别注意这一点
Arrays.stream(child2.getClass().getDeclaredMethods())
.filter(method -> method.getName().equals("setValue") && !method.isBridge())
.findFirst().ifPresent(method -> {
try {
method.invoke(chi2, "test");
} catch (Exception e) {
e.printStackTrace();
}
});
- 自定义注解可以通过标记元注解@Inherited实现注解的继承,不过这只适用于类。如果要继承定义在接口或方法上的注解,可以使用Spring的工具类AnnotatedElementUtils
AnnotatedElementUtils.findMergedAnnotation(child.getClass().getMethod("foo");