本章涵蓋 HTTP 呼叫、文件 IO、資源管理等 IO 與網路相關的常見陷阱。這些問題往往在生產環境才會暴露,需要特別注意。
文件 IO 陷阱#
字符編碼陷阱#
問題現象#
使用 FileReader 讀取文件,在不同環境下讀取結果不一致。
原因分析#
FileReader使用系統默認字符集,不同操作系統、不同 JVM 組態的默認字符集可能不同。
// 錯誤示範:使用 FileReader,依賴系統默認編碼
char[] chars = new char[10];
String content;
try (FileReader fileReader = new FileReader("hello.txt")) {
int count;
while ((count = fileReader.read(chars)) != -1) {
content = new String(chars, 0, count);
}
}正確做法#
// 正確示範:顯式指定字符編碼
char[] chars = new char[10];
String content;
try (InputStreamReader isr = new InputStreamReader(
new FileInputStream("hello.txt"), StandardCharsets.UTF_8)) {
int count;
while ((count = isr.read(chars)) != -1) {
content = new String(chars, 0, count);
}
}
// 或使用 Files.readString (Java 11+)
String content = Files.readString(Paths.get("hello.txt"), StandardCharsets.UTF_8);文件讀寫原則:永遠顯式指定字符編碼,不要依賴系統默認編碼。
Files.lines 資源洩漏陷阱#
問題現象#
使用 Files.lines 讀取大文件,長時間執行後出現 too many open files 錯誤。
原因分析#
Files.lines回傳的Stream持有文件句柄,如果不關閉 Stream,會導致資源洩漏。
// 錯誤示範:Stream 未關閉
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -> {
try {
// 每次循環都打開一個 Stream,但沒有關閉
Files.lines(Paths.get("demo.txt")).forEach(line -> longAdder.increment());
} catch (IOException e) {
e.printStackTrace();
}
});
// 最終拋出 too many open files 例外正確做法#
// 正確示範:使用 try-with-resources 確保 Stream 關閉
LongAdder longAdder = new LongAdder();
IntStream.rangeClosed(1, 1000000).forEach(i -> {
try (Stream<String> lines = Files.lines(Paths.get("demo.txt"))) {
lines.forEach(line -> longAdder.increment());
} catch (IOException e) {
e.printStackTrace();
}
});
Files.lines、Files.list、Files.walk等方法回傳的 Stream 都需要使用 try-with-resources 確保關閉。
文件讀寫緩衝區陷阱#
問題現象#
逐字節讀寫大文件,效能極差。
原因分析#
每次讀寫操作都會觸發一次系統呼叫,系統呼叫的開銷遠大於內存操作。
// 錯誤示範:逐字節讀寫
private static void perByteOperation() throws IOException {
try (FileInputStream fis = new FileInputStream("src.txt");
FileOutputStream fos = new FileOutputStream("dest.txt")) {
int b;
while ((b = fis.read()) != -1) {
fos.write(b);
}
}
}
// 讀寫 35MB 文件耗時約 190 秒正確做法#
使用緩衝區批量讀寫
// 方案一:自定義緩衝區
private static void bufferOperationWith100KBuffer() throws IOException {
try (FileInputStream fis = new FileInputStream("src.txt");
FileOutputStream fos = new FileOutputStream("dest.txt")) {
byte[] buffer = new byte[100 * 1024]; // 100KB 緩衝區
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
}
}
// 讀寫 35MB 文件耗時約 0.5 秒
// 方案二:使用 BufferedInputStream/BufferedOutputStream
private static void bufferedStreamByteOperation() throws IOException {
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("src.txt"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("dest.txt"))) {
int b;
while ((b = bis.read()) != -1) {
bos.write(b);
}
}
}
// 讀寫 35MB 文件耗時約 26 秒(比無緩衝快 7 倍,但不如自定義大緩衝區)
// 方案三:使用 Files.copy(推薦)
private static void filesCopyOperation() throws IOException {
Files.copy(Paths.get("src.txt"), Paths.get("dest.txt"), StandardCopyOption.REPLACE_EXISTING);
}
// 使用零拷貝技術,效能最佳效能比較#
| 方式 | 35MB 文件耗時 |
|---|---|
| 逐字節讀寫 | ~190 秒 |
| BufferedStream 逐字節 | ~26 秒 |
| 自定義 100KB 緩衝區 | ~0.5 秒 |
| Files.copy | ~0.3 秒 |
文件複製首選
Files.copy,它內部使用零拷貝技術,效能最優。
HTTP 呼叫陷阱#
連線逾時與讀取逾時#
問題現象#
HTTP 呼叫沒有設置逾時,導致線程長時間阻塞。
原因分析#
HTTP 呼叫涉及兩種逾時:
- 連線逾時 (Connection Timeout):建立 TCP 連線的逾時時間
- 讀取逾時 (Read/Socket Timeout):等待服務器回傳資料的逾時時間
// 錯誤示範:使用 Feign 默認組態,沒有設置逾時
@FeignClient(name = "user-service")
public interface UserServiceClient {
@GetMapping("/users/{id}")
User getUser(@PathVariable Long id);
}
// Feign 默認連線逾時 10 秒,讀取逾時 60 秒,可能太長正確做法#
// 方案一:Feign 組態逾時
@Configuration
public class FeignConfig {
@Bean
public Request.Options options() {
return new Request.Options(
5, TimeUnit.SECONDS, // 連線逾時 5 秒
10, TimeUnit.SECONDS, // 讀取逾時 10 秒
true // 重定向跟隨
);
}
}
// 方案二:RestTemplate 組態逾時
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
return new RestTemplate(factory);
}
// 方案三:OkHttp 組態逾時
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build();HTTP 逾時原則:
- 所有 HTTP 呼叫必須設置逾時
- 連線逾時通常設置 3-5 秒
- 讀取逾時根據下游服務 SLA 設置,預留緩衝
HTTP 連線池組態陷阱#
問題現象#
高並行場景下,HTTP 呼叫延遲很高,甚至出現連線逾時。
原因分析#
HTTP 連線池組態不當可能導致:
- 連線數不足,請求排隊等待
- 連線數過多,佔用大量資源
- 連線洩漏,可用連線逐漸減少
// 錯誤示範:默認連線池組態可能不適合高並行
HttpClient client = HttpClient.newBuilder().build();
// 默認連線池大小較小正確做法#
// Apache HttpClient 連線池組態
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(200); // 連線池最大連線數
connectionManager.setDefaultMaxPerRoute(50); // 每個目標主機的最大連線數
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.evictExpiredConnections() // 定期清理過期連線
.evictIdleConnections(30, TimeUnit.SECONDS) // 清理空閒連線
.build();連線池組態建議:
MaxTotal根據並行量設置,一般 100-500DefaultMaxPerRoute根據目標服務器數量均分- 開啟空閒連線清理,避免使用失效連線
HTTP 重試策略陷阱#
問題現象#
網路抖動導致 HTTP 呼叫失敗,但沒有重試機制,直接回傳錯誤給用戶。
錯誤做法#
// 錯誤示範一:無腦重試所有請求
@Retryable(maxAttempts = 3)
public void createOrder(Order order) {
// POST 請求可能導致重複創建訂單!
restTemplate.postForObject("/orders", order, Order.class);
}
// 錯誤示範二:固定間隔重試
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public User getUser(Long id) {
// 固定 1 秒間隔,可能造成驚群效應
return restTemplate.getForObject("/users/" + id, User.class);
}正確做法#
// 正確示範:只重試冪等請求,使用指數退避
@Retryable(
value = {ResourceAccessException.class, HttpServerErrorException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000) // 指數退避
)
public User getUser(Long id) {
return restTemplate.getForObject("/users/" + id, User.class);
}
@Recover
public User getUserFallback(Exception e, Long id) {
log.error("獲取用戶失敗,id={}", id, e);
return null; // 或回傳默認值
}重試策略原則:
- 只對冪等操作(GET、DELETE)進行重試
- 使用指數退避(Exponential Backoff)避免驚群效應
- 設置最大重試次數和最大延遲時間
- 對非冪等操作(POST、PUT)謹慎重試,可能需要業務層去重
資源管理陷阱#
try-with-resources 使用陷阱#
問題現象#
使用 try-with-resources 但資源仍然洩漏。
// 錯誤示範:內部資源無法自動關閉
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("data.bin"))) {
// 如果 ObjectInputStream 構造失敗,FileInputStream 不會關閉
}正確做法#
// 正確示範:每個資源單獨聲明
try (FileInputStream fis = new FileInputStream("data.bin");
ObjectInputStream ois = new ObjectInputStream(fis)) {
// 兩個資源都會被正確關閉
}連線洩漏陷阱#
問題現象#
資料庫連線池或 HTTP 連線池的連線數持續增長,最終耗盡。
常見原因#
連線洩漏的常見原因:
- 獲取連線後發生例外,沒有在 finally 中歸還連線
- 使用
getConnection()但忘記呼叫close()- HTTP Response 的 InputStream 未關閉
// 錯誤示範:例外時連線洩漏
public void queryData() {
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM users");
// 如果這裡拋例外,連線不會關閉
rs.close();
stmt.close();
conn.close();
}正確做法#
// 正確示範:使用 try-with-resources
public void queryData() {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM users")) {
// 處理結果
} catch (SQLException e) {
// 例外處理
}
// 資源會自動關閉
}序列化陷阱#
Redis 序列化不一致#
問題現象#
不同服務使用不同的 RedisTemplate 組態,導致無法讀取彼此寫入的資料。
原因分析#
Spring 的
RedisTemplate和StringRedisTemplate使用不同的序列化器:
RedisTemplate:默認使用 JDK 序列化StringRedisTemplate:使用 String 序列化
// 錯誤示範:混用不同的 Template
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 用 RedisTemplate 寫入
redisTemplate.opsForValue().set("key", "value");
// 用 StringRedisTemplate 讀取 -> 回傳 null
String value = stringRedisTemplate.opsForValue().get("key");正確做法#
// 統一使用 StringRedisTemplate
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
// 或自定義序列化組態
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 使用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value 使用 JSON 序列化
Jackson2JsonRedisSerializer<Object> serializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(om.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(om);
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
return template;
}Jackson 反序列化陷阱#
問題現象#
物件序列化後反序列化,部分字段值丟失或例外。
常見問題#
問題一:枚舉新增值導致反序列化失敗
// 假設原本枚舉只有 ACTIVE, INACTIVE
enum Status { ACTIVE, INACTIVE, DELETED } // 新增 DELETED
// 反序列化舊資料或其他系統傳來的 DELETED 可能失敗
ObjectMapper om = new ObjectMapper();
om.readValue("\"DELETED\"", Status.class); // 如果舊版本不認識 DELETED 會報錯
// 解決方案:組態 UNKNOWN 值處理
om.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);問題二:無默認構造函式導致反序列化失敗
// 錯誤:沒有默認構造函式
public class User {
private final String name;
public User(String name) {
this.name = name;
}
}
// 解決方案:使用 @JsonCreator
public class User {
private final String name;
@JsonCreator
public User(@JsonProperty("name") String name) {
this.name = name;
}
}問題三:@Transient 或 static 字段被意外序列化
// 某些框架的 @Transient 不被 Jackson 識別
@Entity
public class User {
@javax.persistence.Transient // JPA 的 @Transient,Jackson 不認識
private String tempData;
}
// 解決方案:使用 Jackson 的註解
public class User {
@JsonIgnore
private String tempData;
}總結:IO 與網路問題檢查清單#
| 檢查項目 | 正確做法 |
|---|---|
| 文件編碼 | 顯式指定 UTF-8,不依賴系統默認 |
| Files.lines | 使用 try-with-resources 確保關閉 |
| 文件讀寫 | 使用緩衝區或 Files.copy |
| HTTP 逾時 | 所有呼叫必須設置連線逾時和讀取逾時 |
| HTTP 連線池 | 根據並行量組態,開啟空閒連線清理 |
| HTTP 重試 | 只重試冪等操作,使用指數退避 |
| 資源管理 | 使用 try-with-resources,每個資源單獨聲明 |
| Redis 序列化 | 統一序列化器組態,推薦 JSON |
| Jackson | 組態 UNKNOWN 枚舉處理,使用 @JsonCreator |