本章涵蓋 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.linesFiles.listFiles.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-500
  • DefaultMaxPerRoute 根據目標服務器數量均分
  • 開啟空閒連線清理,避免使用失效連線

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 連線池的連線數持續增長,最終耗盡。

常見原因#

連線洩漏的常見原因:

  1. 獲取連線後發生例外,沒有在 finally 中歸還連線
  2. 使用 getConnection() 但忘記呼叫 close()
  3. 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 的 RedisTemplateStringRedisTemplate 使用不同的序列化器:

  • 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