データベースに存在する 20 万件のデータ(350MB 弱)を取得して、 CSV ファイルでダウンロードする方法の技術検証。
取得したデータを List
とかに一旦全て格納してたら、ヒープメモリのサイズによっては簡単に OutOfMemory が発生する。
HttpServletResponse#getWriter()
で取得した Writer
にどんどん出力すれば OutOfMemory の発生は防げる気がするけど、本当にうまくいくのか検証した。
#環境
##OS
Windows7 64bit
##サーバー
Apache Tomcat 7.0.32
###ヒープ・サイズ
512MB
##Java
1.7.0_25
##データベース
- MySQL 5.5.28
- Oracle 11.2.0.1.0
###テーブル
MySQL
CREATE TABLE `TEST_TABLE` (
`ID` int(11) NOT NULL AUTO_INCREMENT,
`VALUE_1` varchar(256) DEFAULT NULL,
`VALUE_2` varchar(256) DEFAULT NULL,
`VALUE_3` varchar(256) DEFAULT NULL,
`VALUE_4` varchar(256) DEFAULT NULL,
`VALUE_5` varchar(256) DEFAULT NULL,
`VALUE_6` varchar(256) DEFAULT NULL,
`VALUE_7` varchar(256) DEFAULT NULL,
`VALUE_8` varchar(256) DEFAULT NULL,
`VALUE_9` varchar(256) DEFAULT NULL,
`VALUE_10` varchar(256) DEFAULT NULL,
`VALUE_11` varchar(256) DEFAULT NULL,
`VALUE_12` varchar(256) DEFAULT NULL,
`VALUE_13` varchar(256) DEFAULT NULL,
`VALUE_14` varchar(256) DEFAULT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
Oracle
CREATE TABLE "TEST_TABLE" (
"ID" NUMBER(20,0),
"VALUE_1" VARCHAR2(256 BYTE),
"VALUE_2" VARCHAR2(256 BYTE),
"VALUE_3" VARCHAR2(256 BYTE),
"VALUE_4" VARCHAR2(256 BYTE),
"VALUE_5" VARCHAR2(256 BYTE),
"VALUE_6" VARCHAR2(256 BYTE),
"VALUE_7" VARCHAR2(256 BYTE),
"VALUE_8" VARCHAR2(256 BYTE),
"VALUE_9" VARCHAR2(256 BYTE),
"VALUE_10" VARCHAR2(256 BYTE),
"VALUE_11" VARCHAR2(256 BYTE),
"VALUE_12" VARCHAR2(256 BYTE),
"VALUE_13" VARCHAR2(256 BYTE),
"VALUE_14" VARCHAR2(256 BYTE)
);
ALTER TABLE "TEST_TABLE" ADD PRIMARY KEY ("ID");
※各カラムにランダムな文字列を設定したレコードを 20 万件生成して登録。
#結論
確かに、 HttpServletResponse#getWriter()
で取得した PrintWriter
に順次出力していけば、 メモリの消費は抑えられる 。
しかし、使ってる DBMS (というか JDBC ドライバの実装)によっては、 それだけでは OutOfMemory を回避できない可能性がある 。
今回試した DBMS の場合、MySQL だとアウトだが、 Oracle ならセーフとなる。
##なぜ MySQL はアウト?
MySQL の JDBC ドライバは、デフォルトだと 全ての検索結果をメモリに格納する らしく、 PrintWriter
に順次書き込みをしてもメモリ消費は抑えられない。
MySQL を使う場合は、以下の方法でフェッチサイズを変える必要がある。
Statement stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
stmt.setFetchSize(Integer.MIN_VALUE);
公式リファレンスに書いてあった。
MySQL :: MySQL 5.1 リファレンスマニュアル :: 24.4.4.2 JDBC API 実装についての注記
デフォルトにより、ResultSets は 完全に摘出され、メモリに保存されます 。
(中略)
要求されるメモリのために JVM に十分なスペースを割り振れない場合は、ドライバに結果を 一行ごとにストリームし戻す よう指示することができます。
##Oracle は?
Oracle の JDBC ドライバは、デフォルトのフェッチサイズが 10 になっているらしく、大量データを検索してもメモリ上は 10 件ずつしか読み込まれない。
当然、検索結果の件数が多くなれば、その分 DB サーバとのアクセス回数も多くなる。
DB とのやり取りを減らしたい場合は、 Statement#setFetchSize(int)
メソッドでフェッチサイズを調整すればいい。
##つまり
大量データを DB から検索してきてダウンロードさせる場合は、
- 検索結果は
HttpServletResponse
から取得したPrintWriter
に順次出力する - 使用する DBMS が提供してる JDBC ドライバの実装のフェッチサイズを確認し、場合によってはフェッチサイズを調整する
必要がある。
#動作検証時のメモリ使用量
以下2つのパターンで、処理中のメモリ使用量を確認した。
※具体的な実装は後述。
bulk
ResultSet
から一旦検索結果を全て取得し List
に詰め、その後で PrintWriter
に書き出す。
sequential
ResultSet
から1行読むごとに PrintWriter
に書き出す。
##MySQL の場合
###bulk
bulk start
フェッチサイズ:0
[bulk クエリ実行前] 合計:491.50MB, 使用:119.23MB, 空き:372.27MB
[bulk クエリ実行後] 合計:491.50MB, 使用:421.16MB, 空き:70.34MB
[bulk 10000] 合計:491.50MB, 使用:457.30MB, 空き:34.20MB
Exception in thread "http-bio-8080-exec-3" java.lang.OutOfMemoryError: GC overhead limit exceeded
(後略)
クエリ実行前後でメモリが一気に消費されているのがわかる。
その後、更に検索結果を List
に詰めようとしてソッコーで OutOfMemory が発生した。
※ [bulk 10000]
の 10000
は ResultSet
からレコードの情報を取得して処理した件数。
###sequential
sequential start
フェッチサイズ:0
[sequential クエリ実行前] 合計:491.50MB, 使用:119.33MB, 空き:372.17MB
[sequential クエリ実行後] 合計:491.50MB, 使用:421.06MB, 空き:70.44MB
[sequential 10000] 合計:491.50MB, 使用:470.36MB, 空き:21.14MB
[sequential 20000] 合計:491.50MB, 使用:467.61MB, 空き:23.89MB
[sequential 30000] 合計:491.50MB, 使用:464.27MB, 空き:27.23MB
[sequential 40000] 合計:491.50MB, 使用:462.40MB, 空き:29.10MB
[sequential 50000] 合計:491.50MB, 使用:459.33MB, 空き:32.17MB
[sequential 60000] 合計:491.50MB, 使用:456.25MB, 空き:35.25MB
[sequential 70000] 合計:491.50MB, 使用:453.88MB, 空き:37.62MB
[sequential 80000] 合計:491.50MB, 使用:452.12MB, 空き:39.38MB
[sequential 90000] 合計:491.50MB, 使用:449.05MB, 空き:42.45MB
[sequential 100000] 合計:491.50MB, 使用:446.00MB, 空き:45.50MB
[sequential 110000] 合計:491.50MB, 使用:442.92MB, 空き:48.58MB
[sequential 120000] 合計:491.50MB, 使用:439.84MB, 空き:51.66MB
[sequential 130000] 合計:491.50MB, 使用:437.81MB, 空き:53.69MB
[sequential 140000] 合計:491.50MB, 使用:434.74MB, 空き:56.76MB
[sequential 150000] 合計:491.50MB, 使用:431.67MB, 空き:59.83MB
[sequential 160000] 合計:491.50MB, 使用:428.60MB, 空き:62.90MB
[sequential 170000] 合計:491.50MB, 使用:425.53MB, 空き:65.97MB
[sequential 180000] 合計:491.50MB, 使用:422.46MB, 空き:69.04MB
[sequential 190000] 合計:491.50MB, 使用:470.34MB, 空き:21.16MB
[sequential 200000] 合計:491.50MB, 使用:467.49MB, 空き:24.01MB
sequential end
一応、1リクエストならダウンロードが完了した。
しかし、 ResultSet
を操作している間は検索結果がメモリ上に確保されているため、メモリの空きが少ない状態が続いている。
この状態で別のリクエストが届くと、簡単に OutOfMemory が発生してしまう。
##Oracle の場合
###bulk
bulk start
フェッチサイズ:10
[bulk クエリ実行前] 合計:491.50MB, 使用:126.92MB, 空き:364.58MB
[bulk クエリ実行後] 合計:491.50MB, 使用:129.00MB, 空き:362.50MB
[bulk 10000] 合計:491.50MB, 使用:69.77MB, 空き:421.73MB
[bulk 20000] 合計:491.50MB, 使用:125.68MB, 空き:365.82MB
[bulk 30000] 合計:491.50MB, 使用:183.33MB, 空き:308.17MB
[bulk 40000] 合計:491.50MB, 使用:239.25MB, 空き:252.25MB
[bulk 50000] 合計:455.50MB, 使用:209.22MB, 空き:246.28MB
[bulk 60000] 合計:455.50MB, 使用:276.34MB, 空き:179.16MB
[bulk 70000] 合計:455.50MB, 使用:301.11MB, 空き:154.39MB
[bulk 80000] 合計:455.50MB, 使用:290.85MB, 空き:164.65MB
[bulk 90000] 合計:455.50MB, 使用:354.81MB, 空き:100.69MB
[bulk 100000] 合計:455.50MB, 使用:375.39MB, 空き:80.11MB
[bulk 110000] 合計:455.50MB, 使用:396.53MB, 空き:58.97MB
Exception in thread "http-bio-8080-exec-3" java.lang.OutOfMemoryError: GC overhead limit exceeded
(後略)
クエリ実行前後では、デフォルトの MySQL ほどは大きくメモリを消費していない。
しかし、その後の List
に詰める処理でどんどんがメモリ消費されていき、最後にはやはり OutOfMemory が発生した。
###sequential
sequential start
フェッチサイズ:10
[sequential クエリ実行前] 合計:491.50MB, 使用:126.93MB, 空き:364.57MB
[sequential クエリ実行後] 合計:491.50MB, 使用:129.00MB, 空き:362.50MB
[sequential 10000] 合計:491.50MB, 使用:37.15MB, 空き:454.35MB
[sequential 20000] 合計:491.50MB, 使用:59.67MB, 空き:431.83MB
[sequential 30000] 合計:491.50MB, 使用:81.81MB, 空き:409.69MB
[sequential 40000] 合計:481.00MB, 使用:106.66MB, 空き:374.34MB
[sequential 50000] 合計:491.00MB, 使用:130.32MB, 空き:360.68MB
[sequential 60000] 合計:488.50MB, 使用:28.64MB, 空き:459.86MB
[sequential 70000] 合計:489.00MB, 使用:55.97MB, 空き:433.03MB
[sequential 80000] 合計:489.00MB, 使用:83.55MB, 空き:405.45MB
[sequential 90000] 合計:489.00MB, 使用:110.83MB, 空き:378.17MB
[sequential 100000] 合計:490.00MB, 使用:137.09MB, 空き:352.91MB
[sequential 110000] 合計:491.00MB, 使用:39.65MB, 空き:451.35MB
[sequential 120000] 合計:490.50MB, 使用:66.05MB, 空き:424.45MB
[sequential 130000] 合計:493.00MB, 使用:90.34MB, 空き:402.66MB
[sequential 140000] 合計:492.00MB, 使用:111.29MB, 空き:380.71MB
[sequential 150000] 合計:494.50MB, 使用:134.99MB, 空き:359.51MB
[sequential 160000] 合計:496.50MB, 使用:17.24MB, 空き:479.26MB
[sequential 170000] 合計:495.50MB, 使用:33.73MB, 空き:461.77MB
[sequential 180000] 合計:498.00MB, 使用:47.32MB, 空き:450.68MB
[sequential 190000] 合計:497.50MB, 使用:57.29MB, 空き:440.21MB
[sequential 200000] 合計:500.00MB, 使用:67.19MB, 空き:432.81MB
sequential end
メモリの空きは常に余裕がある。見てて安心できる。
2,3個のリクエストを同時に送ってみたが、OutOfMemory は発生しなかった。
##MySQL(フェッチサイズ修正後)
MySQL で、前述したフェッチサイズの修正を施すと次のようになる。
sequential start
フェッチサイズ:-2147483648
[sequential クエリ実行前] 合計:491.50MB, 使用:124.35MB, 空き:367.15MB
[sequential クエリ実行後] 合計:491.50MB, 使用:124.35MB, 空き:367.15MB
[sequential 10000] 合計:491.50MB, 使用:80.44MB, 空き:411.06MB
[sequential 20000] 合計:491.50MB, 使用:25.61MB, 空き:465.89MB
[sequential 30000] 合計:491.50MB, 使用:95.72MB, 空き:395.78MB
[sequential 40000] 合計:472.50MB, 使用:37.76MB, 空き:434.74MB
[sequential 50000] 合計:490.00MB, 使用:109.89MB, 空き:380.11MB
[sequential 60000] 合計:490.50MB, 使用:55.47MB, 空き:435.03MB
[sequential 70000] 合計:490.50MB, 使用:132.41MB, 空き:358.09MB
[sequential 80000] 合計:491.00MB, 使用:77.06MB, 空き:413.94MB
[sequential 90000] 合計:492.00MB, 使用:20.33MB, 空き:471.67MB
[sequential 100000] 合計:494.00MB, 使用:90.06MB, 空き:403.94MB
[sequential 110000] 合計:495.50MB, 使用:26.32MB, 空き:469.18MB
[sequential 120000] 合計:495.00MB, 使用:89.50MB, 空き:405.50MB
[sequential 130000] 合計:496.50MB, 使用:15.47MB, 空き:481.03MB
[sequential 140000] 合計:499.00MB, 使用:76.00MB, 空き:423.00MB
[sequential 150000] 合計:498.00MB, 使用:133.54MB, 空き:364.46MB
[sequential 160000] 合計:500.00MB, 使用:45.21MB, 空き:454.79MB
[sequential 170000] 合計:502.00MB, 使用:101.61MB, 空き:400.39MB
[sequential 180000] 合計:501.50MB, 使用:157.79MB, 空き:343.71MB
[sequential 190000] 合計:502.50MB, 使用:55.73MB, 空き:446.77MB
[sequential 200000] 合計:504.50MB, 使用:106.23MB, 空き:398.27MB
sequential end
Oracle-sequential と同じように、メモリの空き容量に大きな余裕が生まれた。
#実装
##使用したサードパーティのライブラリ
- Apache Commons Lang 3
- Apache Commons DBCP 1.4
##サーバ起動時にデータソースを作成するリスナー
package test.download.listener;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;
import javax.sql.DataSource;
import org.apache.commons.dbcp.BasicDataSource;
import test.download.util.MemoryUtil;
@WebListener
public class ApplicationInitializer implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent event) {
MemoryUtil.printMemoryInfo("サーバ起動");
// DataSource ds = getOracleDatasource();
DataSource ds = getMySQLDatasource();
event.getServletContext().setAttribute("datasource", ds);
}
private DataSource getOracleDatasource() {
System.out.println("init datasource : oracle");
BasicDataSource ds = new BasicDataSource();
ds.setDriverClassName("oracle.jdbc.driver.OracleDriver");
ds.setUrl("jdbc:oracle:thin:@192.168.61.10:1521:test");
ds.setUsername("test");
ds.setPassword("test");
return ds;
}
private DataSource getMySQLDatasource() {
System.out.println("init datasource : mysql");
BasicDataSource ds = new BasicDataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost/test");
ds.setUsername("test");
ds.setPassword("test");
return ds;
}
@Override
public void contextDestroyed(ServletContextEvent arg0) {}
}
##サーブレット
###基底サーブレット
package test.download.servlet;
import java.io.IOException;
import java.io.PrintWriter;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.sql.DataSource;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import test.download.service.DatabaseAccess;
public abstract class AbstractCSVDownloadServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override
public void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException {
res.setContentType("application/octet-stream");
res.setHeader("Content-Disposition", "filename=" + createFileName());
DataSource ds = (DataSource)req.getServletContext().getAttribute("datasource");
DatabaseAccess databaseAccess = new DatabaseAccess(getName(), ds);
try (PrintWriter writer = res.getWriter()) {
download(databaseAccess, writer);
}
}
private String createFileName() {
String date = DateFormatUtils.format(new Date(), "yyyyMMdd_HHmmss");
return getName() + "_" + date + ".csv";
}
protected String toStringLine(ResultSet rs) throws SQLException {
List<String> list = new ArrayList<>();
list.add(String.valueOf(rs.getLong("ID")));
list.add(rs.getString("VALUE_1"));
list.add(rs.getString("VALUE_2"));
list.add(rs.getString("VALUE_3"));
list.add(rs.getString("VALUE_4"));
list.add(rs.getString("VALUE_5"));
list.add(rs.getString("VALUE_6"));
list.add(rs.getString("VALUE_7"));
list.add(rs.getString("VALUE_8"));
list.add(rs.getString("VALUE_9"));
list.add(rs.getString("VALUE_10"));
list.add(rs.getString("VALUE_11"));
list.add(rs.getString("VALUE_12"));
list.add(rs.getString("VALUE_13"));
list.add(rs.getString("VALUE_14"));
return StringUtils.join(list, ",");
}
abstract protected void download(DatabaseAccess databaseAccess, final PrintWriter writer);
abstract protected String getName();
}
###一旦 List
にデータを突っ込むロジックのサーブレット
package test.download.servlet;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.annotation.WebServlet;
import test.download.service.DatabaseAccess;
import test.download.service.DatabaseAccess.Processor;
import test.download.util.MemoryWatcher;
@WebServlet("/bulk")
public class BulkCSVDownloadServlet extends AbstractCSVDownloadServlet {
private static final long serialVersionUID = 1L;
@Override
protected String getName() {
return "bulk";
}
@Override
protected void download(DatabaseAccess databaseAccess, final PrintWriter writer) {
databaseAccess.execute(new Processor() {
@Override
public Statement getStatement(Connection con) throws SQLException {
return con.createStatement();
}
@Override
public void process(ResultSet rs) throws SQLException {
MemoryWatcher watcher = new MemoryWatcher(getName());
List<String> lineList = new ArrayList<>();
while (rs.next()) {
String line = toStringLine(rs);
lineList.add(line);
watcher.step();
}
for (String line : lineList) {
writer.println(line);
}
}
});
}
}
###順次 PrintWriter
に書き出すロジックのサーブレット
package test.download.servlet;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import javax.servlet.annotation.WebServlet;
import test.download.service.DatabaseAccess;
import test.download.service.DatabaseAccess.Processor;
import test.download.util.MemoryWatcher;
@WebServlet("/sequential")
public class SequentialCSVDownloadServlet extends AbstractCSVDownloadServlet {
private static final long serialVersionUID = 1L;
@Override
protected String getName() {
return "sequential";
}
@Override
protected void download(DatabaseAccess databaseAccess, final PrintWriter writer) {
databaseAccess.execute(new Processor() {
@Override
public Statement getStatement(Connection con) throws SQLException {
Statement stmt = con.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
stmt.setFetchSize(Integer.MIN_VALUE);
return stmt;
}
@Override
public void process(ResultSet rs) throws SQLException {
MemoryWatcher watcher = new MemoryWatcher(getName());
while (rs.next()) {
String line = toStringLine(rs);
writer.println(line);
watcher.step();
}
}
});
}
}
##データベースアクセス用クラス
package test.download.service;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
import test.download.util.MemoryUtil;
public class DatabaseAccess {
private String name;
private DataSource ds;
public DatabaseAccess(String name, DataSource ds) {
this.name = name;
this.ds = ds;
}
public void execute(Processor processor) {
System.out.println(name + " start");
try (Connection con = ds.getConnection();
Statement stmt = processor.getStatement(con)) {
System.out.println("フェッチサイズ:" + stmt.getFetchSize());
MemoryUtil.printMemoryInfo(name + " クエリ実行前");
stmt.execute("SELECT * FROM TEST_TABLE");
MemoryUtil.printMemoryInfo(name + " クエリ実行後");
ResultSet rs = stmt.getResultSet();
processor.process(rs);
} catch (SQLException e) {
e.printStackTrace();
}
System.out.println(name + " end");
}
public static interface Processor {
Statement getStatement(Connection con) throws SQLException;
void process(ResultSet rs) throws SQLException;
}
}
##メモリ使用量計測用のユーティリティ
###メモリ情報出力クラス
package test.download.util;
public class MemoryUtil {
public static void printMemoryInfo(Object tag) {
Runtime runtime = Runtime.getRuntime();
double total = runtime.totalMemory() / 1024.0 / 1024.0;
double free = runtime.freeMemory() / 1024.0 / 1024.0;
System.out.println(String.format("[%s] 合計:%.2fMB, 使用:%.2fMB, 空き:%.2fMB", tag, total, total-free, free));
}
}
###カウンターを内蔵したメモリ使用量計測用クラス
package test.download.util;
public class MemoryWatcher {
private String tag;
private int counter = 0;
private int interval = 10000;
public MemoryWatcher(String tag) {
this.tag = tag;
}
public MemoryWatcher(String tag, int interval) {
this(tag);
this.interval = interval;
}
public void step() {
counter++;
if (counter % interval == 0) {
MemoryUtil.printMemoryInfo(tag + " " + counter);
}
}
}
#参考