JDBC وقواعد البيانات

تجميع الاتصالات وإدارة الموارد

15 دقيقة الدرس 9 من 13

تجميع الاتصالات وإدارة الموارد

كل كائن Connection تُنشئه عبر DriverManager.getConnection() يفتح مقبس TCP فعليًا إلى خادم قاعدة البيانات، ويُجري المصادقة، ويُخصّص ذاكرة على كلا الطرفين. تكلّف هذه الرحلة الذهاب والإياب عشرات إلى مئات الميلي‌ثانية. في تطبيق ويب يستقبل مئات الطلبات المتزامنة، إنشاء اتصال جديد لكل استعلام هو أسرع طريقة لإنهاك خادم قاعدة البيانات.

يحلّ تجميع الاتصالات (Connection Pooling) هذه المشكلة بالاحتفاظ بمخزن من الاتصالات المفتوحة مسبقًا، تُسلَّم للطالب وتُعاد إليه حين ينتهي — دون إغلاق المقبس الفعلي. تتمثّل النتيجة في تأخير أقل بكثير وموارد أقل على جانب قاعدة البيانات.

لماذا لا يكفي DriverManager للتوسّع

تأمّل دورة حياة اتصال مفتوح بـ DriverManager:

  1. يستدعي كودك DriverManager.getConnection(url, user, pw).
  2. يفتح برنامج تشغيل JDBC مقبس TCP إلى قاعدة البيانات.
  3. تُجري قاعدة البيانات المصادقة (10–100 مللي‌ثانية).
  4. يُنفّذ كودك الاستعلام.
  5. يستدعي كودك connection.close() فيُغلق المقبس.

الخطوات 1–3 و5 عبء صرف يفوق زمن الاستعلام الفعلي للاستعلامات القصيرة. تحت الضغط تنفد خانات الاتصال المتاحة أيضًا (الافتراضي في PostgreSQL 100، وكثير من الاستضافات المشتركة تسمح بأقل من ذلك).

قاعدة عامة: في أي تطبيق يخدم أكثر من حفنة من المستخدمين المتزامنين، استخدم دائمًا تجميع الاتصالات. حتى المهمة الخلفية ذات الخيط الواحد تستفيد لأن تكلفة إنشاء المجمّع تُدفع مرة واحدة عند البدء وليس مرة لكل استعلام.

HikariCP — المجمّع القياسي الفعلي

HikariCP هو أسرع وأكثر مجمّعات اتصالات JDBC استخدامًا على JVM. يعتمده Spring Boot افتراضيًا. إضافته إلى مشروع Maven تتطلّب تبعية واحدة:

<dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.1.0</version> </dependency>

إنشاء المجمّع وضبطه عند بدء التطبيق:

import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; public class DataSourceFactory { public static DataSource createPool() { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb"); config.setUsername("app_user"); config.setPassword("secret"); // حجم المجمّع config.setMaximumPoolSize(10); // الحد الأقصى للاتصالات المفتوحة config.setMinimumIdle(2); // اتصالات خاملة جاهزة دائمًا // ضبط المهل الزمنية (بالميلي‌ثانية) config.setConnectionTimeout(3_000); // مدة الانتظار للحصول على اتصال من المجمّع config.setIdleTimeout(600_000); // مدة بقاء الاتصال الخامل config.setMaxLifetime(1_800_000); // إعادة تدوير قسرية للاتصالات بعد 30 دقيقة // التحقق من أن الاتصالات لا تزال حية قبل تسليمها config.setConnectionTestQuery("SELECT 1"); return new HikariDataSource(config); } }
حجم المجمّع: الأكبر ليس الأفضل دائمًا. الصيغة الكلاسيكية للأعمال المكثّفة معالجيًا هي 2 × عدد الأنوية + عدد الأقراص الفعّالة. لمعظم تطبيقات الويب النطاق المناسب هو 5–20 اتصالًا لكل عملية JVM. المجمّع الكبير جدًا يُهدر الذاكرة ويُسبّب تنازعًا على الأقفال في قاعدة البيانات.

استخدام المجمّع — نفس الواجهة البرمجية بدون احتكاك

من منظور المستدعي، لا شيء يتغيّر. تستدعي dataSource.getConnection() بدلًا من DriverManager.getConnection()، وتستدعي connection.close() عند الانتهاء — لكن close() الآن تُعيد الاتصال إلى المجمّع بدلًا من تدميره.

import javax.sql.DataSource; import java.sql.*; public class UserRepository { private final DataSource dataSource; public UserRepository(DataSource dataSource) { this.dataSource = dataSource; } public String findUsernameById(int id) throws SQLException { String sql = "SELECT username FROM users WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getString("username"); } return null; } } // conn.close() هنا تُعيد الاتصال إلى المجمّع فقط } }

try-with-resources — الإغلاق الصحيح لكل شيء

يستخدم JDBC ثلاثة موارد من نوع AutoCloseable: Connection، وStatement / PreparedStatement، وResultSet. الإخفاق في إغلاق أي منها يُسرّب المعالج الأساسي للنظام، وبالنسبة لـ Connection يُجفّف المجمّع.

عبارة try-with-resources المتاحة منذ Java 7 تضمن استدعاء close() على كل مورد معلَن بـترتيب عكسي للإعلان، حتى لو رُمي استثناء:

// صحيح — تُغلَق جميع الموارد الثلاثة تلقائيًا // الأفضل: تقسيم ResultSet إلى try داخلي لإتاحة تعيين المعاملات أولًا try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM products WHERE category = ?")) { ps.setString(1, "electronics"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { System.out.println(rs.getString("name")); } } // rs يُغلق هنا } // ps ثم conn يُغلقان هنا (بترتيب عكسي)
لا تدمج try-with-resources باستهتار. الخطأ الشائع هو إعلان ResultSet مع Connection في رأس try واحد عندما تحتاج أولًا إلى استدعاء setters على الـ statement. قسّم إلى try خارجي لـ Connection وPreparedStatement، وtry داخلي لـ ResultSet.

ماذا يحدث عند نفاد المجمّع

إذا كانت جميع الاتصالات مُخصَّصة وجاء طلب جديد، يحجب dataSource.getConnection() حتى connectionTimeout ميلي‌ثانية ثم يرمي SQLException. هذا متعمَّد: يُنشئ ضغطًا عكسيًا يمنع تطبيقك من وضع آلاف الطلبات في الصف وتحطيم قاعدة البيانات بصمت.

// تعامل دائمًا مع استنفاد المجمّع بلطف في كود الإنتاج try (Connection conn = dataSource.getConnection()) { // ... } catch (SQLTimeoutException e) { // سجّل الخطأ وأعِد HTTP 503 Service Unavailable throw new ServiceUnavailableException("Database pool exhausted", e); } catch (SQLException e) { throw new DataAccessException("Query failed", e); }

دورة الحياة: مجمّع واحد للتطبيق مشترَك بين الخيوط

HikariDataSource آمن للخيوط (thread-safe). أنشئه مرة واحدة — في حقل ثابت، أو singleton، أو حاوية حقن التبعيات — وشاركه في كل مكان. إنشاء HikariDataSource جديد لكل طلب يُلغي كل فوائد التجميع.

// نقطة دخول التطبيق — إنشاء المجمّع مرة واحدة public class App { private static final DataSource DATA_SOURCE = DataSourceFactory.createPool(); public static void main(String[] args) { UserRepository repo = new UserRepository(DATA_SOURCE); System.out.println(repo.findUsernameById(1)); // عند الإيقاف، أغلق المجمّع لتحرير جميع الاتصالات ((HikariDataSource) DATA_SOURCE).close(); } }
مستخدمو Spring Boot: إذا أعلنت حبّة DataSource (أو اعتمدت على الإعداد التلقائي بخصائص spring.datasource.*)، يُنشئ Spring مجمّع HikariCP ويُديره نيابةً عنك. ما عليك سوى حقن DataSource واستدعاء getConnection(). يُغلق المجمّع تلقائيًا عند إيقاف التطبيق.

الخلاصة

تُعيد استخدام تجميعات الاتصالات الاتصالات الفعلية بقاعدة البيانات، مختزلةً وقت الاتصال من ميلي‌ثانية لكل استعلام إلى ميكرو‌ثانية. HikariCP هو الخيار القياسي: اضبطه مرة واحدة عند البدء، واجعل maximumPoolSize محافظًا، واستخدم دائمًا try-with-resources لتُعاد الاتصالات والعبارات إلى المجمّع فور انتهاء استخدامها. الاتصال المُسرَّب الذي لا يُعاد أبدًا سيُجفّف المجمّع تحت الضغط فيُفشل جميع الطلبات اللاحقة — وعبارة try-with-resources تجعل هذا الصنف من الأخطاء مستحيلًا.