JDBC ونمط DAO

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

18 دقيقة الدرس 3 من 13

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

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

لماذا فتح اتصال جديد في كل مرة يُشكّل مشكلة

إنشاء اتصال TCP بخادم قاعدة البيانات يستلزم البحث في DNS، والمصافحة الثلاثية TCP، والمصادقة عبر المشغّل، وإعداد الجلسة على جانب الخادم. على شبكة محلية يستغرق هذا عادةً من 20 إلى 100 مللي ثانية؛ وعلى شبكة سحابية يمكن أن يتجاوز 200 مللي ثانية بسهولة. لنقطة وصول ويب يجب أن تستجيب في أقل من 500 مللي ثانية، فإن إنفاق ثلث الميزانية على إنشاء الاتصال أمر غير مقبول.

علاوةً على زمن الاستجابة، تفرض خوادم قواعد البيانات حدًا صارمًا على الجلسات المتزامنة (MySQL يتيح 151 افتراضيًا؛ PostgreSQL يتيح 100). التطبيق القائم على DriverManager الذي يفتح اتصالًا لكل طلب سيُشبع هذا الحد تحت تزامن متواضع، ويرمي SQLNonTransientConnectionException للجميع.

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

واجهة DataSource

تُعدّ javax.sql.DataSource التجريد المعياري في JDBC لمصنع الاتصالات. عقدها بسيط:

public interface DataSource extends CommonDataSource { Connection getConnection() throws SQLException; Connection getConnection(String username, String password) throws SQLException; }

جميع مكتبات التجميع (HikariCP، Apache DBCP2، c3p0، Tomcat JDBC Pool) تُنفّذ هذه الواجهة. كودك لا يستدعي إلا dataSource.getConnection() — ولا يعرف ولا يكترث بما إذا جاء الـ Connection المُعاد من تجمّع أم من مقبس جديد أم من صنّارة اختبار. هذا الفصل هو ما يجعل التصميم قابلًا للاختبار وقابلًا للنقل.

HikariCP — التجمّع المعياري في الصناعة

HikariCP هو التجمّع الافتراضي في Spring Boot منذ الإصدار 2.0، ويُعدّ على نطاق واسع الأسرع والأكثر موثوقية. أضفه إلى مشروعك بـ Maven:

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

أنشئ كائن DataSource وحيدًا أثناء بدء التطبيق — في تطبيق قائم على Servlet، يُعدّ ServletContextListener النقطة الصحيحة:

import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; import jakarta.servlet.annotation.WebListener; import javax.sql.DataSource; @WebListener public class AppBootstrap implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl(System.getenv("DB_URL")); // مثال: jdbc:postgresql://db:5432/shop cfg.setUsername(System.getenv("DB_USER")); cfg.setPassword(System.getenv("DB_PASS")); // حجم التجمّع — انظر النقاش أدناه cfg.setMaximumPoolSize(10); cfg.setMinimumIdle(2); // ضبط أوقات الانتهاء cfg.setConnectionTimeout(30_000); // الحد الأقصى بالمللي ثانية للانتظار من التجمّع cfg.setIdleTimeout(600_000); // طرد الاتصال الخامل بعد 10 دقائق cfg.setMaxLifetime(1_800_000); // سقف صارم 30 دقيقة (يجب < wait_timeout في قاعدة البيانات) // التحقق من الصحة cfg.setConnectionTestQuery("SELECT 1"); // احذفها للمشغّلات التي تدعم isValid() HikariDataSource ds = new HikariDataSource(cfg); sce.getServletContext().setAttribute("dataSource", ds); } @Override public void contextDestroyed(ServletContextEvent sce) { HikariDataSource ds = (HikariDataSource) sce.getServletContext().getAttribute("dataSource"); if (ds != null) ds.close(); // يُفرغ التجمّع بانتظام عند إلغاء النشر } }

يستطيع أي Servlet أو DAO استرداد DataSource المشترك من ServletContext:

DataSource ds = (DataSource) getServletContext().getAttribute("dataSource"); try (Connection conn = ds.getConnection()) { // نفّذ SQL هنا } // conn.close() تُعيد الاتصال إلى التجمّع — مقبس TCP يبقى مفتوحًا
استخدم try-with-resources دائمًا للاتصالات والجمل ومجموعات النتائج. حين تستدعي conn.close() على اتصال مُجمَّع، لا يُغلق HikariCP المقبس الأساسي — بل يُعيد تعيين حالة الجلسة ويُعيد المقبض المنطقي إلى التجمّع. نسيان استدعائها ضار مثلما يضر التسريب الحقيقي تمامًا: تظل الفتحة محجوزة إلى الأبد ويعلق التطبيق في انتظار اتصال لن يعود.

الضبط الصحيح لحجم التجمّع

خطأ شائع هو تعيين maximumPoolSize بقيمة مرتفعة جدًا بحجة أن "المزيد من الاتصالات = إنتاجية أعلى". العكس عادةً صحيح بمجرد تجاوز التزامن الأمثل لخادم قاعدة البيانات. يوصي فريق HikariCP وتوثيق PostgreSQL بالبدء بـ:

// الصيغة التجريبية (PostgreSQL wiki / مستندات HikariCP): // pool_size = (عدد أنوية CPU في خادم DB) * 2 + عدد أقراص الدوران الفعّالة // خادم DB بأربعة أنوية مع SSD: 4 * 2 + 1 = 9 → نُقرّب إلى 10 cfg.setMaximumPoolSize(10); cfg.setMinimumIdle(2); // أبقِ 2 دافئَين حين يكون التطبيق هادئًا لتجنب التأخير الأولي

في بيئة الخدمات المصغّرة حيث تتشارك نسخ تطبيقات متعددة قاعدة بيانات واحدة، يجب أن يظل مجموع عدد الاتصالات عبر جميع النسخ ضمن max_connections في الخادم. عشر نسخ لكل منها تجمّع من 10 = 100 اتصال — وهذا بالضبط السقف الافتراضي لـ PostgreSQL. احسب ذلك قبل رفع أحجام التجمّعات.

JNDI — DataSource يديره الحاوي

في بيئة خادم تطبيقات (WildFly، GlassFish، Payara، Tomcat في الوضع المؤسسي) غالبًا لا تُنشئ التجمّع في الكود أصلًا. بدلًا من ذلك، يُهيّئ مسؤول الخادم التجمّع في وحدة تحكم إدارة الخادم ويُسجّله باسم في JNDI (Java Naming and Directory Interface). يبحث تطبيقك عنه:

import javax.naming.InitialContext; import javax.sql.DataSource; // داخل Servlet أو EJB: InitialContext ctx = new InitialContext(); DataSource ds = (DataSource) ctx.lookup("java:comp/env/jdbc/ShopDB"); try (Connection conn = ds.getConnection()) { // استخدام طبيعي }

يجب كذلك الإعلان عن مرجع مورد JNDI في web.xml (أو استخدام الحقن @Resource في مكوّن Jakarta EE):

<!-- web.xml --> <resource-ref> <res-ref-name>jdbc/ShopDB</res-ref-name> <res-type>javax.sql.DataSource</res-type> <res-auth>Container</res-auth> </resource-ref>

مع CDI أو EJB يمكنك حقن DataSource مباشرةً وهو أكثر نظافةً:

import jakarta.annotation.Resource; import jakarta.enterprise.context.RequestScoped; import javax.sql.DataSource; @RequestScoped public class OrderRepository { @Resource(lookup = "java:comp/env/jdbc/ShopDB") private DataSource dataSource; public void save(Order order) throws SQLException { try (Connection conn = dataSource.getConnection()) { // ... } } }
JNDI مقابل التجمّع المدمج: البحث عبر JNDI هو النهج المعياري في المؤسسات؛ يتيح لفرق العمليات تغيير بيانات الاعتماد ومعاملات التجمّع دون لمس ملف WAR. أما HikariCP المُهيَّأ في الكود فهو الخيار العملي للملفات المستقلة (Spring Boot fat JAR، الخدمات المصغّرة). للمشاريع الجديدة بلا متطلبات مؤسسية، يكون HikariCP في الكود أبسط للإعداد والاختبار.

ما يجري داخل التجمّع

فهم دورة الحياة يساعدك في تشخيص المشاكل:

  1. الاستعارة: يستدعي كودك ds.getConnection(). يختار HikariCP اتصالًا خاملًا من التجمّع. إن لم يتوفر أي اتصال والتجمّع لم يبلغ maximumPoolSize، يفتح اتصالًا جديدًا. إن امتلأ التجمّع، ينتظر حتى connectionTimeout مللي ثانية ثم يرمي SQLTransientConnectionException.
  2. الاستخدام: تُنفّذ SQL على الاتصال المُستعار. إن تسبّب استثناء في تخطي close()، ستكتشف خيط الصيانة في التجمّع التسريب في نهاية المطاف (إن كان leakDetectionThreshold مُهيَّأً) وتُسجّل تحذيرًا.
  3. الإعادة: يُعيد conn.close() تعيين autoCommit، ويُصفّي التحذيرات، ويُراجع أي معاملة غير مُلتزمة، ويُعلّم الاتصال متاحًا.
  4. التحقق: قبل تسليم اتصال للمستدعي التالي، يُجري HikariCP تحققًا سريعًا (isValid() أو connectionTestQuery) لاكتشاف الاتصالات التي أغلقها خادم قاعدة البيانات بسبب wait_timeout أو اضطراب الشبكة.
  5. الطرد: الاتصالات الخاملة لفترة أطول من idleTimeout، أو الحية أطول من maxLifetime، تُغلق بصمت وتُستبدل — للحفاظ على التجمّع منتعشًا.
لا تُلتزم ثم تنسى الإغلاق. إن عطّلت autoCommit وأعدت اتصالًا إلى التجمّع دون استدعاء commit() أو rollback()، سيُجري HikariCP تراجعًا عند الإعادة — لكن المستعير التالي قد يواجه حالة غير متسقة في أقفال الصفوف المحتجزة حتى ذلك التراجع، مما يُسبّب أوقات انتظار غامضة. استخدم try-with-resources أو كتلة finally تستدعي دائمًا rollback() عند الخطأ وcommit() عند النجاح.

تمكين مقاييس HikariCP (اختياري لكن مفيد)

يكشف HikariCP إحصاءات التجمّع عبر JMX وMicrometer من الصندوق. في تطبيق Spring Boot تحصل على مقاييس التجمّع في Actuator (/actuator/metrics/hikaricp.connections) مجانًا. في تطبيق Servlet مستقل يمكنك الاستعلام برمجيًا:

HikariDataSource hds = (HikariDataSource) ds; HikariPoolMXBean pool = hds.getHikariPoolMXBean(); int active = pool.getActiveConnections(); // مُستعارة حاليًا int idle = pool.getIdleConnections(); // تنتظر في التجمّع int waiting = pool.getThreadsAwaitingConnection(); // خيوط محجوبة في الاستعارة int total = pool.getTotalConnections(); // نشطة + خاملة

راقب threadsAwaitingConnection في بيئة الإنتاج. القيم المستمرة فوق الصفر تعني أن تجمّعك صغير جدًا أو استعلاماتك تستغرق وقتًا طويلًا — وكلاهما إشارة قابلة للتصرف.

الخلاصة

تجميع الاتصالات عبر DataSource ليس تحسينًا تضيفه لاحقًا — بل هو متطلب صحة لأي تطبيق ويب. استخدم HikariCP (أو تجمّعًا يديره JNDI في خادم تطبيقات متكامل)، اضبط حجم التجمّع تجريبيًا، أعِد الاتصالات دائمًا بسرعة عبر try-with-resources، وراقب مقاييس التجمّع كي تكتشف الإشباع قبل أن يتحوّل إلى انقطاع في الخدمة. في الدرس القادم ستضع DataSource مُجمَّعًا حقيقيًا في الاستخدام لتنفيذ جمل CRUD فعلية باستخدام PreparedStatement.