0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSVダウンロードで500エラー・メモリ不足・低速化の対応

0
Posted at

はじめに

CSVダウンロード処理で以下のような問題が発生したので、原因と対応した内容を備忘録として残します。

  • CSVダウンロード時に ERR_INVALID_RESPONSE / 500エラーになる
  • 件数が多いとメモリ不足になる
  • ダウンロードに時間がかかる

最終的には、取得対象を絞る・1行ずつ出力する・UTF-8 BOM付きにする・gzip圧縮することで改善。

開発環境

  • PHP
  • PDO
  • MySQL
  • CSVダウンロード機能
  • ExcelでCSVを開く想定

500エラー / ERR_INVALID_RESPONSE

CSVダウンロード時に、 ERR_INVALID_RESPONSE のエラーあり。

原因は、phpファイルのの末尾にPHPの閉じタグ ?> があり、その後ろに改行が入っていたことでした。

<?php
// DB設定など

?>

?> の後ろにある改行が、意図せずレスポンスとして出力されていました。

CSVダウンロード処理では、以下のように header() でレスポンスヘッダーを設定する。

header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="export.csv"');

ただ、header() より前に空白や改行などの出力があると、正常にヘッダーを送れなくなります。

その結果、CSVのレスポンスが壊れて500エラーのようになっていました。

メモリ不足

次に、CSVの件数が多いと以下のエラーが出ました。

Allowed memory size exhausted

原因は、全年度・全件のデータを取得して、CSV全体を $csvstr に溜め込んでから出力していたことでした。

// 修正前
$csvstr = '';

while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    $csvstr .= implode(',', $row) . "\r\n";
}

echo mb_convert_encoding($csvstr, 'SJIS-win', 'UTF-8');

この書き方だと、件数が増えるほど $csvstr が大きくなり、最後に mb_convert_encoding() で一括変換していたので、変換前と変換後の文字列がメモリ上に存在して、メモリをかなり使っていました。

対応1:取得対象を絞る

まず、全件取得をやめて、当年度のデータだけ取得するようにしました。

$year = ((int)date('n') >= 4)
    ? (int)date('Y')
    : (int)date('Y') - 1;

SQLも当年度だけ取得するように修正しました。

SELECT *
FROM users
WHERE fiscal_year = :year
$stmt = $pdo->prepare(
    'SELECT * FROM users WHERE fiscal_year = :year'
);

$stmt->execute([
    ':year' => $year,
]);

不要なデータを取らないだけでも、かなり改善できました。

対応2:1行ずつ出力する

次に、CSV全体を1つの文字列に溜め込むのをやめました。

while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    echo implode(',', array_map('csvEscape', $row)) . "\r\n";
}

取得したデータをそのまま1行ずつ出力することで、CSV全体をメモリに持たなくてよくなりました。

大量データをCSV出力するときは、一度全部作ってから出力するより、取得しながら順番に出力する方がよさそうです。

ダウンロードが遅い

メモリ不足は解消しましたが、まだダウンロードに時間がかかっていました。

原因は主に以下の2つでした。

  • mb_convert_encoding() を毎行呼んでいた
  • 出力バッファなしで毎行 echo していた

mb_convert_encodingをやめる

元々はExcelで文字化けしないように、毎行SJISに変換していました。

echo mb_convert_encoding($line, 'SJIS-win', 'UTF-8');

ただ、件数が多いとこの変換処理がかなり重くなります。

今回は、SJIS変換をやめてUTF-8 BOM付きCSVにしました。

echo "\xEF\xBB\xBF";

レスポンスヘッダーもUTF-8にします。

header('Content-Type: text/csv; charset=UTF-8');

ExcelはUTF-8 BOM付きであればそのまま開けるので、文字コード変換が不要になりました。

gzip圧縮バッファを使う

1行ずつ echo する形にしたことでメモリ使用量は減りましたが、そのままだと細かい出力が大量に発生します。

そこで、ob_start('ob_gzhandler') を使ってgzip圧縮バッファを有効にしました。

ob_start('ob_gzhandler');

CSVはテキストなのでgzip圧縮と相性がよく、今回のケースでは以下のように転送サイズが減りました。

30MB → 約3〜5MB

転送量が減ったことで、ダウンロードもかなり速くなりました。

最終的なコード

最終的には以下のような形にしました。

<?php

// 1. 年度を計算
$year = ((int)date('n') >= 4)
    ? (int)date('Y')
    : (int)date('Y') - 1;

// 2. gzip圧縮バッファ開始
ob_start('ob_gzhandler');

// 3. CSV用ヘッダー
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="export.csv"');

// 4. UTF-8 BOMを出力
echo "\xEF\xBB\xBF";

// 5. 当年度のみ取得
$stmt = $pdo->prepare(
    'SELECT * FROM users WHERE fiscal_year = :year'
);

$stmt->execute([
    ':year' => $year,
]);

// 6. 1行ずつCSV出力
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    echo implode(',', array_map('csvEscape', $row)) . "\r\n";
}

ob_end_flush();

CSVエスケープ

CSVでは値にカンマやダブルクォート、改行が入る可能性があります。

そのため、単純に implode(',', $row) するだけだと崩れる可能性があります。

function csvEscape($value): string
{
    $value = (string)$value;

    if (
        str_contains($value, ',') ||
        str_contains($value, '"') ||
        str_contains($value, "\n") ||
        str_contains($value, "\r")
    ) {
        $value = '"' . str_replace('"', '""', $value) . '"';
    }

    return $value;
}

PHP7系などで str_contains() が使えない場合は、strpos() を使います。

function csvEscape($value): string
{
    $value = (string)$value;

    if (
        strpos($value, ',') !== false ||
        strpos($value, '"') !== false ||
        strpos($value, "\n") !== false ||
        strpos($value, "\r") !== false
    ) {
        $value = '"' . str_replace('"', '""', $value) . '"';
    }

    return $value;
}

まとめ

今回はCSVダウンロード処理で発生した、500エラー・メモリ不足・低速化に対応しました。
CSV出力は単純そうに見えますが、データ量が増えるとメモリや速度の問題が出やすいと感じました。

今後、大量データをCSVで出力する場合は、最初から「必要なデータだけ取得する」「全件をメモリに持たない」「不要な変換をしない」を意識して実装しようと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?