المعالجة الدفعية
كل استدعاء JDBC تُجريه يسافر عبر مقبس شبكي إلى خادم قاعدة البيانات، يُحلَّل ثم يُخطَّط ثم يُنفَّذ، وبعدها تُعاد النتيجة. عندما تحتاج إلى إدراج آلاف الصفوف أو تحديثها، فإن دفع هذه التكلفة لكل صف على حدة يكون بطيئًا بشكل فادح. تحل المعالجة الدفعية هذه المشكلة: تضع عدة عبارات SQL في قائمة انتظار على جانب العميل ثم ترسلها إلى الخادم في رسالة شبكية واحدة، مما يتيح لقاعدة البيانات تنفيذها كمجموعة.
مشكلة التنفيذ صفًا بصف
تخيّل إدراج 10,000 سجل تدقيق واحدًا تلو الآخر:
// نمط مضاد: رحلة شبكية لكل إدراج
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO audit_log (event, ts) VALUES (?, ?)")) {
for (AuditEvent e : events) { // 10,000 تكرار
ps.setString(1, e.event());
ps.setTimestamp(2, Timestamp.from(e.ts()));
ps.executeUpdate(); // رحلة شبكية في كل مرة
}
}
على قاعدة بيانات محلية قد يستغرق هذا بضع ثوانٍ. عبر شبكة حقيقية (حتى داخل مركز بيانات واحد) قد يستغرق دقائق. تضغط المعالجة الدفعية تلك العشرة آلاف رحلة إلى حفنة من الرحلات فقط.
addBatch و executeBatch
يوفر JDBC طريقتين على كل من Statement و PreparedStatement:
addBatch() — تُرحِّل مجموعة المعاملات الحالية (أو نص SQL كامل على Statement) دون إرسال أي شيء.
executeBatch() — ترسل جميع الأوامر المرحَّلة إلى الخادم في استدعاء واحد وتُعيد مصفوفة int[] من أعداد التحديثات، واحد لكل أمر.
import java.sql.*;
import java.time.Instant;
import java.util.List;
public class BatchInsertDemo {
record AuditEvent(String event, Instant ts) {}
public static void insertAuditLogs(Connection conn, List<AuditEvent> events)
throws SQLException {
final String sql = "INSERT INTO audit_log (event, ts) VALUES (?, ?)";
// أوقف الإيداع التلقائي لتصبح الدفعة كلها معاملة واحدة
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (AuditEvent e : events) {
ps.setString(1, e.event());
ps.setTimestamp(2, Timestamp.from(e.ts()));
ps.addBatch(); // ترحيل — لا رحلة شبكية بعد
}
int[] counts = ps.executeBatch(); // رحلة شبكية واحدة لكل الصفوف
conn.commit();
System.out.println("الصفوف المتأثرة: " + sumCounts(counts));
} catch (BatchUpdateException ex) {
conn.rollback();
// ex.getUpdateCounts() يُخبرك بالأوامر التي نجحت قبل الفشل
throw ex;
} catch (SQLException ex) {
conn.rollback();
throw ex;
} finally {
conn.setAutoCommit(true);
}
}
private static int sumCounts(int[] counts) {
int total = 0;
for (int c : counts) {
if (c >= 0) total += c; // SUCCESS_NO_INFO == -2، تجاهله
}
return total;
}
}
أوقف دائمًا الإيداع التلقائي قبل الدفعة. مع تفعيل الإيداع التلقائي قد يُودِع المشغّل بعد كل استدعاء addBatch (السلوك يعتمد على المشغّل)، وقد تترك الدفعة المطبَّقة جزئيًا البيانات في حالة غير متسقة. تغليف الدفعة بأكملها في معاملة صريحة واحدة هو النهج الآمن الوحيد.
تقطيع الدفعات الكبيرة
ترحيل مئات الآلاف من الصفوف قبل استدعاء executeBatch() واحد يستهلك ذاكرة كبيرة على جانب العميل وينتج معاملة قاعدة بيانات ضخمة جدًا. النمط القياسي هو التفريغ عند حجم قطعة محدد:
public static void insertInChunks(Connection conn, List<AuditEvent> events,
int chunkSize) throws SQLException {
final String sql = "INSERT INTO audit_log (event, ts) VALUES (?, ?)";
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(sql)) {
int i = 0;
for (AuditEvent e : events) {
ps.setString(1, e.event());
ps.setTimestamp(2, Timestamp.from(e.ts()));
ps.addBatch();
if (++i % chunkSize == 0) {
ps.executeBatch(); // فرّغ هذه القطعة
conn.commit();
ps.clearBatch(); // اختياري — معظم المشغّلات تُصفّي تلقائيًا
}
}
// فرّغ أي بقية
ps.executeBatch();
conn.commit();
} catch (SQLException ex) {
conn.rollback();
throw ex;
} finally {
conn.setAutoCommit(true);
}
}
اختيار حجم القطعة: تفريغ كل 500–1,000 صف نقطة بداية شائعة. صغير جدًا ويُفقد فائدة التجميع، كبير جدًا وتُخاطر بضغط الذاكرة ومدد قفل طويلة. قِس على بيانات الحجم الحقيقي وزمن استجابة الشبكة لإيجاد النقطة المثلى.
تفسير القيمة المُعادة int[]
تُعيد executeBatch() عدد صحيح واحد لكل أمر مرحَّل. يمكن أن تكون القيمة:
- عدد موجب — عدد التحديثات الدقيق لذلك الأمر.
Statement.SUCCESS_NO_INFO (-2) — نجح الأمر لكن المشغّل لا يستطيع الإبلاغ عن عدد الصفوف المتغيرة (شائع مع MySQL/MariaDB في وضع الدفعات).
Statement.EXECUTE_FAILED (-3) — فشل الأمر (ممكن فقط حين لا يُطلق المشغّل BatchUpdateException فورًا).
int[] counts = ps.executeBatch();
for (int idx = 0; idx < counts.length; idx++) {
switch (counts[idx]) {
case Statement.SUCCESS_NO_INFO ->
System.out.printf("الأمر %d: نجح (العدد غير معروف)%n", idx);
case Statement.EXECUTE_FAILED ->
System.out.printf("الأمر %d: فشل%n", idx);
default ->
System.out.printf("الأمر %d: %d صف متأثر%n", idx, counts[idx]);
}
}
BatchUpdateException — فشل جزئي
إذا فشل أحد الأوامر في الدفعة، يُطلق المشغّل BatchUpdateException (وهي صنف فرعي من SQLException). والأهم أن بعض الأوامر ربما نُفِّذت بالفعل قبل الفشل. استدعِ ex.getUpdateCounts() لمعرفة أيها نجح:
} catch (BatchUpdateException ex) {
conn.rollback(); // تراجع عن كل ما نُفِّذ
int[] partial = ex.getUpdateCounts();
for (int idx = 0; idx < partial.length; idx++) {
if (partial[idx] == Statement.EXECUTE_FAILED) {
System.err.println("فشل عند الفهرس " + idx);
}
}
throw ex;
}
سلوك المشغّل يتفاوت. بعض المشغّلات (كـ PostgreSQL JDBC) تُلغي الدفعة بأكملها عند أول فشل وتتراجع ضمنيًا. وأخرى (كـ MySQL Connector/J مع continueBatchOnError=true) تواصل وتُعلّم الإدخالات الفاشلة بـ EXECUTE_FAILED. اقرأ دائمًا توثيق مشغّلك وغلِّف كل دفعة في معاملة صريحة لتكون مسار التراجع واضحًا بصرف النظر عن سلوك المشغّل.
التجميع مع Statement (SQL ديناميكي)
يمكنك أيضًا تجميع نصوص SQL عشوائية على كائن Statement عادي. نادرًا ما يكون هذا الاختيار الصحيح — إذ يتجاوز المعاملات ويفتح الباب لحقن SQL — لكنه مفيد للسكربتات لمرة واحدة أو مهام ترحيل البيانات حيث يُولَّد SQL من مصادر داخلية موثوقة:
try (Statement st = conn.createStatement()) {
conn.setAutoCommit(false);
st.addBatch("UPDATE products SET archived = true WHERE created_at < '2020-01-01'");
st.addBatch("DELETE FROM sessions WHERE expires_at < NOW()");
st.addBatch("UPDATE counters SET value = 0 WHERE name = 'daily_hits'");
int[] counts = st.executeBatch();
conn.commit();
}
فضّل PreparedStatement للتجميع كلما كان شكل SQL ثابتًا وتتغير البيانات فقط. يمنع حقن SQL، ويسمح لقاعدة البيانات بإعادة استخدام خطة التنفيذ لجميع الصفوف في الدفعة، وهو أسرع عادةً من إعادة تحليل نص عبارة لكل صف.
الأداء عمليًا
التحسين الناجم عن التجميع هائل. فيما يلي معايير مرجعية نموذجية عند إدراج 50,000 صف عبر اتصال JDBC محلي:
- صف بصف مع
executeUpdate(): نحو 12 ثانية
- دفعات بحجم قطعة 500: نحو 0.8 ثانية
- دفعات بحجم قطعة 2,000: نحو 0.5 ثانية
الأرقام الفعلية تعتمد على زمن استجابة الشبكة وحمل قاعدة البيانات وحجم الصف والمشغّل. أما التحسن النسبي فمتسق: التجميع يُحقق دائمًا تسريعًا بمقدار 10–50 مرة في أحمال الكتابة الكبيرة.
الخلاصة
استخدم addBatch() لترحيل مجموعات المعاملات دون إرسالها، ثم فرّغ العمل المتراكم بـ executeBatch(). غلِّف العملية بأكملها في معاملة صريحة واحدة وتعامل دائمًا مع BatchUpdateException مع تراجع. لمجموعات البيانات الكبيرة، قطِّع الدفعة إلى مجموعات ذات حجم ثابت للتحكم في استخدام الذاكرة ومدة القفل. فضِّل PreparedStatement على تجميع Statement للحفاظ على معاملة SQL وإتاحة إعادة استخدام خطة التنفيذ لقاعدة البيانات.