সরাসরি পর্বে যান
পর্ব ৪৯ · ৬৭-এর মধ্যে · মডিউল ৮

অ্যাপ থেকে ডেটাবেস — driver, prepared statement আর connection pool

Databases from Applications — Drivers, Wire Protocols, Prepared Statements, Transactions in Code, the N+1 Problem, Batching and Connection Pooling

Taught in Bangla, this lesson covers using databases from application code: the three-tier path, drivers and wire protocols, the cost of a connection, cursors, fetching and type mapping, prepared statements and parameters, transactions in code with commit, rollback, timeouts and retries, the N+1 query problem, batching, bulk inserts and keyset pagination, and connection pooling and monitoring.

এই পর্বে যা শিখবেন

  • ওয়েব অ্যাপের তিন স্তর, আর browser কেন ডেটাবেসের সাথে সরাসরি কথা বলে না
  • Driver, wire protocol, JDBC/ODBC — আর একটি সংযোগ তৈরিতে আসলে কতগুলো round trip লাগে
  • Cursor, fetch, NULL ↔ None, তারিখ আর টাকার টাইপ — কোড আর ডেটাবেসের মধ্যে অনুবাদ
  • Prepared statement-এর Parse → Bind → Execute, placeholder-এর রকমফের, আর কোড থেকে transaction, timeout ও retry
  • N+1 query ধরা ও সারানো, batching, keyset pagination আর connection pool-এর মাপ ঠিক করা

১ · তিন স্তরের পথ — browser, অ্যাপ সার্ভার আর ডেটাবেস

এ পর্যন্ত আপনি SQL লিখেছেন সরাসরি ডেটাবেসে — এই পাতার এডিটরে, psql-এ বা DB Browser-এ। কিন্তু দোকানের গ্রাহক Rahim Uddin কখনো SQL লেখেন না। তিনি ফোনে অ্যাপ খুলে “আমার অর্ডার” চাপেন, আর এক সেকেন্ডের মধ্যে তালিকা আসে। মাঝখানে কে SQL লিখল, কোথায় চালাল, ফল কীভাবে ফিরল — এই পর্ব সেই পথের গল্প। মডিউল ৮ শুরু হলো: এখান থেকে ডেটাবেসকে দেখবেন একটি বড় সিস্টেমের অংশ হিসেবে।

প্রায় সব আধুনিক অ্যাপ একটি three-tier নকশা মানে। প্রথম স্তর client: browser বা মোবাইল অ্যাপ, যা শুধু দেখায় আর অনুরোধ পাঠায়। দ্বিতীয় স্তর অ্যাপ সার্ভার: Python, Java, PHP বা Node.js-এ লেখা কোড, যা HTTP অনুরোধ নেয়, ব্যবসার নিয়ম চালায় আর ডেটাবেসকে SQL পাঠায়। তৃতীয় স্তর ডেটাবেস সার্ভার। অ্যাপ সার্ভার আর ডেটাবেসের সম্পর্কটি পর্ব ৩-এর client-server মডেল: অ্যাপটিই ডেটাবেসের client.

ব্যাংকের শাখার কথা ভাবুন। গ্রাহক ভল্টে ঢোকেন না। তিনি কাউন্টারে চেক দেন; ক্যাশিয়ার পরিচয় আর সই মেলান, ব্যালান্স দেখেন, নিয়ম মেনে ভল্ট থেকে টাকা আনেন। ভল্টের চাবি থাকে শুধু ভেতরের লোকের কাছে। গ্রাহক = browser, কাউন্টার আর ক্যাশিয়ার = API layer, ভল্ট = ডেটাবেস। কাউন্টার না থাকলে প্রত্যেক গ্রাহকের হাতে ভল্টের চাবি দিতে হতো।

কেন browser সরাসরি ডেটাবেসে যায় না? পাঁচটি কারণ:

  • চাবি লুকানো যায় না। browser-এর JavaScript যে কেউ DevTools খুলে পড়তে পারেন। ডেটাবেসের ব্যবহারকারী-নাম আর পাসওয়ার্ড সেখানে থাকলে তা সবার হাতে।
  • অনুমতি অনেক মোটা দাগের। ডেটাবেসের GRANT বলে “এই ব্যবহারকারী orders টেবিল পড়তে পারে”। কিন্তু দরকার “Rahim শুধু নিজের অর্ডার দেখবেন”। এই নিয়মটি API-র কোডে থাকে: customer_id আসে লগইন session থেকে, URL বা ফর্ম থেকে নয়।
  • যাচাই আর ব্যবসার নিয়ম। দাম কত, ছাড় প্রযোজ্য কি না, স্টক আছে কি না — এগুলো ব্যবহারকারীর পাঠানো সংখ্যায় বিশ্বাস করে ঠিক করা যায় না।
  • নেটওয়ার্কের নিরাপত্তা। ডেটাবেসের port (PostgreSQL-এ ৫৪৩২, MySQL-এ ৩৩০৬) ইন্টারনেটে খোলা রাখা মানে সারা পৃথিবীর স্ক্যানারকে দরজা দেখানো। তা ছাড়া browser ডেটাবেসের নিজস্ব TCP protocol বলতেই পারে না; সে বোঝে HTTP আর WebSocket.
  • সংযোগের সংখ্যা। লাখো browser মানে লাখো সংযোগ — কোনো ডেটাবেস সার্ভার তা সামলাতে পারে না। অ্যাপ সার্ভার কয়েকটি সংযোগ ভাগ করে চালায় (অংশ ৯)।

Firebase বা Supabase-এর মতো সেবায় মনে হয় browser “সরাসরি” ডেটাবেসে লিখছে। আসলে সেখানেও মাঝে একটি API আছে, আর নিরাপত্তা নির্ভর করে security rules বা PostgreSQL-এর row-level security-র ওপর। নিয়ম ভুল হলে পুরো ডেটা খোলা — তাই সেখানেও নিয়ম লেখা আর যাচাই করা আপনার দায়িত্ব।

এই পর্বের লাইভ SQL চলে দোকানের ডেটাবেসে (db-shop): customers (customer_id, name, city, phone, joined_on — ৮ জন), categories (category_id, name — ৪টি শ্রেণি), products (product_id, name, category_id, price, stock — ১০টি পণ্য), orders (order_id, customer_id, order_date, status — ১০টি অর্ডার) আর order_items (order_id, product_id, quantity, unit_price — ১৬টি লাইন)। প্রতিবার চালালে ডেটাবেস নতুন করে তৈরি হয়, foreign key চালু থাকে। এডিটরগুলোতে থাকবে ঠিক সেই SQL, যা অ্যাপ সার্ভার পাঠায়। Rahim “আমার অর্ডার” চাপলে API এটি চালায় (Rahim-এর customer_id = 1, এসেছে তাঁর session থেকে):

-- GET /api/my-orders  (customer_id আসে session থেকে)
SELECT order_id, order_date, status
FROM orders
WHERE customer_id = 1
ORDER BY order_date DESC;

তিনটি সারি: 1009 | 2025-10-08 | pending, 1003 | 2025-09-10 | shipped আর 1001 | 2025-09-01 | delivered. API এই সারিগুলো JSON-এ বদলে browser-এ পাঠায়, যেমন [{"order_id": 1009, "status": "pending"}, …]. browser কখনো SQL দেখে না, টেবিলের নামও জানে না। কোডে 1 সংখ্যাটি অবশ্য সরাসরি লেখা হবে না — আসবে parameter হিসেবে (অংশ ৫)।

২ · Driver আর protocol — ডেটাবেসের ভাষায় কথা বলা

আপনার Python কোড লেখে cur.execute("SELECT …"). PostgreSQL সার্ভার কিন্তু Python বোঝে না; সে বোঝে নেটওয়ার্কে আসা নির্দিষ্ট গঠনের বাইট। মাঝের অনুবাদকের নাম database driver: একটি লাইব্রেরি, যা ফাংশন কলকে ডেটাবেসের wire protocol-এর বার্তায় বদলায়, আর ফিরে আসা বাইটকে ভাষার int, str, None-এ ফেরায়।

  • PostgreSQL: frontend/backend protocol-এর ৩ নম্বর সংস্করণ, PostgreSQL ৭.৪ (২০০৩) থেকে চলছে। প্রতিটি বার্তার শুরুতে এক অক্ষরের ধরন আর দৈর্ঘ্য: client পাঠায় Query, Parse, Bind, Execute; সার্ভার ফেরায় RowDescription, প্রতি সারিতে একটি DataRow, শেষে CommandComplete আর ReadyForQuery. অফিসিয়াল C লাইব্রেরি libpq; Python-এর psycopg এর ওপর চলে, অথচ Java-র pgjdbc আর Node.js-এর pg একই protocol নিজেরাই লিখেছে।
  • MySQL: নিজস্ব client/server protocol, যার দুটি পথ — লেখা SQL পাঠানোর text protocol (COM_QUERY) আর prepared statement-এর binary protocol (COM_STMT_PREPARE, COM_STMT_EXECUTE). MariaDB-ও মূলত একই protocol বলে, তাই একই driver দুটিতে চলে।
  • SQLite: কোনো protocol নেই, কারণ কোনো সার্ভার নেই। এটি একটি embedded database: লাইব্রেরিটি আপনার প্রক্রিয়ার ভেতরেই চলে, ডেটাবেস একটি ফাইল। Python-এর sqlite3 module-এ “কোয়েরি পাঠানো” আসলে একটি C ফাংশন ডাকা — মাইক্রোসেকেন্ডের কাজ। এ কারণেই SQLite-এ N+1 (অংশ ৭) PostgreSQL-এর তুলনায় অনেক কম ক্ষতি করে।

প্রতিটি ডেটাবেসের জন্য আলাদা API শিখতে না হয়, তাই এসেছে অভিন্ন interface. ১৯৯২ সালে Microsoft আনে ODBC; ১৯৯৭ সালে JDK ১.১-এর সাথে আসে JDBC. দুটির ধারণা এক: অ্যাপ একটি নির্দিষ্ট API ডাকে (JDBC-তে Connection, PreparedStatement, ResultSet), আর নিচের driver বদলালে অন্য ডেটাবেস। Python-এ একই কাজ করে DB-API (PEP 249): connect(), cursor(), execute(), fetchone(), commit(). একটি সতর্কতা: interface অভিন্ন হলেও SQL-এর উপভাষা আলাদাই থাকে (পর্ব ৩১)। driver বদলালেই PostgreSQL-এর কোড MySQL-এ চলবে না।

ভাষাPostgreSQLMySQL / MariaDBসাধারণ interface
Pythonpsycopg (৩), asyncpgmysql-connector-python, PyMySQLDB-API (PEP 249)
JavapgjdbcMySQL Connector/JJDBC
PHPpdo_pgsqlpdo_mysql, mysqliPDO
Node.jspg (node-postgres)mysql2নেই — প্রতিটির নিজস্ব API
Gopgxgo-sql-driver/mysqldatabase/sql

৩ · একটি সংযোগের দাম — TCP, TLS আর পরিচয় যাচাই

“ডেটাবেসে connect করা” এক লাইনের কোড, কিন্তু নেটওয়ার্কে কয়েক দফা কথাবার্তা। প্রতিটি দফায় বার্তা যায় আর উত্তর আসে — একটি round trip. অ্যাপ সার্ভার আর ডেটাবেস একই ডেটা সেন্টারে থাকলে একটি round trip সাধারণত এক মিলিসেকেন্ডের কম। দূরের অঞ্চলে থাকলে কয়েক দশক মিলিসেকেন্ড। নিচের হিসাবে ধরা হলো একটি round trip = ৩০ ms, আর PostgreSQL ১৪+ এর ডিফল্ট পাসওয়ার্ড-যাচাই।

অ্যাপ সার্ভার ⇄ DB (port 5432)সবুজ = এই ধাপ

            
হিসাব (১ round trip = ৩০ ms)
এই ধাপে round trip
মোট round trip
মোট সময়
  1. TCP. তিনটি বার্তার TCP handshake — একটি round trip. এর আগে host-এর নাম থেকে IP বের করতে DNS খোঁজও লাগতে পারে, যা এখানে ধরা হয়নি।
  2. SSLRequest. PostgreSQL-এ client আগে জিজ্ঞেস করে, “এনক্রিপশন চলবে?”, সার্ভার উত্তর দেয় 'S'. আরেকটি round trip. PostgreSQL ১৭ থেকে sslnegotiation=direct দিলে এই ধাপ বাদ দেওয়া যায়।
  3. TLS. TLS ১.৩-এ handshake একটি round trip (১.২-এ দুটি)। client সার্ভারের certificate যাচাই করে — sslmode=verify-full দিলে তবেই নিশ্চিত হওয়া যায় যে ভুয়া সার্ভারে কথা বলছেন না।
  4. পরিচয় যাচাই। StartupMessage-এ ব্যবহারকারী আর ডেটাবেসের নাম যায়। তারপর SCRAM-SHA-256-এর challenge-response — পাসওয়ার্ড নিজে কখনো নেটওয়ার্কে যায় না। মোট তিনটি round trip. এ সময়ে PostgreSQL এই সংযোগের জন্য একটি আলাদা OS প্রক্রিয়া চালু করে, যার নিজের মেমরি লাগে।
  5. প্রথম কোয়েরি। অবশেষে Query বার্তা যায়, আর ফল আসে। ছয়টি round trip খরচের পর সপ্তম round trip-এ কাজের কাজ: মোট প্রায় ২১০ ms, যার ১৮০ ms গেছে শুধু সংযোগ তৈরিতে।
  6. খোলা সংযোগের পুনর্ব্যবহার। একই সংযোগে পরের কোয়েরি শুধু একটি round trip — ৩০ ms. প্রতিটি HTTP অনুরোধে নতুন সংযোগ খুললে প্রতিবার সেই ১৮০ ms আর একটি নতুন প্রক্রিয়ার খরচ দিতে হতো। এই পার্থক্য থেকেই connection pool (অংশ ৯)।

সংযোগের সব তথ্য সাধারণত এক লাইনে লেখা হয় — connection string বা URL. এতে থাকে host, port, ডেটাবেস, ব্যবহারকারী, পাসওয়ার্ড আর বিকল্প। সবচেয়ে বড় নিয়ম: এই লাইন কোডে লিখবেন না। Git-এ একবার ঢুকলে history থেকে মোছা কঠিন, আর পাবলিক repository-তে ফাঁস হওয়া key স্বয়ংক্রিয় বট খুব অল্প সময়েই খুঁজে নেয়। এর বদলে environment variable ব্যবহার করুন:

# PostgreSQL — libpq, psycopg, node-postgres সবাই এই রূপ বোঝে
DATABASE_URL=postgresql://shop_app:CHANGE_ME@db.internal:5432/shop?sslmode=verify-full&application_name=shop-web
# MySQL (mysql2, অনেক ORM)
# DATABASE_URL=mysql://shop_app:CHANGE_ME@db.internal:3306/shop
# JDBC-র রূপ আলাদা; পাসওয়ার্ড সাধারণত আলাদা property-তে
# jdbc:postgresql://db.internal:5432/shop?sslmode=verify-full

কোডে শুধু নামটি থাকে: Python-এ os.environ["DATABASE_URL"], Node.js-এ process.env.DATABASE_URL, PHP-তে getenv('DATABASE_URL'). বড় সিস্টেমে পাসওয়ার্ড রাখা হয় secrets manager-এ — HashiCorp Vault, AWS Secrets Manager, Azure Key Vault বা Google Secret Manager. এরা কে কখন পড়ল তা লেখে, আর পাসওয়ার্ড নিয়মিত বদলাতে পারে। আরেকটি অভ্যাস: অ্যাপের ব্যবহারকারী (shop_app) কখনো superuser নয়; শুধু দরকারি টেবিলে দরকারি অনুমতি — least privilege. বিস্তারিত পর্ব ৫১-এ।

৪ · কোড থেকে কোয়েরি — cursor, fetch আর টাইপের অনুবাদ

সংযোগ খোলার পর কোয়েরি চালানোর ধাপগুলো প্রায় সব ভাষায় এক: একটি cursor (বা statement) তৈরি, execute, তারপর ফল পড়া। ফলের সারিগুলোকে বলে result set; cursor তার ওপর একটি আঙুল, যা জানে পরের সারি কোনটি। Python-এর DB-API-তে পড়ার তিনটি উপায়:

cur = con.execute("SELECT order_id, status FROM orders WHERE customer_id = ?", (1,))
first = cur.fetchone()          # একটি সারি, বা শেষ হলে None
rest  = cur.fetchall()          # বাকি সব সারি একটি list-এ
for order_id, status in con.execute("SELECT order_id, status FROM orders"):
    print(order_id, status)     # cursor নিজেই iterable — একটি একটি করে
while batch := cur.fetchmany(500):   # বড় ফল: ৫০০টি করে
    handle(batch)

fetchall() সহজ, কিন্তু দশ লাখ সারি হলে সব একসাথে অ্যাপের মেমরিতে। fetchmany() টুকরো টুকরো পড়ে। তবে একটি ফাঁদ আছে: PostgreSQL আর MySQL-এর বেশিরভাগ driver ডিফল্টে পুরো ফল আগেই নেটওয়ার্ক থেকে টেনে client-এর মেমরিতে রাখে; fetchmany শুধু সেখান থেকে ভাগ করে দেয়। সত্যিকারের অল্প-অল্প আনার জন্য লাগে server-side cursor: psycopg-এ নাম দেওয়া cursor (conn.cursor(name="big")), JDBC-তে setFetchSize(1000) — pgjdbc-তে এটি কাজ করে শুধু autocommit বন্ধ থাকলে।

সারি আসে tuple হিসেবে: (1009, '2025-10-08', 'pending'). কলামের নামে পড়তে চাইলে driver-কে বলুন সারি dict বা object হিসেবে দিতে — sqlite3-এ con.row_factory = sqlite3.Row, psycopg-এ row_factory=dict_row, PDO-তে PDO::FETCH_ASSOC. টেবিলের সারি থেকে ভাষার object বানানোর এই কাজটি বড় হয়ে গেলে তার নাম ORM — পরের পর্বের বিষয়।

টাইপের অনুবাদ — NULL, সময় আর টাকা

SQL-এর টাইপ আর ভাষার টাইপ এক নয়। তিনটি জায়গায় সবচেয়ে বেশি ভুল হয়।

ভাষাSQL NULLটাকা (NUMERIC / DECIMAL)ফাঁদ
PythonNonedecimal.Decimalfloat দিয়ে হিসাব করবেন না
Java (JDBC)nullBigDecimalrs.getInt() NULL পেলে 0 দেয়; rs.wasNull() বা getObject(…, Integer.class) লাগে
PHP (PDO)nullstringপুরোনো সংস্করণে সংখ্যাও string হয়ে আসতে পারে
Node.js (pg)nullstringNUMERIC আর BIGINT ডিফল্টে string আসে, কারণ JS-এর Number ২৫৩ − ১-এর বেশি পূর্ণসংখ্যা নিখুঁত রাখতে পারে না
Gosql.NullString, pointerstring বা decimal লাইব্রেরিNULL-কে সাধারণ string-এ Scan করলে ত্রুটি

সময়: একটি মুহূর্ত (অর্ডারের সময়, লগইনের সময়) রাখুন UTC-তে, আর দেখানোর সময় ব্যবহারকারীর সময় অঞ্চলে বদলান — বাংলাদেশে UTC+6. PostgreSQL-এ টাইপটি timestamptz; এটি ভেতরে UTC মুহূর্ত রাখে আর session-এর TimeZone অনুযায়ী দেখায়। MySQL-এর TIMESTAMP UTC-তে রাখে, DATETIME কোনো অঞ্চল জানে না। কোডে “naive” সময় (অঞ্চলহীন) এড়িয়ে চলুন — Python-এ datetime.now(timezone.utc), Java-য় Instant বা OffsetDateTime.

টাকা: floating-point সংখ্যা ০.১-কে নিখুঁতভাবে রাখতে পারে না। টাকা রাখুন DECIMAL/NUMERIC-এ, অথবা সবচেয়ে ছোট এককে পূর্ণসংখ্যা হিসেবে — ১২০০ টাকা = ১,২০,০০০ পয়সা। db-shop-এর price কলামটি REAL, শেখানোর সুবিধার জন্য; আসল দোকানে এটি হতো DECIMAL বা পয়সার INTEGER. নিচের এডিটরে NULL আর টাকার দুটি দিক দেখুন:

-- অ্যাপে এরা None / null হয়ে যাবে
SELECT customer_id, name, phone, city FROM customers
WHERE phone IS NULL OR city IS NULL;
-- float বনাম পয়সার পূর্ণসংখ্যা
SELECT 0.1 + 0.2 = 0.3 AS float_ok, 10 + 20 = 30 AS paisa_ok;
SELECT SUM(quantity * unit_price) AS total_taka
FROM order_items WHERE order_id = 1009;

প্রথম ফলে দুটি সারি: 5 | Tanvir Ahmed | NULL | Khulna আর 7 | Imran Hossain | 01311000007 | NULL. Python-এ এরা আসবে (5, 'Tanvir Ahmed', None, 'Khulna') রূপে — অ্যাপের কোডে None সামলাতে না জানলে “Tanvir-কে SMS পাঠাও” লাইনটি ভেঙে পড়বে। দ্বিতীয় ফল 0 | 1: float-এ ০.১ + ০.২ ঠিক ০.৩ নয়, পূর্ণসংখ্যায় ১০ + ২০ ঠিক ৩০. তৃতীয় ফল 18000 (১৪,৫০০ + ৩,৫০০)। কলামটি REAL বলে Python-এর sqlite3 এটি দেবে float হিসেবে, 18000.0. ছোট সংখ্যায় ঠিক দেখালেও লাখো লেনদেন যোগ করলে float-এর ভুল জমে পয়সা হারাতে পারে।

৫ · Prepared statement আর parameter — কাঠামো একবার, মান আলাদা

সার্চ বক্সে গ্রাহক নাম লিখলেন, আর কোড SQL বানাল string জোড়া দিয়ে: "… WHERE name = '" + name + "'". যতক্ষণ নাম “Mitu Rani”, সব ঠিক। এবার একজন গ্রাহকের নাম Mitu D'Souza. নামের ভেতরের apostrophe SQL-এর উদ্ধৃতিচিহ্ন বন্ধ করে দেয়। জোড়া লাগানোর পর যে SQL তৈরি হলো, সেটিই চালিয়ে দেখুন:

-- string জোড়া দিয়ে বানানো SQL (ভুল পদ্ধতি)
SELECT customer_id, name FROM customers WHERE name = 'Mitu D'Souza';

ত্রুটি: near "Souza": syntax error. ডেটাবেস 'Mitu D'-কে পুরো string ভেবেছে, তারপর Souza শব্দটি তার কাছে অর্থহীন SQL. একজন সৎ গ্রাহকের নামেই পাতা ভেঙে গেল। আরও বড় বিপদ হলো, ব্যবহারকারীর লেখা যদি SQL-এর অংশ হতে পারে, তাহলে কেউ ইচ্ছে করে এমন লেখা দিতে পারেন যা কোয়েরির অর্থই বদলে দেয় — একেই বলে SQL injection. এর পূর্ণ আলোচনা আর প্রতিরোধের সব স্তর পর্ব ৫১-এ। সমাধানের মূল কথাটি এখানেই: মানকে কখনো SQL-এর লেখার ভেতরে বসাবেন না।

সঠিক পথ হলো placeholder: SQL-এ মানের জায়গায় একটি চিহ্ন রাখুন, আর মানটি আলাদাভাবে দিন — parameter binding. Python-এ con.execute("SELECT … WHERE name = ?", (name,)). ডেটাবেস প্রথমে কাঠামো বোঝে, পরে মান পায় — মানটি যা-ই হোক, সেটি শুধুই ডেটা। এই কাঠামো-আগে-মান-পরে ব্যবস্থার নাম prepared statement. PostgreSQL-এর extended query protocol-এ এটি দেখতে এমন (driver আপনার হয়ে বার্তাগুলো পাঠায়):

app → PostgreSQLএকই সংযোগে

            
সার্ভারের কাজ
parse হয়েছে
চালানো হয়েছে
plan
  1. Parse — একবার। SQL-এর লেখাটি যায় placeholder-সহ, একটি নাম দিয়ে (s1). সার্ভার parse করে, টেবিল আর কলাম আছে কি না দেখে, আর প্রতিটি placeholder-এর টাইপ ঠিক করে: category_id INTEGER বলে $1 integer, LIMIT-এর $2 bigint. statement-টি এই সংযোগে জমা থাকে।
  2. Bind — মান আলাদা। মান দুটি যায় আলাদা বার্তায়, প্রতিটির দৈর্ঘ্যসহ। সার্ভার কখনো এগুলোকে SQL-এর লেখার সাথে জোড়া দেয় না। মান জেনে সার্ভার এখন পরিকল্পনা (plan) বানায়।
  3. Execute. category 1 (Electronics)-এর সবচেয়ে সস্তা ২টি পণ্য: Headphone ১,২০০ আর Power Bank ১,৮০০. Sync বার্তা বলে “এই দফা শেষ”, সার্ভার ReadyForQuery দিয়ে উত্তর দেয়।
  4. আবার, অন্য মানে। category 2 (Books), LIMIT 1 → SQL Book ৫৫০. Parse আর হয়নি — শুধু Bind আর Execute. একই কোয়েরি হাজারবার চললে হাজারবার parse বাঁচে।
  5. অদ্ভুত মান। কেউ $1-এ লিখলেন 1 OR 1=1. string জোড়া দেওয়া SQL-এ এটি শর্তের অংশ হয়ে যেত। এখানে এটি শুধু একটি মান, যাকে integer-এ বদলানো যায় না — সার্ভার ত্রুটি দেয়, কোয়েরির কাঠামো একটুও বদলায় না।
  6. Plan cache. PostgreSQL প্রথম পাঁচবার প্রতিটি মানের জন্য আলাদা custom plan বানায়। তারপর সাধারণ একটি generic plan-এর আনুমানিক খরচ সেগুলোর গড়ের চেয়ে খুব বেশি না হলে generic plan-এ চলে যেতে পারে — তখন planning-ও বাঁচে। prepared statement সংযোগের সম্পত্তি, তাই pool-এর প্রতিটি সংযোগ নিজের কপি রাখে।

দুটি লাভ তাই আলাদা করে মনে রাখুন। নিরাপত্তা: মান কখনো কোড হয় না — এটি প্রতিবার, প্রতিটি মানে সত্য। গতি: parse, আর কখনো plan-ও, একবারই — এটি নির্ভর করে driver আর ডেটাবেসের ওপর। গতির দিকে সূক্ষ্মতাও আছে: generic plan সব মানের জন্য ভালো নাও হতে পারে (পর্ব ২৮-এর plan cache আর parameter sniffing). placeholder-এর চিহ্ন driver-ভেদে আলাদা:

driverplaceholderউদাহরণ
Python sqlite3? বা :nameexecute("… id = ?", (7,))
Python psycopg, PyMySQL%s বা %(name)sexecute("… id = %s", (7,))
Java JDBC?ps.setInt(1, 7)
PHP PDO? বা :name$st->execute([7])
Node.js pg$1, $2query("… id = $1", [7])
Node.js mysql2?execute("… id = ?", [7])
Go database/sqldriver-এর: pgx-এ $1, MySQL-এ ?db.QueryRow("…", 7)

psycopg-এর %s দেখে বিভ্রান্ত হবেন না। এটি Python-এর % formatting নয়। execute("… = %s" % name) লিখলে আবার string জোড়া হয়ে গেল; সঠিক হলো execute("… = %s", (name,)) — মান দ্বিতীয় argument-এ। আর placeholder শুধু মানের জায়গায় বসে। টেবিল বা কলামের নাম, ORDER BY-এর দিক (ASC/DESC) parameter হতে পারে না; এগুলো ব্যবহারকারীর কাছ থেকে এলে আগে থেকে ঠিক করা তালিকা (allow-list) থেকে বেছে নিন।

দুটি driver-এর বিশেষ আচরণ জেনে রাখুন। PHP-র pdo_mysql ডিফল্টে prepared statement “অনুকরণ” করে: PDO::ATTR_EMULATE_PREPARES চালু থাকলে driver নিজেই মান escape করে SQL-এ বসায়, সার্ভারে আসল prepare হয় না। সঠিক charset দেওয়া থাকলে এটি নিরাপদ বলে ধরা হয়, তবু অনেকে এটি বন্ধ রাখেন। Node.js-এর mysql2-এ query() client-এ escape করে, আর execute() সার্ভারে আসল prepared statement বানায়।

৬ · কোড থেকে transaction — commit, rollback, timeout আর retry

পর্ব ৪৪-এ আপনি BEGIN, COMMIT আর ROLLBACK লিখেছেন হাতে। কোড থেকে একই কাজ করতে প্রথম প্রশ্ন: driver-এর ডিফল্ট কী? উত্তর driver-ভেদে আলাদা, আর এখান থেকেই অনেক বাস্তব বাগ আসে।

  • JDBC, PDO, Node.js pg, Go database/sql: ডিফল্টে autocommit — প্রতিটি স্টেটমেন্ট সাথে সাথে পাকা। একসাথে কয়েকটি লেখা চাইলে নিজে transaction শুরু করতে হয়: setAutoCommit(false), beginTransaction(), BEGIN বা db.BeginTx().
  • psycopg (Python, PostgreSQL): উল্টো — প্রথম স্টেটমেন্টের সাথেই নিজে থেকে transaction শুরু হয়। commit() না ডাকলে সংযোগ বন্ধের সময় সব লেখা হারায়, আর মাঝের সময়টায় সংযোগটি “idle in transaction” অবস্থায় lock ধরে বসে থাকে (পর্ব ৪৪)।
  • Python sqlite3 (ডিফল্ট মোড): SELECT-এ নয়, কিন্তু প্রথম INSERT/UPDATE/DELETE-এর আগে নিজে BEGIN পাঠায়; commit() আপনাকে ডাকতে হয়।

নিয়মটি সব ভাষায় এক: শুরু → কাজ → সব ঠিক হলে COMMIT → যেকোনো exception-এ ROLLBACK → শেষে সংযোগ ফেরত। এই কাঠামো হাতে try/except/finally দিয়ে লেখা যায়, কিন্তু ভোলার সুযোগ থাকে। তাই প্রায় সব ভাষায় একটি “নিশ্চিত-বন্ধ” ব্যবস্থা আছে — Python-এ context manager (with con:), Java-য় try-with-resources, Node.js-এ finally. আগে SQL-এ দেখুন অ্যাপটি ঠিক কী পাঠায়। Sabbir একটি Headphone আর একটি Power Bank অর্ডার করলেন; Power Bank-এর স্টক ০:

BEGIN;
INSERT INTO orders VALUES (1011, 3, '2025-10-20', 'pending');
INSERT INTO order_items VALUES (1011, 102, 1, 1200);
UPDATE products SET stock = stock - 1 WHERE product_id = 102 AND stock >= 1;
INSERT INTO order_items VALUES (1011, 103, 1, 1800);
UPDATE products SET stock = stock - 1 WHERE product_id = 103 AND stock >= 1;
-- অ্যাপ দেখল: শেষ UPDATE-এ ০টি সারি → ROLLBACK
ROLLBACK;
SELECT COUNT(*) AS orders FROM orders;
SELECT product_id, stock FROM products WHERE product_id IN (102, 103);

প্রথম UPDATE-এ ১টি সারি বদলেছে, দ্বিতীয়টিতে ০টি সারি বদলেছে — কোনো ত্রুটি নয়, শুধু শর্ত মেলেনি। driver এই সংখ্যাটি অ্যাপকে দেয় (Python-এ cur.rowcount, JDBC-তে executeUpdate()-এর ফেরত মান), আর অ্যাপ তা দেখে ROLLBACK পাঠায়। শেষে অর্ডার আবার 10টি, Headphone-এর স্টক 40, Power Bank 0 — অর্ধেক অর্ডার কোথাও থেকে যায়নি।

একই “অর্ডার দেওয়া” কাজ চারটি ভাষায়। Python-এর সংস্করণ এখানেই চালানো যায় (sqlite3, মেমরিতে ছোট একটি দোকান, দাম পয়সায়)। বাকি তিনটি একই কাজ PostgreSQL-এ — order_id সেখানে GENERATED ALWAYS AS IDENTITY, তাই RETURNING দিয়ে নতুন id ফেরত আসে।

import sqlite3
con = sqlite3.connect(":memory:")
con.executescript("""CREATE TABLE products (product_id INTEGER PRIMARY KEY, name TEXT, price_paisa INTEGER, stock INTEGER);
CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER, total_paisa INTEGER);
CREATE TABLE order_items (order_id INTEGER, product_id INTEGER, quantity INTEGER, unit_paisa INTEGER);
INSERT INTO products VALUES (102, 'Headphone', 120000, 40), (103, 'Power Bank', 180000, 0);""")
def place_order(con, customer_id, items):      # items = {product_id: quantity}
    with con:                                  # COMMIT at the end, ROLLBACK on any exception
        order_id = con.execute("INSERT INTO orders (customer_id, total_paisa) VALUES (?, 0)", (customer_id,)).lastrowid
        total = 0
        for pid, qty in items.items():
            cur = con.execute("UPDATE products SET stock = stock - ? WHERE product_id = ? AND stock >= ?", (qty, pid, qty))
            if cur.rowcount == 0: raise LookupError(f"out of stock: {pid}")
            (price,) = con.execute("SELECT price_paisa FROM products WHERE product_id = ?", (pid,)).fetchone()
            con.execute("INSERT INTO order_items VALUES (?, ?, ?, ?)", (order_id, pid, qty, price))
            total += price * qty
        con.execute("UPDATE orders SET total_paisa = ? WHERE order_id = ?", (total, order_id))
    return order_id, total
print("order 1:", place_order(con, 3, {102: 2}))
try: place_order(con, 5, {102: 1, 103: 1})
except LookupError as e: print("order 2 rolled back:", e)
print("orders, paisa:", con.execute("SELECT COUNT(*), SUM(total_paisa) FROM orders").fetchone(),
      "| Headphone stock:", con.execute("SELECT stock FROM products WHERE product_id = 102").fetchone()[0])

আউটপুট তিন লাইন: order 1: (1, 240000), order 2 rolled back: out of stock: 103 আর orders, paisa: (1, 240000) | Headphone stock: 38. দ্বিতীয় অর্ডারে Headphone-এর স্টক ৩৮ থেকে ৩৭ হয়েছিল, অর্ডারের সারিও তৈরি হয়েছিল — কিন্তু Power Bank-এ exception উঠতেই with con: ROLLBACK করল, তাই স্টক আবার ৩৮, অর্ডার একটিই। লক্ষ করুন, প্রতিটি মান ? দিয়ে যায়; কোনো SQL-এ string জোড়া নেই। আর টাকা পয়সার পূর্ণসংখ্যায়: ২ × ১,২০,০০০ = ২,৪০,০০০ পয়সা = ২,৪০০ টাকা।

long placeOrder(DataSource ds, int customerId, Map<Integer, Integer> items) throws SQLException {
    try (Connection con = ds.getConnection()) {          // pool থেকে ধার, শেষে close() = ফেরত
        con.setAutoCommit(false);                        // transaction শুরু
        try (PreparedStatement order = con.prepareStatement(
                 "INSERT INTO orders (customer_id, order_date, status) VALUES (?, CURRENT_DATE, 'pending') RETURNING order_id");
             PreparedStatement upd = con.prepareStatement(
                 "UPDATE products SET stock = stock - ? WHERE product_id = ? AND stock >= ?");
             PreparedStatement item = con.prepareStatement(
                 "INSERT INTO order_items (order_id, product_id, quantity, unit_price) " +
                 "VALUES (?, ?, ?, (SELECT price FROM products WHERE product_id = ?))")) {
            order.setInt(1, customerId);
            long orderId;
            try (ResultSet rs = order.executeQuery()) { rs.next(); orderId = rs.getLong(1); }
            for (Map.Entry<Integer, Integer> e : items.entrySet()) {
                int pid = e.getKey(), qty = e.getValue();
                upd.setInt(1, qty); upd.setInt(2, pid); upd.setInt(3, qty);
                if (upd.executeUpdate() == 0) throw new SQLException("out of stock: " + pid);
                item.setLong(1, orderId); item.setInt(2, pid); item.setInt(3, qty); item.setInt(4, pid);
                item.executeUpdate();
            }
            con.commit();
            return orderId;
        } catch (SQLException ex) {
            con.rollback();                              // যেকোনো ত্রুটিতে পুরোটা বাতিল
            throw ex;
        }
    }
}

JDBC-র placeholder ?, আর মান বসে টাইপসহ setter দিয়ে — setInt(1, …)-এর ১ মানে প্রথম ?. try-with-resources নিশ্চিত করে যে statement আর connection সবসময় বন্ধ হবে; pool থেকে নেওয়া connection-এর close() আসলে সেটিকে pool-এ ফেরত দেয়। বিস্তারিত JDBC দেখুন Java কোর্সের JDBC পর্বে।

<?php
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASSWORD'), [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,   // PHP 8-এ এটিই ডিফল্ট
    PDO::ATTR_EMULATE_PREPARES => false,           // সার্ভারে আসল prepare
]);
function placeOrder(PDO $pdo, int $customerId, array $items): int {   // $items = [productId => qty]
    $pdo->beginTransaction();
    try {
        $st = $pdo->prepare("INSERT INTO orders (customer_id, order_date, status)
                             VALUES (?, CURRENT_DATE, 'pending') RETURNING order_id");
        $st->execute([$customerId]);
        $orderId = (int) $st->fetchColumn();
        $upd  = $pdo->prepare('UPDATE products SET stock = stock - ? WHERE product_id = ? AND stock >= ?');
        $item = $pdo->prepare('INSERT INTO order_items (order_id, product_id, quantity, unit_price)
                               VALUES (?, ?, ?, (SELECT price FROM products WHERE product_id = ?))');
        foreach ($items as $pid => $qty) {
            $upd->execute([$qty, $pid, $qty]);
            if ($upd->rowCount() === 0) throw new RuntimeException("out of stock: $pid");
            $item->execute([$orderId, $pid, $qty, $pid]);
        }
        $pdo->commit();
        return $orderId;
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

PDO-তে নামওয়ালা placeholder (:qty)-ও চলে, তবে emulation বন্ধ থাকলে একই নাম একটি statement-এ দুবার ব্যবহার করা যায় না — তাই এখানে ?. MySQL-এ শুধু DSN বদলাবে আর RETURNING-এর বদলে $pdo->lastInsertId().

const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

async function placeOrder(customerId, items) {        // items = [[productId, qty], ...]
  const client = await pool.connect();                // একটি নির্দিষ্ট সংযোগ ধার
  try {
    await client.query('BEGIN');
    const { rows } = await client.query(
      "INSERT INTO orders (customer_id, order_date, status) VALUES ($1, CURRENT_DATE, 'pending') RETURNING order_id",
      [customerId]);
    const orderId = rows[0].order_id;
    for (const [pid, qty] of items) {
      const upd = await client.query(
        'UPDATE products SET stock = stock - $1 WHERE product_id = $2 AND stock >= $1', [qty, pid]);
      if (upd.rowCount === 0) throw new Error(`out of stock: ${pid}`);
      await client.query(
        'INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES ($1, $2, $3, (SELECT price FROM products WHERE product_id = $2))',
        [orderId, pid, qty]);
    }
    await client.query('COMMIT');
    return orderId;
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();                                 // pool-এ ফেরত — না দিলে leak
  }
}

Node.js-এর সবচেয়ে প্রচলিত ভুল: pool.query('BEGIN'), তারপর pool.query('UPDATE …'). প্রতিটি pool.query pool-এর যেকোনো খালি সংযোগ নিতে পারে, তাই BEGIN এক সংযোগে আর UPDATE আরেকটিতে — transaction বলে কিছুই থাকে না। transaction-এর জন্য সবসময় pool.connect() দিয়ে একটি client ধরে রাখুন। আরও দেখুন JavaScript কোর্সের Node.js ও ডেটাবেস পর্ব।

Timeout আর retry

কোড থেকে ডেটাবেস ডাকলে দুটি প্রশ্ন সবসময় ঠিক করে রাখুন: কতক্ষণ অপেক্ষা করব, আর ব্যর্থ হলে কী করব। অপেক্ষার সীমা কয়েক স্তরে: সংযোগ তৈরির timeout (libpq-তে connect_timeout), কোয়েরির statement timeout (PostgreSQL-এ statement_timeout, MySQL-এ SELECT-এর জন্য max_execution_time, JDBC-তে setQueryTimeout), lock-এর জন্য অপেক্ষার সীমা (lock_timeout, InnoDB-তে innodb_lock_wait_timeout), আর pool থেকে সংযোগ পাওয়ার অপেক্ষা। timeout না থাকলে একটি ধীর কোয়েরি অনুরোধটিকে আটকে রাখে, অনুরোধ সংযোগটিকে, আর কয়েক মিনিটে পুরো pool আটকে যায়।

ব্যর্থতার দুই জাত। স্থায়ী ত্রুটি — UNIQUE ভাঙা, CHECK ভাঙা, ভুল SQL — আবার চালালেও একই ফল, তাই retry অর্থহীন। সাময়িক ত্রুটি — deadlock (PostgreSQL-এ SQLSTATE 40P01, MySQL-এ ১২১৩), serialization failure (40001), ক্ষণিকের নেটওয়ার্ক বিচ্ছেদ — একটু পরে আবার করলে সাধারণত সফল। পর্ব ৪৭-এর retry loop-এর অ্যাপ-পাশের রূপ:

for attempt in 0 .. 4:
    try:
        BEGIN; …পুরো transaction…; COMMIT
        return ফল
    except error e:
        ROLLBACK
        if e.sqlstate not in {40001, 40P01} or attempt == 4:
            raise e                                  -- স্থায়ী ত্রুটি, বা চেষ্টা শেষ
        wait random(0, min(2 s, 50 ms × 2^attempt))  -- exponential backoff + full jitter

তিনটি নিয়ম। এক, retry করুন পুরো transaction, শুধু ব্যর্থ স্টেটমেন্ট নয় — ROLLBACK-এর পর আগের পড়া মানগুলো আর সত্য নাও হতে পারে। দুই, অপেক্ষা বাড়ান ধাপে ধাপে — exponential backoff — আর তাতে random অংশ (jitter) মেশান, যাতে একসাথে ব্যর্থ হওয়া একশো অনুরোধ একই মিলিসেকেন্ডে আবার আঘাত না করে। তিন, সংখ্যার সীমা রাখুন। আর COMMIT পাঠানোর পর সংযোগ কেটে গেলে আপনি জানেন না COMMIT হয়েছে কি না — সেখানে অন্ধ retry দ্বিগুণ অর্ডার বানাতে পারে; তাই পেমেন্ট আর অর্ডারে পর্ব ৪৪-এর idempotency key.

৭ · N+1 query — লুপের ভেতরে কোয়েরির ফাঁদ

অ্যাডমিন পাতায় দেখাতে হবে: সব delivered অর্ডার, প্রতিটির পাশে গ্রাহকের নাম। স্বাভাবিকভাবে লেখা কোড: প্রথমে অর্ডারগুলো আনা, তারপর লুপে প্রতিটি অর্ডারের গ্রাহককে আনা। পড়তে সহজ, চলেও ঠিক — আর এটিই N+1 query problem: ১টি কোয়েরি N-টি সারি আনে, তারপর প্রতিটির জন্য আরও ১টি। ORM-এ এটি আরও লুকিয়ে ঘটে: order.customer.name লিখলেই পেছনে একটি করে SELECT চলে যায় (পর্ব ৫০)।

অ্যাপ যা পাঠালপ্রতি লাইন = ১ round trip

            
খরচ
কোয়েরি
অপেক্ষা (RTT ১ ms)
অপেক্ষা (RTT ৩০ ms)
  1. প্রথম কোয়েরি। ৫টি delivered অর্ডার আসে, প্রতিটির সাথে শুধু customer_id. নাম দেখাতে হলে customers টেবিল লাগবে।
  2. লুপের প্রথম ঘুর। অর্ডার 1001-এর গ্রাহক 1 — আলাদা একটি কোয়েরি, আলাদা একটি round trip. ফল: Rahim Uddin.
  3. লুপ শেষ। আরও চারটি কোয়েরি — তার একটি Karima-কে দ্বিতীয়বার আনল, কারণ লুপ জানে না আগে কী এনেছে। N = ৫ অর্ডারে মোট ৬টি কোয়েরি।
  4. মাপ বাড়লে। ১,০০০টি অর্ডারে ১,০০১টি কোয়েরি। প্রতিটি হয়তো ডেটাবেসে ০.০৫ ms-এ চলে, কিন্তু round trip-এর অপেক্ষা যোগ হয়: একই ডেটা সেন্টারে (১ ms) প্রায় ১ সেকেন্ড, দূরের অঞ্চলে (৩০ ms) প্রায় ৩০ সেকেন্ড। মোট সময় ≈ কোয়েরির সংখ্যা × RTT + আসল কাজ — আর এখানে প্রথম অংশই বড়।
  5. সমাধান ১: JOIN. একটি কোয়েরি, একটি round trip — ডেটাবেস নিজের ভেতরে মেলায়, পর্ব ৪২-এর join অ্যালগরিদম দিয়ে। N যত বড়ই হোক, কোয়েরি ১টি।
  6. সমাধান ২: IN দিয়ে batch. প্রথমে অর্ডার, তারপর আলাদা আলাদা customer_id-গুলো (1, 2, 4, 7) একটি IN (…)-এ — মোট ২টি কোয়েরি, পুনরাবৃত্তিও নেই। অ্যাপ একটি dict বানিয়ে মেলায়। ORM-এর “eager loading” আর GraphQL-এর DataLoader ঠিক এই কৌশল।

তিনটি সংস্করণই নিজে চালিয়ে দেখুন। প্রথমটি — N+1 অ্যাপ যে ৬টি কোয়েরি পাঠায়, হুবহু:

SELECT order_id, customer_id FROM orders WHERE status = 'delivered';
-- লুপ: প্রতিটি অর্ডারের জন্য একটি করে
SELECT name FROM customers WHERE customer_id = 1;
SELECT name FROM customers WHERE customer_id = 2;
SELECT name FROM customers WHERE customer_id = 4;
SELECT name FROM customers WHERE customer_id = 2;
SELECT name FROM customers WHERE customer_id = 7;

ছয়টি আলাদা ফল-টেবিল: প্রথমটিতে ৫টি সারি (1001 | 1, 1002 | 2, 1005 | 4, 1007 | 2, 1010 | 7), তারপর পাঁচটি এক-সারির টেবিল — Rahim Uddin, Karima Begum, Nusrat Jahan, আবার Karima Begum, Imran Hossain. প্রতিটি টেবিল আসল অ্যাপে একটি আলাদা নেটওয়ার্ক যাত্রা।

-- সমাধান ১: একটি JOIN
SELECT o.order_id, c.name
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.status = 'delivered'
ORDER BY o.order_id;

একটি ফল, ৫টি সারি: 1001 | Rahim Uddin, 1002 | Karima Begum, 1005 | Nusrat Jahan, 1007 | Karima Begum, 1010 | Imran Hossain — N+1-এর ছয় কোয়েরির সমান তথ্য। customer_id customers-এর PRIMARY KEY, তাই প্রতিটি অর্ডারের জন্য ডেটাবেস index দিয়ে সরাসরি গ্রাহক খুঁজে পায়।

-- সমাধান ২: দুটি কোয়েরি, দ্বিতীয়টিতে IN-এর batch
SELECT order_id, customer_id FROM orders WHERE status = 'delivered';
SELECT customer_id, name FROM customers WHERE customer_id IN (1, 2, 4, 7);

দ্বিতীয় ফলে ৪টি সারি — 1 | Rahim Uddin, 2 | Karima Begum, 4 | Nusrat Jahan, 7 | Imran Hossain; Karima একবারই। JOIN থাকতে এটি কেন? যখন “বাবা” সারির সাথে অনেক “সন্তান” সারি আসে (একটি অর্ডারের ২০টি লাইন), JOIN প্রতিটি সন্তানের সাথে বাবার সব কলাম বারবার পাঠায় — পর্ব ১৯-এর fan-out. দুটি আলাদা কোয়েরিতে প্রতিটি সারি একবারই যায়। কোডে IN-এর তালিকাও placeholder দিয়ে বানাতে হয় — নিচের প্রোগ্রামের মতো।

এবার মাপ বাড়িয়ে গুনে দেখা যাক। নিচের প্রোগ্রামে ৫০ জন গ্রাহক আর ২০০টি অর্ডার। sqlite3-এর set_trace_callback প্রতিটি পাঠানো SQL একটি list-এ লিখে রাখে — অ্যাপের ভেতরে একটি ছোট “কোয়েরি গণনাকারী”:

import sqlite3
con = sqlite3.connect(":memory:", autocommit=True)
con.execute("CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT)")
con.execute("CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers)")
con.executemany("INSERT INTO customers VALUES (?, ?)", [(c, f"Customer {c}") for c in range(1, 51)])
con.executemany("INSERT INTO orders VALUES (?, ?)", [(o, o % 50 + 1) for o in range(1, 201)])
sent = []
con.set_trace_callback(sent.append)            # records every SQL statement the app sends
ORDERS = "SELECT order_id, customer_id FROM orders ORDER BY order_id"
def n_plus_one():
    return [(o, con.execute("SELECT name FROM customers WHERE customer_id = ?", (c,)).fetchone()[0])
            for o, c in con.execute(ORDERS).fetchall()]
def one_join():
    return con.execute("SELECT o.order_id, c.name FROM orders o JOIN customers c USING (customer_id) ORDER BY o.order_id").fetchall()
def in_batch():
    rows = con.execute(ORDERS).fetchall()
    ids = sorted({c for _, c in rows})
    marks = ", ".join("?" * len(ids))          # only "?" marks go into the SQL text; values are bound
    names = dict(con.execute(f"SELECT customer_id, name FROM customers WHERE customer_id IN ({marks})", ids))
    return [(o, names[c]) for o, c in rows]
results = []
for f in (n_plus_one, one_join, in_batch):
    sent.clear(); results.append(f())
    print(f"{f.__name__:10} queries sent: {len(sent):3}   rows: {len(results[-1])}")
print("same answer:", results[0] == results[1] == results[2]); con.close()

আউটপুট: n_plus_one queries sent: 201 rows: 200, one_join queries sent: 1 rows: 200, in_batch queries sent: 2 rows: 200, আর শেষে same answer: True. ফল হুবহু এক, কোয়েরি ২০১ বনাম ১ বনাম ২. এখানে SQLite একই প্রক্রিয়ায়, তাই ২০১টি কোয়েরিও চোখের পলকে শেষ; একই কোড PostgreSQL-এ, ৩০ ms RTT-তে, শুধু অপেক্ষাতেই প্রায় ৬ সেকেন্ড নিত (২০১ × ৩০ ms). in_batch-এ f-string দিয়ে SQL বানানো হয়েছে, কিন্তু তাতে ঢুকেছে শুধু ? চিহ্ন; মানগুলো এখনো আলাদা parameter — নিরাপদ পদ্ধতি এটিই। (খুব বড় তালিকায় placeholder-এর সংখ্যার সীমা আছে — অংশ ৮।)

৮ · বড় কাজ কম যাত্রায় — batching, bulk insert আর keyset pagination

N+1-এর লেখার দিকের যমজ ভাই: CSV থেকে ১০,০০০টি পণ্য তুলতে লুপে ১০,০০০টি আলাদা INSERT, প্রতিটি autocommit-এ। এতে ১০,০০০টি round trip, আর ১০,০০০টি COMMIT — প্রতিটি COMMIT-এ log ডিস্কে নিশ্চিত করার খরচ (পর্ব ৪৮-এর fsync). সমাধান batching, তিন স্তরে:

  • একটি transaction-এ মোড়ানো: শুধু এটুকুতেই COMMIT ১০,০০০ থেকে ১-এ নামে।
  • একটি statement-এ অনেক সারি: executemany() (Python), addBatch()/executeBatch() (JDBC), অথবা নিজে বহু-সারির VALUES (…), (…), (…). driver-ভেদে এর প্রভাব আলাদা: pgjdbc-তে reWriteBatchedInserts=true আর MySQL Connector/J-তে rewriteBatchedStatements=true দিলে driver batch-কে বহু-সারির INSERT-এ বদলে দেয়; psycopg ৩ executemany-তে pipeline ব্যবহার করে round trip কমায়।
  • বিশেষ bulk পথ: লাখো সারিতে PostgreSQL-এর COPY আর MySQL-এর LOAD DATA সাধারণ INSERT-এর চেয়ে অনেক দ্রুত (পর্ব ১৪-এর bulk load).
-- তিনটি সারি, একটি statement, একটি round trip
INSERT INTO products (product_id, name, category_id, price, stock) VALUES
  (111, 'Notebook',    2,  80, 200),
  (112, 'Lentils 1kg', 4, 140,  80),
  (113, 'USB Cable',   1, 250,   0);
SELECT COUNT(*) AS products, SUM(stock) AS total_stock FROM products;

৩টি সারি বদলেছে, তারপর 13 | 575 — আগের ১০টি পণ্যের মোট স্টক ২৯৫, নতুন তিনটির ২৮০. কোডে এই VALUES-এর প্রতিটি মান placeholder হবে: তিন সারি × পাঁচ কলাম = ১৫টি parameter. এখানেই সীমা আসে — PostgreSQL-এর protocol একটি statement-এ সর্বোচ্চ ৬৫,৫৩৫টি parameter নেয়, SQLite ৩.৩২ থেকে ডিফল্টে ৩২,৭৬৬টি। তাই বাস্তবে ৫০০–১,০০০ সারির টুকরোয় ভাগ করা হয়, প্রতিটি টুকরো এক transaction-এ।

পড়ার দিকেও একই কথা। “সব অর্ডার দেখাও” পাতায় সব সারি একবারে পাঠানো যায় না; পাতায় পাতায় ভাগ করতে হয় — pagination. সহজ পথ LIMIT 20 OFFSET 2000, কিন্তু পর্ব ১৫-এ দেখেছেন, OFFSET-এ ডেটাবেসকে আগের ২,০০০টি সারি পড়ে ফেলে দিতে হয় — ১০০তম পাতা প্রথম পাতার চেয়ে অনেক ধীর, আর মাঝে নতুন সারি ঢুকলে একটি সারি দুই পাতায় আসে বা হারিয়ে যায়। অ্যাপের জন্য ভালো পথ keyset pagination: “শেষ যে সারিটি দেখিয়েছি, তার পরের ২০টি দাও”।

-- পাতা ১: নতুন থেকে পুরোনো
SELECT order_id, order_date FROM orders
ORDER BY order_date DESC, order_id DESC
LIMIT 4;
-- পাতা ২: অ্যাপ মনে রেখেছে শেষ সারি (2025-10-01, 1007)
SELECT order_id, order_date FROM orders
WHERE (order_date, order_id) < ('2025-10-01', 1007)
ORDER BY order_date DESC, order_id DESC
LIMIT 4;

পাতা ১: 1010, 1009, 1008, 1007 (৯ অক্টোবর থেকে ১ অক্টোবর)। পাতা ২: 1006, 1005, 1004, 1003. (order_date, order_id) < (…) হলো row-value তুলনা — আগে তারিখ, তারিখ সমান হলে order_id. order_id যোগ করা হয়েছে tiebreaker হিসেবে, যাতে একই দিনের দুটি অর্ডারের ক্রম নির্দিষ্ট থাকে। (order_date, order_id)-এ একটি composite index থাকলে (পর্ব ৪১) ১০০তম পাতাও প্রথম পাতার মতো দ্রুত। API সাধারণত শেষ সারির মান দুটি একটি “next cursor” token হিসেবে browser-কে দেয়, আর পরের অনুরোধে সেটি ফেরত নেয় — অবশ্যই parameter হিসেবে।

৯ · Connection pool আর পর্যবেক্ষণ

অংশ ৩-এ দেখেছেন, একটি নতুন সংযোগে প্রায় ছয়টি round trip আর সার্ভারে একটি নতুন প্রক্রিয়া। প্রতি সেকেন্ডে কয়েকশো অনুরোধ আসলে এই খরচ অসহনীয়। সমাধান connection pool: অ্যাপ চালু হওয়ার সময় কয়েকটি সংযোগ খুলে রাখা, প্রতিটি অনুরোধ একটি ধার নেয় আর কাজ শেষে ফেরত দেয়। রেলস্টেশনের রিকশা-স্ট্যান্ডের মতো — যাত্রী এলে নতুন রিকশা বানানো হয় না, লাইনে দাঁড়ানো একটিতে ওঠেন; রিকশা ফিরে আবার লাইনে দাঁড়ায়। Java-য় HikariCP, Node.js-এ pg.Pool, Go-র database/sql নিজেই একটি pool, Python-এ psycopg_pool বা SQLAlchemy-র pool.

  1. BrowserGET /api/my-ordersHTTPS
  2. অ্যাপ সার্ভারhandlersession যাচাই
  3. Poolmax = 10খোলা সংযোগ
  4. ডেটাবেসPostgreSQLSQL চালায়
pool-এর অবস্থা১০টি সংযোগ

            
গণনা
ব্যস্ত সংযোগ
খালি সংযোগ
অপেক্ষায় অনুরোধ
  1. অনুরোধ আসে। browser HTTPS-এ অ্যাপ সার্ভারে অনুরোধ পাঠায়। handler লগইন session দেখে customer_id বের করে। pool-এ এখন ১০টির মধ্যে ৭টি সংযোগ অন্য অনুরোধে ব্যস্ত, ৩টি খালি।
  2. ধার। handler pool থেকে একটি খালি সংযোগ চায়, আর সাথে সাথে পায় — ৮ নম্বর। এটি আগে থেকেই খোলা, তাই TCP, TLS বা পরিচয় যাচাই কিছুই আবার করতে হয় না।
  3. কোয়েরি। সংযোগ #8 দিয়ে prepared statement-এ Bind আর Execute; ডেটাবেস ২ ms কাজ করে ৩টি সারি ফেরত দেয়। মোট খরচ একটি round trip আর আসল কাজ।
  4. ফেরত। handler সংযোগটি pool-এ ফেরত দেয়। ভালো pool নিশ্চিত করে যে সংযোগে কোনো খোলা transaction নেই (দরকারে ROLLBACK পাঠায়), যাতে পরের ধারকারী আগের জনের অর্ধেক কাজ না পায়। ফেরত না দিলে তা connection leak.
  5. উত্তর। সারিগুলো JSON হয়ে browser-এ যায়। সংযোগটি ততক্ষণে অন্য অনুরোধের কাজে লেগে গেছে — ১০টি সংযোগ দিয়ে প্রতি সেকেন্ডে হাজারো ছোট অনুরোধ সামলানো যায়, কারণ প্রতিটি সংযোগ শুধু কয়েক মিলিসেকেন্ড ব্যস্ত থাকে।
  6. ভিড়। একসাথে ১৫টি অনুরোধ: ১০টি সংযোগ পায়, ৫টি সারিতে অপেক্ষা করে। pool-এর অপেক্ষার সীমা (এখানে ৫ s) পেরোলে অনুরোধটি ত্রুটি পায়। এটি খারাপ শোনালেও আসলে সুরক্ষা — ডেটাবেসে ১৫টি একসাথে চাপানোর বদলে চাপ সারিতে আটকে থাকে।

Pool কত বড় হবে?

স্বজ্ঞা বলে “বড় pool = বেশি গতি”, কিন্তু বাস্তবে উল্টো হতে পারে। ডেটাবেস সার্ভারে CPU core আর ডিস্ক সীমিত; ২০০টি সংযোগ একসাথে কাজ চাইলে তারা lock, CPU আর cache নিয়ে কাড়াকাড়ি করে, আর প্রত্যেকে ধীর হয়। HikariCP-র উইকিতে শুরুর বিন্দু হিসেবে একটি সূত্র দেওয়া আছে: সংযোগ ≈ (core সংখ্যা × ২) + কার্যকর ডিস্কের সংখ্যা — এটি চূড়ান্ত উত্তর নয়, মেপে ঠিক করার শুরু। আর হিসাবটি করুন পুরো সিস্টেমের: ৮টি অ্যাপ সার্ভার × pool ২০ = ১৬০টি সংযোগ, অথচ PostgreSQL-এর ডিফল্ট max_connections ১০০ (MySQL-এর ১৫১)। PostgreSQL প্রতিটি সংযোগে একটি আলাদা প্রক্রিয়া চালায়, তাই সংখ্যা বাড়ানোও সস্তা নয়।

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) // সংযোগ নয়, pool তৈরি
if err != nil { log.Fatal(err) }
db.SetMaxOpenConns(10)                  // একসাথে সর্বোচ্চ ১০টি সংযোগ
db.SetMaxIdleConns(10)                  // খালি অবস্থায় রাখা যাবে ১০টি
db.SetConnMaxLifetime(30 * time.Minute) // পুরোনো সংযোগ মাঝে মাঝে বদলায়

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()                          // ২ সেকেন্ডের বেশি অপেক্ষা নয়
var name string
err = db.QueryRowContext(ctx, "SELECT name FROM customers WHERE customer_id = $1", 7).Scan(&name)

অনেক অ্যাপ সার্ভার, serverless ফাংশন বা হাজারো client থাকলে প্রতিটির নিজের pool যথেষ্ট নয়; তখন ডেটাবেসের সামনে একটি আলাদা pooler বসে — PostgreSQL-এর জগতে সবচেয়ে পরিচিত PgBouncer. এর তিনটি মোড: session (client যতক্ষণ সংযুক্ত, ততক্ষণ একটি সার্ভার সংযোগ তার), transaction (শুধু একটি transaction-এর জন্য) আর statement (একটি স্টেটমেন্টের জন্য; বহু-স্টেটমেন্টের transaction চলে না)। সবচেয়ে বেশি সাশ্রয় transaction pooling-এ, কিন্তু তার দাম: পরের transaction অন্য সার্ভার সংযোগে যেতে পারে, তাই session-এর অবস্থা ভরসাযোগ্য নয় — session-স্তরের SET, temporary table, session-এর advisory lock, LISTEN, WITH HOLD cursor. protocol-স্তরের prepared statement-ও আগে এই মোডে চলত না; PgBouncer ১.২১ (২০২৩) থেকে এর সমর্থন এসেছে, তবে চালু করতে সেটিং লাগে।

পর্যবেক্ষণ — কোন কোয়েরি, কোথা থেকে, কত ধীর

অ্যাপ বড় হলে প্রশ্ন আসে: “গতকাল রাতে সাইট ধীর ছিল কেন?” উত্তরের জন্য লাগে observability. ডেটাবেসের দিকে slow query log — PostgreSQL-এ log_min_duration_statement (যেমন ২০০ ms-এর বেশি হলে লগে লেখা), MySQL-এ slow_query_log আর long_query_time — আর PostgreSQL-এর pg_stat_statements extension, যা প্রতিটি কোয়েরির ধরন কতবার চলল আর মোট কত সময় নিল তা জমায়। N+1 এখানে ধরা পড়ে একটি ছোট কোয়েরি হিসেবে, যা লাখবার চলেছে।

কিন্তু log-এ শুধু SQL দেখে বোঝা যায় না, কোন পাতা বা কোন কোড থেকে এল। তাই কোয়েরিতে একটি query tag জুড়ে দিন: সংযোগে application_name=shop-web, আর SQL-এর শুরুতে একটি comment — /* route='/api/my-orders' */ SELECT …. Google-এর sqlcommenter-এর মতো লাইব্রেরি ORM থেকে এটি স্বয়ংক্রিয়ভাবে যোগ করে। অ্যাপের দিকে মাপুন: প্রতিটি অনুরোধে কয়টি কোয়েরি, pool থেকে সংযোগ পেতে কত অপেক্ষা, কতগুলো সংযোগ ব্যস্ত। একটি সতর্কতা: log-এ parameter-এর মানসহ SQL লিখলে ফোন নম্বর বা অন্যান্য ব্যক্তিগত তথ্য log-এ জমে — দরকার না হলে মান বাদ দিন।

আরও গভীরে: round trip-ই যদি মূল খরচ হয়, তাহলে উত্তরের অপেক্ষা না করে কয়েকটি কোয়েরি পরপর পাঠানো যায় কি? PostgreSQL ১৪-এ libpq-তে pipeline mode যোগ হয়েছে: client একের পর এক Parse/Bind/Execute পাঠায়, সার্ভার ক্রম মেনে উত্তর দেয় — ১০টি স্বাধীন কোয়েরি প্রায় একটি round trip-এ। protocol-টি এটি অনেক আগে থেকেই সম্ভব করত (Sync বার্তা দফা ভাগ করে), JDBC-র batch আর psycopg ৩-এর executemany ভেতরে এই সুবিধাই নেয়। তবে একটি স্টেটমেন্ট ব্যর্থ হলে পরের Sync পর্যন্ত বাকিগুলো বাদ পড়ে, তাই ত্রুটি সামলানো জটিল হয়।

১০ · AI-এর যুগে — অ্যাপের ডেটাবেস কোড যাচাই

“অর্ডারের API লিখে দাও” — AI কয়েক সেকেন্ডে কাজ চলা কোড দেয়। পাঁচটি সারির ডেমোতে সব ঠিক দেখায়; সমস্যা ধরা পড়ে আসল মাপে, একসাথে অনেক অনুরোধে বা অদ্ভুত নামে। AI-এর লেখা ডেটাবেস কোডে প্রায়ই যা দেখা যায়:

AI-এর ভুলফলকীভাবে ধরবেন
f-string বা + দিয়ে SQL বানানোapostrophe-ওয়ালা নামে ত্রুটি, SQL injection-এর ঝুঁকিকোডে খুঁজুন: execute-এর ভেতরে f", %, +; D'Souza দিয়ে পরীক্ষা
লুপের ভেতরে কোয়েরি, বা ORM-এ lazy loadingN+1, বড় ডেটায় ধীর পাতাপ্রতি অনুরোধে কোয়েরি গুনুন (trace বা log); ১,০০০ সারিতে চালান
প্রতি অনুরোধে নতুন সংযোগ খোলাপ্রতিবার handshake, ভিড়ে “too many connections”connect() কোথায় ডাকা হচ্ছে দেখুন; pool আছে কি না
pool.query('BEGIN') বা commit ভুলে যাওয়াtransaction নেই, বা idle in transactionএকই client-এ BEGIN…COMMIT, প্রতিটি ত্রুটির পথে ROLLBACK
টাকা float-এ, সময় অঞ্চলহীনপয়সার গরমিল, ৬ ঘণ্টার ভুলটাইপ দেখুন: DECIMAL/পয়সা, timestamptz/UTC
পাসওয়ার্ড কোডে, timeout আর retry নেইফাঁস হওয়া key, আটকে থাকা poolenv var বা secrets manager; statement timeout; 40001-এ retry

AI-কে কাজটি এভাবে বুঝিয়ে দিন:

আমার স্ট্যাক: PostgreSQL 16, Python 3.12, psycopg 3 আর psycopg_pool. টেবিল: customers(customer_id PK, name, city), orders(order_id PK identity, customer_id FK, order_date, status), order_items(order_id, product_id, quantity, unit_price NUMERIC(12,2)), products(product_id PK, price NUMERIC(12,2), stock INTEGER CHECK (stock >= 0)). একটি place_order(customer_id, items) ফাংশন লিখে দাও, যেখানে: ১. সংযোগ আসবে pool থেকে; DATABASE_URL environment variable থেকে পড়বে, কোডে কোনো পাসওয়ার্ড নয়। ২. প্রতিটি মান parameter (%s) দিয়ে; কোনো f-string বা string জোড়া SQL নয়। ৩. পুরো কাজ একটি transaction-এ; স্টক কমানো শর্তযুক্ত UPDATE দিয়ে, rowcount 0 হলে ROLLBACK. ৪. টাকা Decimal-এ, কোনো float নয়। ৫. statement_timeout 2 সেকেন্ড; 40001 আর 40P01-এ পুরো transaction সর্বোচ্চ ৪ বার, exponential backoff আর jitter-সহ। ৬. লুপের ভেতরে কোনো SELECT নয় — সব পণ্যের দাম একটি কোয়েরিতে আনো। শেষে বলো, প্রতিটি অর্ডারে মোট কয়টি কোয়েরি যায়, আর একই পণ্যে দুটি অর্ডার একসাথে এলে কী হয়।

AI-এর লেখা ডেটাবেস কোড চালু করার আগে — চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • Prepared statement কী, আর এর দুটি সুবিধা? — placeholder-সহ SQL একবার parse (কখনো plan) করে বারবার আলাদা মানে চালানো; গতি আর SQL injection প্রতিরোধ, কারণ মান কখনো SQL কোড হয় না।
  • N+1 query problem কী, সমাধান কী? — N সারির জন্য লুপে N-টি অতিরিক্ত কোয়েরি; JOIN, IN (…) দিয়ে batch, বা ORM-এর eager loading.
  • Connection pool কেন লাগে? — নতুন সংযোগে TCP, TLS আর পরিচয় যাচাইয়ের কয়েকটি round trip আর সার্ভারে প্রক্রিয়া/thread তৈরির খরচ; pool খোলা সংযোগ পুনর্ব্যবহার করে আর ডেটাবেসে মোট সংযোগ সীমিত রাখে।
  • JDBC আর ODBC কী? — ডেটাবেস-নিরপেক্ষ API: অ্যাপ একটি interface ডাকে, নিচে ডেটাবেস-নির্দিষ্ট driver; JDBC Java-র, ODBC ভাষা-নিরপেক্ষ C API.
  • OFFSET-এর বদলে keyset pagination কেন? — OFFSET আগের সব সারি পড়ে ফেলে দেয়, গভীর পাতায় ধীর আর ডেটা বদলালে সারি বাদ পড়ে বা দুবার আসে; keyset শেষ key-এর পর থেকে index দিয়ে সরাসরি শুরু করে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১PostgreSQL প্রতিটি সংযোগের জন্য একটি আলাদা OS প্রক্রিয়া চালায়, MySQL চালায় একটি thread. এই নকশার উৎস কী, আর PgBouncer-এর মতো আলাদা pooler কেন জনপ্রিয় হলো?

PostgreSQL-এর শিকড় ১৯৮০-এর দশকে Berkeley-র POSTGRES প্রকল্পে। তখন Unix-এ thread-এর মানসম্মত ব্যবস্থা ছিল না, আর প্রক্রিয়া ছিল স্বাভাবিক একক। প্রতিটি সংযোগে একটি করে fork()-করা backend প্রক্রিয়ার একটি বড় সুবিধা আছে: একটি backend ভুল করে মেমরি নষ্ট করলে বা crash করলে বাকিদের নিজস্ব ঠিকানা-জায়গা অক্ষত থাকে (shared memory-র নিরাপত্তায় postmaster তখন সব backend পুনরায় চালু করে)। দাম হলো প্রতিটি সংযোগে কয়েক মেগাবাইট মেমরি, প্রক্রিয়া তৈরির সময়, আর হাজারো প্রক্রিয়ায় OS-এর scheduling আর CPU cache-এর চাপ। MySQL শুরু থেকে thread-per-connection, তাই সংযোগ তুলনায় সস্তা — তবু হাজারো সক্রিয় thread-এ InnoDB-ও কাড়াকাড়িতে ধীর হয়, আর MySQL Enterprise ও MariaDB-তে thread pool আছে।

ওয়েবের মাপ বাড়ার সাথে সমস্যা স্পষ্ট হলো: শত শত অ্যাপ সার্ভার, প্রত্যেকের নিজের pool, মোট সংযোগ হাজার ছাড়িয়ে। PgBouncer, মূলত Skype-এ তৈরি হওয়া একটি হালকা pooler, হাজারো client সংযোগ নিয়ে অল্প কয়েকটি আসল সার্ভার সংযোগে ভাগ করে দেয়। পরে Pgpool-II, Odyssey, PgCat, আর ক্লাউডে Amazon RDS Proxy-র মতো সেবাও এসেছে। PostgreSQL নিজেও উন্নতি করেছে — যেমন ১৪ সংস্করণে snapshot তৈরির খরচ কমিয়ে অনেক অলস সংযোগে কাজের গতি বাড়ানো হয়েছে — তবে সংযোগ-প্রতি প্রক্রিয়ার মৌলিক নকশা রয়ে গেছে।

এখানে একটি গভীর শিক্ষা: “একসাথে কাজ করা সংযোগ” আর “খোলা সংযোগ” আলাদা জিনিস। ডেটাবেস একসাথে যতগুলো কোয়েরি সত্যিই কার্যকরভাবে চালাতে পারে, সেই সংখ্যা মোটামুটি core আর ডিস্কের সাথে বাঁধা; বাকিরা সারিতে অপেক্ষা করলেই মোট throughput বেশি থাকে।

মূল উপলব্ধি: সংযোগের সংখ্যা বাড়ালে কাজের ক্ষমতা বাড়ে না; অল্প সংযোগে বেশি অনুরোধ ভাগ করে চালানোই দক্ষতা — pool আর pooler সেই ভাগের যন্ত্র।

প্র ০২SQL injection-এর কথা প্রকাশ্যে আলোচিত হচ্ছে ১৯৯০-এর দশকের শেষ থেকে, তবু এটি বহু বছর ধরে ওয়েবের সবচেয়ে পরিচিত দুর্বলতাগুলোর একটি। escape করার বদলে parameter-কেই কেন একমাত্র নির্ভরযোগ্য সমাধান ধরা হয়?

১৯৯৮ সালে Phrack ম্যাগাজিনে Jeff Forristal (“rain.forest.puppy” নামে) ওয়েব অ্যাপ থেকে SQL Server-এ অনাকাঙ্ক্ষিত SQL চলার সমস্যা নিয়ে লেখেন — এটিকে প্রায়ই এই সমস্যার প্রথম দিকের প্রকাশ্য বর্ণনা বলা হয়। এরপর OWASP-এর Top 10 তালিকায় injection বারবার জায়গা পেয়েছে — ২০১০, ২০১৩ আর ২০১৭-এর তালিকায় এক নম্বরে। কারণ মানুষের অভ্যাস: SQL একটি string, আর string জোড়া দেওয়া প্রতিটি ভাষার প্রথম শেখা কাজ।

প্রথম দিকের প্রতিকার ছিল escape করা — উদ্ধৃতিচিহ্নের আগে backslash বা দ্বিগুণ apostrophe. PHP-র addslashes, পরে mysql_real_escape_string. সমস্যা হলো, escape সঠিক হতে হলে লাইব্রেরিকে জানতে হয় সংযোগটি কোন character set-এ চলছে, SQL-এর কোন প্রসঙ্গে মানটি বসছে (string-এর ভেতরে, না সংখ্যার জায়গায়), আর ডেটাবেসের কোন মোড চালু। কিছু multi-byte charset-এ escape-কে পাশ কাটানোর সমস্যা ২০০০-এর দশকে আলোচিত হয়েছিল। আর সংখ্যার জায়গায় (WHERE id = + মান) উদ্ধৃতিই নেই — escape করার কিছুই নেই। প্রতিটি জায়গায় প্রতিবার ঠিক মনে রাখার ওপর নিরাপত্তা দাঁড়িয়ে থাকে।

Parameter সমস্যাটি নকশা দিয়ে মুছে দেয়: কোড আর ডেটা দুটি আলাদা চ্যানেলে যায় (Parse আর Bind). মান যা-ই হোক, parser সেটি কখনো দেখে না। পুরোনো mysql_* ফাংশনগুলো PHP ৭.০ (২০১৫)-এ সরিয়ে দেওয়া হয়, আর PDO/mysqli-র prepared statement হয় স্বাভাবিক পথ। তবু parameter একা যথেষ্ট নয় — টেবিলের নাম parameter হতে পারে না, তাই allow-list; আর অ্যাপের ব্যবহারকারীর সীমিত অনুমতি ক্ষতির পরিধি ছোট রাখে। পূর্ণ আলোচনা পর্ব ৫১-এ।

মূল উপলব্ধি: যে নিরাপত্তা প্রতিবার মনে রাখার ওপর নির্ভর করে, তা একদিন ভাঙে। parameter কোড আর ডেটাকে কাঠামোগতভাবে আলাদা করে — তাই ভোলার সুযোগই থাকে না।

প্র ০৩ODBC আর JDBC-র স্বপ্ন ছিল “একবার লিখুন, যেকোনো ডেটাবেসে চালান”। ত্রিশ বছর পরে এই স্বপ্ন কতটা সত্য হয়েছে?

১৯৮০-এর দশকের শেষে প্রতিটি ডেটাবেস বিক্রেতার নিজস্ব “embedded SQL” আর C লাইব্রেরি ছিল; একটি অ্যাপ Oracle থেকে Sybase-এ সরাতে মানে বড় অংশ নতুন করে লেখা। SQL Access Group নামের শিল্প-জোট একটি অভিন্ন call-level interface-এর নকশা করে; তার ওপর ভিত্তি করে Microsoft ১৯৯২ সালে ODBC প্রকাশ করে, আর পরে একই ধারণা ISO-র SQL/CLI মান হিসেবে আসে। ১৯৯৭ সালে JDK ১.১-এর সাথে আসে JDBC, যার নকশায় ODBC-র প্রভাব স্পষ্ট; প্রথম দিকে “JDBC-ODBC bridge” দিয়ে Java ODBC driver-ও ব্যবহার করতে পারত।

যা সফল হয়েছে: সংযোগ আর কোয়েরি চালানোর যন্ত্রপাতি। Connection, PreparedStatement, ResultSet, transaction-এর API সত্যিই অভিন্ন; Excel, Power BI, Tableau-র মতো টুল ODBC/JDBC দিয়ে শত রকম ডেটাবেসে সংযোগ করে। Java-র পুরো ecosystem — connection pool, ORM, migration টুল — JDBC-র ওপর দাঁড়িয়ে, তাই একটি নতুন ডেটাবেস শুধু একটি driver লিখেই সব পেয়ে যায়।

যা হয়নি: SQL নিজে। API একই হলেও আপনি যে লেখা পাঠান, তা ডেটাবেসের উপভাষায় — LIMIT না FETCH FIRST, AUTO_INCREMENT না IDENTITY, RETURNING আছে কি না, তারিখের ফাংশন কী (পর্ব ৩১)। আচরণও আলাদা: isolation-এর ডিফল্ট, DDL transaction-এ ফেরে কি না, ত্রুটির পর transaction-এর অবস্থা। ORM এই ফাঁক আংশিক ঢাকে, কিন্তু কার্যক্ষমতার জন্য শেষমেশ প্রায়ই ডেটাবেস-নির্দিষ্ট SQL লিখতে হয়।

মূল উপলব্ধি: অভিন্ন API তারের প্লাগ এক করেছে, কিন্তু তারের ভেতর দিয়ে যাওয়া ভাষা এক করেনি। portability-র জন্য driver বদলানো সহজ; SQL আর আচরণের পার্থক্য যাচাই করাই আসল কাজ।

প্র ০৪N+1 সমস্যাটি সবচেয়ে বেশি দেখা যায় ORM আর GraphQL-এর মতো স্তরে, যারা ডেটাবেসকে “object-এর মতো” দেখায়। এর গভীর কারণ কী, আর শিল্প কীভাবে সমাধান খুঁজেছে?

Object-ভিত্তিক কোডে স্বাভাবিক চলাফেরা হলো একটি একটি করে: for order in orders: print(order.customer.name). relational ডেটাবেস ভাবে সেট ধরে: “সব অর্ডার, তাদের গ্রাহকসহ”। এই দুই চিন্তাধারার অমিলকে বলে impedance mismatch. ২০০৬ সালে Ted Neward তাঁর “The Vietnam of Computer Science” প্রবন্ধে ORM-কে এমন এক যুদ্ধের সাথে তুলনা করেন, যেখানে শুরুর সহজ জয়ের পর ক্রমশ জটিলতায় জড়িয়ে পড়তে হয়। lazy loading — সম্পর্কিত object প্রথম ছোঁয়ার মুহূর্তে আনা — কোডকে সুন্দর রাখে, কিন্তু প্রতিটি ছোঁয়া একটি গোপন কোয়েরি।

সমাধানের তিন ধারা দেখা গেছে। এক, ORM-কে আগেই বলে দেওয়া: Rails-এর includes, Django-র select_related আর prefetch_related, Hibernate-এর fetch join — এরা JOIN বা IN (…)-এর batch বানায়, ঠিক এই পর্বের দুই সমাধান। দুই, স্বয়ংক্রিয় batching: Facebook ২০১৫ সালে GraphQL-এর পাশাপাশি DataLoader প্রকাশ করে; এটি একটি event loop-এর ঘুরে আসা সব “গ্রাহক id দাও” অনুরোধ জমিয়ে একটি batch কোয়েরিতে পাঠায় আর একই id দুবার আনে না। তিন, ধরা: Ruby-র Bullet-এর মতো টুল আর development-এ কোয়েরি-গণনা, যা N+1 দেখলে সতর্ক করে; production-এ pg_stat_statements-এ একটি ছোট কোয়েরি অস্বাভাবিক বেশি বার চলতে দেখা।

লক্ষ করুন, এটি ডেটাবেসের দোষ নয় — প্রতিটি কোয়েরিই দ্রুত। দোষ হলো অ্যাপ আর ডেটাবেসের মাঝের যোগাযোগের নকশায়, যেখানে round trip-এর দাম চোখে পড়ে না।

মূল উপলব্ধি: বিমূর্ততা যত সুন্দর হয়, তার নিচের খরচ তত অদৃশ্য হয়। ORM বা GraphQL ব্যবহার করলেও প্রতি অনুরোধে কয়টি কোয়েরি যায়, তা মাপা আপনার দায়িত্ব।

প্র ০৫“সময় UTC-তে রাখুন” — বাংলাদেশের মতো একটিমাত্র সময় অঞ্চলের দেশে এই নিয়ম কি বাড়াবাড়ি? স্থানীয় সময়ে রাখলে কী ঝুঁকি?

মনে হতে পারে, পুরো দেশ UTC+6-এ, তাই স্থানীয় সময় রাখলেই চলে। কিন্তু ইতিহাস অন্য কথা বলে। ২০০৯ সালের ১৯ জুন বাংলাদেশ বিদ্যুৎ সাশ্রয়ের জন্য ঘড়ি এক ঘণ্টা এগিয়ে daylight saving time চালু করে, আর সেই বছরের শেষে তা তুলে নেওয়া হয়। IANA-র time zone ডেটাবেসে (tzdata) Asia/Dhaka-র ইতিহাসে এই পরিবর্তন লেখা আছে। যে সিস্টেম তখন স্থানীয় সময় লিখেছিল, ডিসেম্বরের শেষ রাতে ঘড়ি পিছিয়ে দেওয়ার মুহূর্তে একই “স্থানীয় সময়” দুবার এসেছে — কোন লেনদেন আগে, তা শুধু সময় দেখে আর বলা যায় না। দুনিয়ার বহু দেশ গত কয়েক দশকে সময় অঞ্চল বা DST-এর নিয়ম বদলেছে, প্রায়ই অল্প নোটিশে।

দ্বিতীয় ঝুঁকি মানুষ আর যন্ত্রের মিশ্রণ। সার্ভার ক্লাউডে, হয়তো সিঙ্গাপুর বা আয়ারল্যান্ডে, যার ঘড়ি UTC বা অন্য অঞ্চলে; ব্যবহারকারী প্রবাসী শ্রমিক, দুবাই থেকে টাকা পাঠান; পেমেন্ট gateway অন্য দেশের। অঞ্চলহীন “২০২৫-১০-২০ ১০:০০” তিনজনের কাছে তিন মুহূর্ত। UTC একটি নির্দিষ্ট, কখনো না বদলানো মাপকাঠি: ডেটাবেসে মুহূর্ত রাখুন UTC-তে (timestamptz), দেখানোর সময় ব্যবহারকারীর অঞ্চলে বদলান।

একটি ব্যতিক্রম জরুরি: সব সময় “মুহূর্ত” নয়। “দোকান খোলে সকাল ৯টায়” বা “জন্মতারিখ ১৯৯৮-০৩-২৬” একটি স্থানীয় ধারণা; এগুলো UTC-তে বদলালে ভুল হয় (জন্মতারিখ অন্য অঞ্চলে আগের দিনে সরে যেতে পারে)। তাই DATE আর স্থানীয় সময় আলাদা টাইপে, আর মুহূর্ত timestamptz-এ।

মূল উপলব্ধি: সময় অঞ্চল রাজনীতি আর প্রশাসনের সিদ্ধান্ত, পদার্থবিদ্যার নয় — যেকোনো দিন বদলাতে পারে। মুহূর্তের জন্য UTC রাখলে সেই বদল শুধু দেখানোর স্তরকে ছোঁয়, ডেটাকে নয়।

অনুশীলন

একটি N+1 সারানো কোয়েরি লেখা, কোয়েরির সংখ্যা হাতে গোনা, pool-এর হিসাব, আর একটি কোড পর্যালোচনা।

  1. কোয়েরি লিখুন: একটি কোড প্রথমে SELECT order_id, customer_id, status FROM orders WHERE status IN ('pending', 'shipped') চালায়, তারপর লুপে প্রতিটি অর্ডারের জন্য গ্রাহকের name আর city আনে। db-shop-এ এই N+1-এর বদলে একটিমাত্র কোয়েরি লিখুন, যা order_id, status, name আর city দেয়, order_date অনুযায়ী সাজানো। ফল কী আসবে, আর মূল কোডে কয়টি কোয়েরি যেত?
    SELECT o.order_id, o.status, c.name, c.city
    FROM orders o
    JOIN customers c ON c.customer_id = o.customer_id
    WHERE o.status IN ('pending', 'shipped')
    ORDER BY o.order_date;

    চারটি সারি: 1003 | shipped | Rahim Uddin | Dhaka, 1006 | pending | Farhana Akter | Sylhet, 1008 | shipped | Mitu Rani | Dhaka, 1009 | pending | Rahim Uddin | Dhaka. মূল কোডে যেত ১ + ৪ = ৫টি কোয়েরি (Rahim-কে দুবার আনা হতো); এখানে ১টি। customers.customer_id PRIMARY KEY বলে JOIN-এর খোঁজ index দিয়েই হয়। কাজটি অংশ ৭-এর যেকোনো এডিটরে চালিয়ে মিলিয়ে নিন।

  2. হাতে গুনুন (trace): একটি “অর্ডারের তালিকা” পাতার কোড: (ক) ২০টি সাম্প্রতিক অর্ডার আনে; (খ) প্রতিটি অর্ডারের জন্য গ্রাহককে আনে; (গ) প্রতিটি অর্ডারের জন্য তার order_items আনে; (ঘ) প্রতিটি order_item-এর জন্য পণ্যের নাম আনে। প্রতিটি অর্ডারে গড়ে ৩টি লাইন। মোট কয়টি কোয়েরি? RTT ২ ms হলে শুধু অপেক্ষা কত? IN-batch দিয়ে কয়টিতে নামানো যায়?

    (ক) ১টি, (খ) ২০টি, (গ) ২০টি, (ঘ) ২০ × ৩ = ৬০টি — মোট ১০১টি কোয়েরি; অপেক্ষা ১০১ × ২ ms ≈ ২০২ ms, আসল কাজ বাদেই। এটি “N+1”-এর বহুস্তর রূপ — কখনো বলা হয় 1 + N + N·M. batch দিয়ে: অর্ডার ১টি; সব customer_id একটি IN-এ ১টি; সব order_id-এর order_items একটি IN-এ ১টি; সব product_id-এর পণ্য একটি IN-এ ১টি — মোট ৪টি, অপেক্ষা ৮ ms. শেষ দুটি একটি JOIN-এ (order_items JOIN products) মিলিয়ে ৩টিতেও নামানো যায়। সংখ্যাটি এখন অর্ডারের সংখ্যার ওপর নির্ভর করে না — এটিই আসল লাভ।

  3. হিসাব করুন: একটি অনলাইন দোকানের ৬টি অ্যাপ সার্ভার, প্রত্যেকটিতে pool-এর max ২৫। PostgreSQL-এর max_connections ১০০, যার ৩টি superuser-এর জন্য সংরক্ষিত (ডিফল্ট)। ঈদের সেলে সব সার্ভার পুরো pool ব্যবহার করলে কী হবে? দুটি সমাধান দিন।

    চাহিদা ৬ × ২৫ = ১৫০টি সংযোগ, অথচ সাধারণ ব্যবহারকারীর জন্য পাওয়া যায় ১০০ − ৩ = ৯৭টি (migration, monitoring বা backup-এর সংযোগ থাকলে আরও কম)। ৯৮তম সংযোগের চেষ্টায় ত্রুটি আসবে — PostgreSQL-এ সাধারণত “sorry, too many clients already” — আর ঠিক ভিড়ের সময়েই অর্ডার ব্যর্থ হবে। নতুন সার্ভার যোগ করলে (autoscaling) সমস্যা আরও বাড়বে।

    সমাধান ১: pool ছোট করুন — যেমন প্রতিটিতে ১২ (মোট ৭২), কারণ ডেটাবেসের কয়েকটি core একসাথে এত কোয়েরি চালাতেও পারে না; বাড়তি অনুরোধ অ্যাপের pool-এর সারিতে অপেক্ষা করুক, pool-এর অপেক্ষার timeout-সহ। সমাধান ২: মাঝে PgBouncer বসান transaction মোডে — অ্যাপগুলো PgBouncer-এ শত শত সংযোগ খুলতে পারে, PgBouncer ডেটাবেসে রাখে ধরুন ৪০টি। তখন session-এর অবস্থার (SET, temp table, advisory lock) ওপর নির্ভর করা কোড খুঁজে সরাতে হবে। max_connections অনেক বাড়ানো তৃতীয় পথ, কিন্তু প্রতিটি সংযোগ একটি প্রক্রিয়া আর মেমরি — সাবধানে।

  4. কোড পর্যালোচনা: একটি Node.js handler: await pool.query('BEGIN'); তারপর pool.query("UPDATE wallet SET balance = balance - " + req.body.amount + " WHERE id = " + userId); তারপর await pool.query('COMMIT'). টাকার পরিমাণ JS-এ parseFloat দিয়ে পড়া, আর ডেটাবেসের পাসওয়ার্ড ফাইলের শুরুতে লেখা। অন্তত চারটি সমস্যা চিহ্নিত করে সংশোধন বলুন।
    • transaction নেই: প্রতিটি pool.query ভিন্ন সংযোগ নিতে পারে, তাই BEGIN, UPDATE আর COMMIT তিন সংযোগে যেতে পারে। সংশোধন: const client = await pool.connect(), একই client-এ BEGIN…COMMIT, catch-এ ROLLBACK, finally-তে client.release().
    • string জোড়া SQL: ব্যবহারকারীর পাঠানো amount সরাসরি SQL-এ — ভুল মানে ত্রুটি, আর SQL injection-এর ঝুঁকি। সংশোধন: '… balance - $1 WHERE id = $2', [amount, userId], আর amount-এর সীমা (ধনাত্মক, সর্বোচ্চ কত) আলাদা যাচাই।
    • টাকা float-এ: parseFloat-এ দশমিকের গরমিল। সংশোধন: পয়সায় পূর্ণসংখ্যা, বা string হিসেবে এনে ডেটাবেসের NUMERIC-এ পাঠানো।
    • পাসওয়ার্ড কোডে: Git-এ গেলে সবার হাতে। সংশোধন: process.env.DATABASE_URL বা secrets manager, আর এখনকার পাসওয়ার্ডটি সাথে সাথে বদলানো।
    • বাড়তি: শর্তযুক্ত UPDATE নেই (AND balance >= $1), তাই ব্যালান্স ঋণাত্মক হতে পারে; rowCount যাচাই নেই; retry আর idempotency key নেই (পর্ব ৪৪)।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি অ্যাপ নতুন সংযোগ খুলে PostgreSQL-এ একটি prepared statement চালায়। ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. TCP handshake (SYN, SYN-ACK, ACK)
  2. TLS handshake আর সার্ভারের certificate যাচাই
  3. StartupMessage আর SCRAM দিয়ে পরিচয় যাচাই
  4. Parse — placeholder-সহ SQL পাঠানো
  5. Bind — parameter-এর মান পাঠানো
  6. Execute — সারিগুলো ফেরত আসে

Node.js-এর node-postgres (pg) driver-এ একটি parameter-ওয়ালা কোয়েরি কোনটি সঠিক?

  • query('SELECT * FROM t WHERE id = ?', [7])
  • query(`SELECT * FROM t WHERE id = ${id}`)
  • query('SELECT * FROM t WHERE id = $1', [7])
  • query('SELECT * FROM t WHERE id = %s', [7])

একটি পাতা প্রথমে ৫০টি অর্ডার আনে, তারপর লুপে প্রতিটি অর্ডারের গ্রাহককে আলাদা কোয়েরিতে আনে। ৫০টি অর্ডারের গ্রাহক মাত্র ১০ জন। মোট কয়টি কোয়েরি যায়?

  • ১১টি
  • ৫১টি
  • ৫০টি
  • ২টি

একটি অ্যাপ PgBouncer-এর transaction pooling মোডের পেছনে PostgreSQL ব্যবহার করে। নিচের কোনটির ওপর নির্ভর করা সবচেয়ে ঝুঁকিপূর্ণ?

  • BEGIN … COMMIT-এর ভেতরে কয়েকটি UPDATE
  • autocommit-এ একক SELECT
  • transaction-এর ভেতরে SET LOCAL statement_timeout
  • সংযোগের শুরুতে একবার SET search_path দিয়ে পরের সব transaction-এ তা ধরে নেওয়া

JDBC-তে একটি INTEGER কলামের মান NULL. rs.getInt("discount") কী ফেরত দেয়?

  • 0
  • null
  • SQLException
  • -1

একটি transaction PostgreSQL-এ SQLSTATE 40001 (serialization failure) পেল। অ্যাপের সবচেয়ে সঠিক প্রতিক্রিয়া কোনটি?

  • শুধু ব্যর্থ স্টেটমেন্টটি সাথে সাথে আবার চালানো
  • ROLLBACK করে, বাড়তে থাকা random অপেক্ষার পর পুরো transaction আবার চালানো
  • ব্যবহারকারীকে স্থায়ী ত্রুটি দেখানো
  • অপেক্ষা ছাড়া সফল না হওয়া পর্যন্ত বারবার চালানো

একটি মোবাইল অ্যাপের ডেভেলপার প্রস্তাব দিলেন, অ্যাপটি সরাসরি MySQL সার্ভারে সংযোগ করুক, ব্যবহারকারী-নাম আর পাসওয়ার্ড অ্যাপের ভেতরে রেখে। সবচেয়ে গুরুতর আপত্তি কোনটি?

  • মোবাইল অ্যাপ থেকে TCP সংযোগ খোলা যায় না
  • সরাসরি সংযোগ সবসময় API-র চেয়ে ধীর
  • অ্যাপ থেকে পাসওয়ার্ড বের করা যায়, আর ব্যবহারকারী-ভিত্তিক নিয়ম চাপানো যায় না
  • MySQL-এর কোনো driver মোবাইলে চলে না

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. তিন স্তর: browser → API/অ্যাপ সার্ভার → ডেটাবেস। পাসওয়ার্ড, ব্যবহারকারী-ভিত্তিক নিয়ম আর যাচাই সার্ভারে; ডেটাবেসের port ইন্টারনেটে নয়। পাসওয়ার্ড env var বা secrets manager-এ, অ্যাপের ব্যবহারকারী সীমিত অনুমতিতে।
  2. Driver আর সংযোগ: driver ভাষার কলকে wire protocol-এ বদলায় (SQLite-এ সরাসরি লাইব্রেরি কল)। নতুন সংযোগে TCP + TLS + পরিচয় যাচাই মিলে কয়েকটি round trip — তাই pool.
  3. Parameter সবসময়: placeholder (?, $1, :name, %s) আর আলাদা মান — Parse একবার, Bind আর Execute বারবার; মান কখনো SQL হয় না। টেবিল বা কলামের নাম parameter নয় — allow-list.
  4. Transaction কোড থেকে: driver-এর autocommit ডিফল্ট জানুন; একই সংযোগে শুরু থেকে COMMIT, ত্রুটিতে ROLLBACK, শেষে ফেরত। timeout দিন; শুধু সাময়িক ত্রুটিতে (40001, 40P01) পুরো transaction-এর retry, backoff আর jitter-সহ।
  5. যাত্রা কমান: N+1-এর বদলে JOIN বা IN-batch; লেখায় executemany বা বহু-সারির VALUES, টুকরো টুকরো transaction-এ; পাতা ভাগে keyset pagination. NULL → None/null, টাকা পয়সায় বা DECIMAL-এ, মুহূর্ত UTC-তে। প্রতি অনুরোধে কোয়েরি গুনুন, ধীর কোয়েরির log আর query tag রাখুন।

পরের পর্বে: এই পর্বের সারি থেকে object বানানোর কাজ যখন একটি লাইব্রেরি নিজেই করে — ORM. তার সুবিধা, লুকানো N+1-এর মতো ফাঁদ, আর schema-র পরিবর্তন নিরাপদে চালানোর migration.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান