এই পর্বে যা শিখবেন
- ORM ভেতরে কী করে — mapping, session, identity map, unit of work আর change tracking
- একই Customer–Order মডেল আর query SQLAlchemy, Django, Hibernate/JPA আর Prisma-য়; ORM, query builder আর raw SQL-এর তুলনা
- N+1, over-fetching, লুকানো query আর bulk কাজের ফাঁদ — ORM-এর পাঠানো SQL লাইভ চালিয়ে আর EXPLAIN করে
- Migration tool, up/down স্ক্রিপ্ট, migration-এর হিসাবের টেবিল — নিজের হাতে একটি ছোট migration runner
- Downtime ছাড়া schema বদল (expand–contract), দলে migration conflict, seeding আর dev/staging/prod
১ · দুই জগতের অমিল — object আর টেবিল
পর্ব ৪-এ দেখেছেন, প্রোগ্রামের ভাষা আর relational ডেটাবেস দুনিয়াকে দুইভাবে দেখে। Python, Java বা JavaScript-এ একজন গ্রাহক একটি object: তার ভেতরে নাম, শহর, আর তার অর্ডারগুলোর একটি তালিকা। ডেটাবেসে একই গ্রাহক customers টেবিলের একটি সারি, আর তার অর্ডারগুলো আলাদা orders টেবিলে, foreign key দিয়ে জোড়া। এই অমিলের নাম impedance mismatch.
মুদি দোকানের মালিক মাথায় ভাবেন, “রহিম ভাই — তিনটি বাকির হিসাব”। কিন্তু খাতায় লেখা দুই জায়গায়: সামনের পাতায় খদ্দেরের নাম আর নম্বর, পেছনের পাতায় তারিখ ধরে বাকির লাইন, প্রতিটির পাশে খদ্দেরের নম্বর। মাথার ছবি থেকে খাতায় লেখা, আর খাতা থেকে আবার মাথার ছবি — এই অনুবাদ প্রতিদিন করতে হয়। ORM সেই অনুবাদের কেরানি।
| প্রোগ্রামে | ডেটাবেসে | অনুবাদের সমস্যা |
|---|---|---|
| object-এর পরিচয় = মেমোরির ঠিকানা | সারির পরিচয় = primary key | একই সারি দুবার পড়লে কি দুটি object? |
customer.orders — একটি তালিকা | অন্য টেবিলে foreign key | তালিকা কখন আনা হবে — এখনই, না দরকার হলে? |
| inheritance (class-এর বংশ) | টেবিলে বংশ নেই | পর্ব ১০-এর তিন কৌশল: single table, class table, concrete table |
| object বদলালেই বদল | UPDATE না পাঠালে কিছুই বদলায় না | কোন object বদলেছে, কে মনে রাখবে? |
| পরিচালনা: লুপ, পদ্ধতি (method) | পরিচালনা: সেট-ভিত্তিক SQL | লুপে এক এক করে কাজ ধীর |
পর্ব ৪৯-এ দেখেছেন driver দিয়ে সরাসরি SQL পাঠানো, parameterized query আর connection pool. সেখানে অনুবাদের সব কাজ আপনার: SELECT-এর সারি থেকে object বানানো, object বদলালে UPDATE লেখা। ORM (Object-Relational Mapping) এই কাজটি একটি library-তে তুলে দেয়।
২ · ORM ভেতরে কী করে — session, identity map আর unit of work
ORM-এর কাজ চার ভাগে। এর বেশিরভাগ নকশার নাম এসেছে Martin Fowler-এর ২০০২ সালের বই Patterns of Enterprise Application Architecture থেকে।
Mapping
একটি class = একটি টেবিল, একটি attribute = একটি কলাম, একটি relationship = foreign key. এই বর্ণনা থেকেই ORM SQL বানায়।
Session আর identity map
session একটি কাজের সময়ের খাতা। তার identity map-এ প্রতিটি primary key-র জন্য একটিই object — একই সারি দুবার চাইলে একই object ফেরে।
Change tracking
ORM পড়ার সময়ের মানের একটি কপি রাখে। পরে তুলনা করে বোঝে কোন object বদলেছে (dirty checking).
Unit of work
বদল সাথে সাথে পাঠানো হয় না, জমা থাকে। flush বা commit-এ সব INSERT, UPDATE, DELETE সঠিক ক্রমে একসাথে যায়, একটি transaction-এর ভেতরে।
নিচের stepper-এ SQLAlchemy 2.0-এর একটি session-এর পুরো জীবন দেখুন। ডান পাশের টেবিলে খেয়াল করুন, কোন লাইনে আসলে SQL যায় আর কোনটিতে যায় না:
| identity map | |
| dirty | |
| new (pending) | |
| transaction | |
| এ পর্যন্ত SQL |
- Session খোলা। এখনো কোনো সংযোগ বা transaction নেই। SQLAlchemy প্রথম দরকারের সময় নিজে থেকে transaction শুরু করে (autobegin).
- প্রথম get. identity map-এ ১ নম্বর গ্রাহক নেই, তাই একটি SELECT যায়। object বানিয়ে map-এ রাখা হয়, সাথে পড়ার সময়ের মানের কপি।
- আবার get. primary key ১ map-এ আছে, তাই কোনো SQL ছাড়াই একই object ফেরে। এক session-এ একটি সারি = একটি object. দুটি আলাদা কপি থাকলে কোনটির বদল সত্য, সেই দ্বন্দ্বই হতো না।
- Attribute বদল। ডেটাবেসে কিছু যায় না। ORM শুধু object-টিকে dirty চিহ্ন দেয়।
- নতুন object যোগ। Order-টি pending — session জানে এটি ঢোকাতে হবে, কিন্তু এখনো ঢোকায়নি।
- Commit — unit of work কাজ করছে। আগে flush: জমা বদলগুলো dependency-র ক্রমে SQL হয় (নতুন parent আর child একসাথে থাকলে foreign key-র জন্য parent আগে), তারপর COMMIT. পুরোটা একটি transaction, তাই হয় সব, নয় কিছুই না।
- Commit-এর পরে পড়া। SQLAlchemy-র default
expire_: commit-এর পর object-এর মান “পুরোনো” ধরা হয়, পরের পড়ায় নতুন SELECT. কারণ commit-এর পর অন্য কেউ সারিটি বদলে থাকতে পারে।on_ commit=True
দুই ধরনের ORM নকশা চিনে রাখুন। Active Record-এ object নিজেই নিজেকে সেভ করে (customer.save()) — Rails, Laravel-এর Eloquent, Django-ও অনেকটা এই ধাঁচে। Data Mapper-এ object সাধারণ class, আলাদা একটি স্তর (session বা EntityManager) তাকে ডেটাবেসে নেয় — SQLAlchemy, Hibernate/JPA. প্রথমটি শুরুতে সহজ, দ্বিতীয়টি বড় ডোমেইনে নমনীয়।
৩ · একই মডেল চার ORM-এ, আর ORM বনাম query builder বনাম raw SQL
এবার দোকানের নমুনা ডেটাবেসের দুটি টেবিল মডেল করি: customers (customer_id, name, city, phone, joined_on) আর orders (order_id, customer_id, order_date, status). একজন গ্রাহকের অনেক অর্ডার — ১:N সম্পর্ক। প্রতিটি ট্যাবে প্রথমে মডেল, তারপর একই query: “ঢাকার গ্রাহক, তাদের অর্ডারসহ”।
from sqlalchemy import ForeignKey, String, select
from sqlalchemy.orm import (DeclarativeBase, Mapped, Session,
mapped_column, relationship, selectinload)
class Base(DeclarativeBase):
pass
class Customer(Base):
__tablename__ = "customers"
customer_id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
city: Mapped[str | None]
orders: Mapped[list["Order"]] = relationship(back_populates="customer")
class Order(Base):
__tablename__ = "orders"
order_id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customers.customer_id"))
order_date: Mapped[str]
status: Mapped[str] = mapped_column(String(20))
customer: Mapped["Customer"] = relationship(back_populates="orders")
with Session(engine) as session:
stmt = (select(Customer).where(Customer.city == "Dhaka")
.options(selectinload(Customer.orders)))
for c in session.scalars(stmt):
print(c.name, len(c.orders))relationship()-এর default lazy loading; selectinload দ্বিতীয় একটি IN query-তে সব অর্ডার আনে, joinedload আনে JOIN দিয়ে। আরও বিস্তারিত Python কোর্সের SQLAlchemy ORM পর্বে।
from django.db import models
class Customer(models.Model):
customer_id = models.AutoField(primary_key=True)
name = models.CharField(max_length=100)
city = models.CharField(max_length=50, null=True)
class Meta:
db_table = "customers"
class Order(models.Model):
order_id = models.AutoField(primary_key=True)
customer = models.ForeignKey(Customer, on_delete=models.PROTECT,
related_name="orders",
db_column="customer_id")
order_date = models.DateField()
status = models.CharField(max_length=20)
class Meta:
db_table = "orders"
qs = Customer.objects.filter(city="Dhaka").prefetch_related("orders")
for c in qs:
print(c.name, len(c.orders.all()))Django-তে দুটি আলাদা হাতিয়ার: select_ JOIN দিয়ে foreign key-র “এক” দিক আনে (order → customer); prefetch_ আলাদা IN query দিয়ে “অনেক” দিক আনে (customer → orders). উল্টো দিকে select_related দিলে Django ত্রুটি দেয়।
@Entity
@Table(name = "customers")
public class Customer {
@Id
@Column(name = "customer_id")
private Long id;
private String name;
private String city;
@OneToMany(mappedBy = "customer") // default: LAZY
private List<Order> orders = new ArrayList<>();
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // default would be EAGER
@JoinColumn(name = "customer_id")
private Customer customer;
private String status;
}
List<Customer> list = em.createQuery(
"select c from Customer c left join fetch c.orders where c.city = :city",
Customer.class)
.setParameter("city", "Dhaka")
.getResultList();JPA-তে @OneToMany-এর default LAZY, কিন্তু @ManyToOne-এর default EAGER — অনেকে তাই সেটিকে নিজে LAZY করে দেন। join fetch একটি JOIN-এ সম্পর্ক আনে; Hibernate 6 নিজেই ডুপ্লিকেট root বাদ দেয় (আগের সংস্করণে distinct লিখতে হতো)। Spring-এর দিক থেকে দেখুন Spring Data JPA পর্বে।
model Customer {
id Int @id @map("customer_id")
name String
city String?
orders Order[]
@@map("customers")
}
model Order {
id Int @id @map("order_id")
customerId Int @map("customer_id")
status String
customer Customer @relation(fields: [customerId], references: [id])
@@map("orders")
}
// query.ts
const customers = await prisma.customer.findMany({
where: { city: "Dhaka" },
include: { orders: true },
});
for (const c of customers) console.log(c.name, c.orders.length);Prisma-য় lazy loading নেই — সম্পর্ক চাইলে include বা select লিখতেই হয়, না লিখলে তালিকা আসেই না। ফলে লুকানো N+1 কম, কিন্তু লুপের ভেতরে নিজে query লিখলে একই সমস্যা। include সাধারণত আলাদা query-তে আনা হয়; নতুন সংস্করণে PostgreSQL আর MySQL-এ JOIN-ভিত্তিক কৌশলও বেছে নেওয়া যায় (relation).
কোন ভাষায় কোন ORM
| ভাষা | ORM / tool | ধাঁচ | eager loading |
|---|---|---|---|
| Python | SQLAlchemy, Django ORM | Data Mapper; Django Active Record-ধাঁচ | joinedload, selectinload; select_related, prefetch_related |
| Java/Kotlin | Hibernate (JPA-র প্রধান বাস্তবায়ন) | Data Mapper | join fetch, @EntityGraph |
| C#/.NET | Entity Framework Core | Data Mapper (DbContext) | Include(); lazy loading default-এ বন্ধ |
| TypeScript/Node | Prisma, TypeORM, Sequelize, Drizzle | Prisma ও Drizzle schema-ভিত্তিক; TypeORM দুটোই; Sequelize Active Record-ধাঁচ | include, relations, with |
| PHP | Eloquent (Laravel), Doctrine | Active Record; Doctrine Data Mapper | with('orders') |
| Ruby | Active Record (Rails) | Active Record | includes, preload, eager_load |
| Go | GORM | Active Record-ধাঁচ | Preload("Orders"), Joins |
তিনটি পথ — ORM, query builder, raw SQL
ORM
object নিয়ে কাজ, SQL প্রায় দেখা যায় না। CRUD-ভারী অ্যাপে দ্রুত, সম্পর্ক আর transaction নিজে সামলায়। দাম: লুকানো query, জটিল রিপোর্টে কষ্ট।
Query builder
কোডে SQL-এর গঠন বানানো: select(...).where(...) — Knex, Kysely, jOOQ, SQLAlchemy Core. object-এর জাদু নেই, কিন্তু parameter আর dialect সামলায়।
Raw SQL
নিজে লেখা SQL, driver দিয়ে পাঠানো (পর্ব ৪৯)। window function, CTE, জটিল রিপোর্ট বা গতির জন্য সেরা নিয়ন্ত্রণ। দাম: সারি থেকে object বানানো আপনার কাজ।
বাস্তবে বেশিরভাগ দল তিনটিই মেশায়: সাধারণ CRUD ORM-এ, রিপোর্ট বা গরম পথে (hot path) raw SQL. প্রায় সব ORM-এ raw SQL চালানোর পথ আছে (session.execute(text(...)), Model.objects.raw(), createNativeQuery, $queryRaw). সেখানেও মান বসান parameter দিয়ে, string জুড়ে নয় — নইলে ORM-এর দেওয়া SQL injection সুরক্ষা হারাবেন (পর্ব ৫১)।
সুবিধাগুলো আসল: কম কোডে বেশি কাজ; সব query-তে parameter স্বয়ংক্রিয়ভাবে, তাই injection-এর ঝুঁকি কম; একই মডেল SQLite-এ পরীক্ষা আর PostgreSQL-এ production — কিছুটা portability; আর schema-র একটি জায়গায় বর্ণনা, যা থেকে migration-ও বানানো যায়। portability অবশ্য সীমিত: dialect-এর পার্থক্য (পর্ব ৩১) — যেমন তারিখ বা case-sensitivity — ORM পুরো ঢাকতে পারে না।
৪ · Lazy, eager আর N+1 — সবচেয়ে পরিচিত ফাঁদ
Lazy loading মানে সম্পর্কিত ডেটা আনা হয় প্রথম ছোঁয়ার মুহূর্তে। Eager loading মানে মূল query-র সাথেই আগাম আনা। lazy শুনতে সাশ্রয়ী — যা লাগবে না, তা আনব কেন? কিন্তু লুপের ভেতরে পড়লেই বিপদ: N+1 সমস্যা.
| এ পর্যন্ত query | |
| লোড হওয়া গ্রাহক | |
| N = ১০,০০০ হলে |
- প্রথম query. ঢাকার তিন গ্রাহক আসে।
ordersসম্পর্ক lazy, তাই তালিকা এখনো ফাঁকা জায়গা মাত্র। - লুপে প্রথম গ্রাহক।
c.ordersছোঁয়া মাত্র ORM চুপিচুপি দ্বিতীয় query পাঠায়। কোডে কোনো SQL বা ডেটাবেসের চিহ্ন নেই — এটিই ফাঁদ। - দ্বিতীয় গ্রাহক। আরেকটি query. প্রতিটি query ছোট আর দ্রুত, তাই একটি একটি করে দেখলে কোনোটিই “ধীর query” হিসেবে ধরা পড়ে না।
- তৃতীয় গ্রাহক — মোট ৪. সাধারণভাবে ১ + N. ধরুন অ্যাপ সার্ভার থেকে ডেটাবেসে একটি round trip ০.৫ ms; তাহলে N = ১০,০০০-এ শুধু অপেক্ষাতেই কমপক্ষে ১০,০০১ × ০.৫ ms ≈ ৫ সেকেন্ড — প্রতিটি query-র নিজের কাজের সময় বাদেই।
- সমাধান — eager loading. JOIN করে একটি query, অথবা প্রথম query-র সব id নিয়ে একটি IN query. query-র সংখ্যা N-এর ওপর আর নির্ভর করে না: O(N) round trip থেকে O(১)।
এবার ORM ঠিক যে SQL পাঠায়, সেটিই চালান। নিচের এডিটর দোকানের নমুনা ডেটাবেসে (db-shop) চলে: customers-এ ৮ জন গ্রাহক, orders-এ ১০টি অর্ডার (order_id, customer_id, order_date, status), সাথে products, categories আর order_items. প্রথমে lazy loading-এর চারটি query:
-- lazy loading: ১টি query, তারপর প্রতি গ্রাহকে ১টি
SELECT customer_id, name FROM customers WHERE city = 'Dhaka';
SELECT order_id, status FROM orders WHERE customer_id = 1;
SELECT order_id, status FROM orders WHERE customer_id = 3;
SELECT order_id, status FROM orders WHERE customer_id = 8;চারটি আলাদা ফল-টেবিল: প্রথমটিতে 1 Rahim Uddin, 3 Sabbir Hasan, 8 Mitu Rani; তারপর 1001 delivered, 1003 shipped, 1009 pending; তারপর 1004 cancelled; শেষে 1008 shipped. এখানে চারটি query একই সংযোগে পরপর চলেছে বলে টের পাবেন না। কিন্তু আসল অ্যাপে প্রতিটি query নেটওয়ার্ক পেরিয়ে যায়, প্রতিটির parse, plan আর execute আলাদা।
এবার joinedload বা join fetch যে SQL পাঠায়:
SELECT c.customer_id, c.name, o.order_id, o.status
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE c.city = 'Dhaka'
ORDER BY c.customer_id, o.order_id;একটি query, ৫টি সারি: Rahim Uddin তিনবার (1001, 1003, 1009), Sabbir Hasan একবার (1004), Mitu Rani একবার (1008). ORM সারিগুলো primary key ধরে গুছিয়ে তিনটি Customer object বানায় — identity map এখানেই কাজে লাগে। LEFT কেন? INNER JOIN দিলে কোনো অর্ডার নেই এমন গ্রাহক বাদ পড়ত, অথচ object-এর তালিকায় তিনি থাকা উচিত — খালি orders নিয়ে। লক্ষ করুন দামও আছে: গ্রাহকের কলাম প্রতিটি সারিতে আবার আসে। দুটি তালিকা (orders আর ধরুন addresses) একসাথে JOIN করলে সারি গুণ হয়ে যায় — পর্ব ১৯-এর fan-out.
এজন্যই “অনেক” দিকের তালিকায় দ্বিতীয় কৌশলটি প্রায়ই ভালো — selectinload, prefetch_related বা Prisma-র include যেমন করে:
SELECT customer_id, name FROM customers WHERE city = 'Dhaka';
-- প্রথম query-র id-গুলো দিয়ে দ্বিতীয় query
SELECT customer_id, order_id, status FROM orders
WHERE customer_id IN (1, 3, 8)
ORDER BY customer_id, order_id;দুটি query: তিন গ্রাহক, তারপর পাঁচটি অর্ডার, প্রতিটির পাশে customer_id — যা দেখে ORM কোন অর্ডার কোন object-এর তালিকায় যাবে তা মেলায়। গ্রাহকের কলাম আর পুনরাবৃত্ত হয় না, আর একাধিক তালিকা আনলেও সারি গুণ হয় না (প্রতিটি তালিকার নিজের query). খুব বড় id তালিকা ORM ছোট ছোট ভাগে পাঠায়, কারণ ডেটাবেসের parameter-এর সংখ্যার সীমা আছে। সাধারণ নিয়ম: “এক” দিক (order → customer) JOIN-এ, “অনেক” দিক (customer → orders) IN query-তে।
ORM-এর ভেতরের যন্ত্রটি আসলে কত ছোট, নিজে বানিয়ে দেখুন। নিচের Python প্রোগ্রাম শুধু standard library-র sqlite3 দিয়ে একটি খুদে ORM: lazy loading-ওয়ালা orders, identity map, আর snapshot মিলিয়ে change tracking. set_ প্রতিটি পাঠানো SQL গুনে রাখে:
import sqlite3
con = sqlite3.connect(":memory:", isolation_level=None)
con.executescript("""CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT, city TEXT);
CREATE TABLE orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER, status TEXT);
INSERT INTO customers VALUES (1,'Rahim','Dhaka'), (3,'Sabbir','Dhaka'), (8,'Mitu','Dhaka');
INSERT INTO orders VALUES (1001,1,'delivered'), (1003,1,'shipped'), (1009,1,'pending'), (1004,3,'cancelled'), (1008,8,'shipped');""")
log = []; con.set_trace_callback(log.append) # every SQL statement we send
class Customer:
def __init__(self, cid, name):
self.id, self.name, self._orders = cid, name, None
@property
def orders(self): # lazy loading
if self._orders is None:
self._orders = [r[0] for r in con.execute("SELECT order_id FROM orders WHERE customer_id = ?", (self.id,))]
return self._orders
class Session:
def __init__(self):
self.identity, self.snapshot = {}, {}
def get(self, cid, name=None): # identity map
if cid not in self.identity:
if name is None: name = con.execute("SELECT name FROM customers WHERE customer_id = ?", (cid,)).fetchone()[0]
self.identity[cid], self.snapshot[cid] = Customer(cid, name), name
return self.identity[cid]
def flush(self): # change tracking (dirty check)
for cid, c in self.identity.items():
if c.name != self.snapshot[cid]:
con.execute("UPDATE customers SET name = ? WHERE customer_id = ?", (c.name, cid))
self.snapshot[cid] = c.name
s = Session() # 1) lazy loading -> N+1
for cid, name in con.execute("SELECT customer_id, name FROM customers WHERE city = 'Dhaka'").fetchall():
print(s.get(cid, name).name, len(s.get(cid).orders))
print("lazy:", len(log), "queries"); log.clear()
s = Session() # 2) eager loading: one JOIN
for cid, name, oid in con.execute("""SELECT c.customer_id, c.name, o.order_id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id WHERE c.city = 'Dhaka'""").fetchall():
c = s.get(cid, name)
c._orders = (c._orders or []) + ([oid] if oid else [])
print({c.name: len(c.orders) for c in s.identity.values()}, "eager:", len(log), "query"); log.clear()
a, b = s.get(1), s.get(1) # 3) same row -> same object, no new query
print("a is b:", a is b, "| queries:", len(log))
a.name = "Rahim Uddin"; s.flush(); s.flush() # 4) unit of work: only changed rows, once
print([q for q in log if q.startswith("UPDATE")])আউটপুট:
Rahim 3
Sabbir 1
Mitu 1
lazy: 4 queries
{'Rahim': 3, 'Sabbir': 1, 'Mitu': 1} eager: 1 query
a is b: True | queries: 0
["UPDATE customers SET name = 'Rahim Uddin' WHERE customer_id = 1"]
চারটি জিনিস একসাথে প্রমাণ হলো। (১) lazy-তে ১ + ৩ = ৪টি query. (২) LEFT JOIN-এ একটিই, ফল একই। (৩) একই primary key দুবার চাইলে একই object, শূন্য query. (৪) flush() দুবার ডাকলেও UPDATE একবার, আর শুধু বদলানো সারির জন্য — snapshot-এর সাথে মিলিয়ে বোঝা যায় কোনটি dirty. আসল ORM এর ওপর যোগ করে transaction, dependency-র ক্রম, INSERT/DELETE আর হাজারো খুঁটিনাটি।
ধরুন: একটি পাতা বা API কলে কয়টি query যায়, পরীক্ষায় গুনুন (Django-র assertNumQueries, অথবা SQL লগ)। সারান: SQLAlchemy joinedload/selectinload, Django select_related/prefetch_related, JPA join fetch/@EntityGraph, EF Core Include, Eloquent with, Rails includes. আটকান: অনেক ORM lazy loading-কে ত্রুটি বানাতে পারে — SQLAlchemy lazy="raise", Rails strict_loading, Laravel preventLazyLoading().
৫ · আরও ফাঁদ — over-fetching, লুকানো query, bulk কাজ আর session-এর সীমা
Over-fetching আর Python-এ হিসাব
ORM সাধারণত পুরো object আনে — মানে সব কলাম, অনেকটা SELECT *-এর মতো। একটি বড় JSON বা লেখার কলাম থাকলে তালিকার পাতাতেও সেটি নেটওয়ার্কে আসে: over-fetching. আরও খারাপ: শুধু গুনতে চেয়ে পুরো তালিকা আনা — len(c.orders). হিসাবটি ডেটাবেসেই করুন:
-- len(c.orders) প্রত্যেকের জন্য = সব অর্ডার টেনে আনা
-- তার বদলে: একটি query, শুধু সংখ্যা
SELECT c.name, COUNT(o.order_id) AS n_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id
ORDER BY n_orders DESC, c.name;৮টি সারি: Rahim Uddin 3, Karima Begum 2, তারপর পাঁচজন ১ করে (Farhana, Imran, Mitu, Nusrat, Sabbir — নামের ক্রমে), শেষে Tanvir Ahmed 0. COUNT(o.order_id) NULL গোনে না, তাই অর্ডারহীন Tanvir পান ০ (পর্ব ১৮)। ORM-এও এটি লেখা যায়: SQLAlchemy-তে func.count(), Django-তে annotate(n=Count("orders")); আর শুধু কিছু কলাম চাইলে load_only, only(), values() বা Prisma-র select.
লুকানো query আর leaky abstraction — ORM-এর SQL দেখুন
Joel Spolsky-র ২০০২ সালের “Law of Leaky Abstractions” বলে: প্রতিটি অ-তুচ্ছ abstraction কোথাও না কোথাও ফুটো — নিচের জটিলতা বেরিয়ে আসে। ORM-এর ফুটো হলো গতি: কোড দেখে বোঝা যায় না কয়টি আর কেমন SQL যাবে। তাই প্রথম অভ্যাস — ORM-এর তৈরি SQL লগে দেখা:
# SQLAlchemy
engine = create_engine(url, echo=True) # প্রতিটি SQL আর parameter লগে
print(stmt) # একটি statement-এর SQL দেখা
# Django (settings.py, শুধু DEBUG=True হলে)
LOGGING = {"version": 1,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {"django.db.backends": {"handlers": ["console"],
"level": "DEBUG"}}}
print(Customer.objects.filter(city="Dhaka").query)
# Hibernate / Spring Boot (application.properties)
# logging.level.org.hibernate.SQL=DEBUG
# logging.level.org.hibernate.orm.jdbc.bind=TRACE (Hibernate 6: parameter)
# Prisma
# const prisma = new PrismaClient({ log: ["query"] });লগ থেকে SQL পেলে তার plan দেখুন — পর্ব ৪১-এর মতো। ORM foreign key-র কলামে নিজে থেকে index বানাবে, এমন ভরসা করবেন না: Django বানায়, কিন্তু SQLAlchemy বা অনেক ORM বানায় না, আর PostgreSQL-ও foreign key-তে নিজে index দেয় না। selectinload-এর পাঠানো IN query-টি নিয়ে দেখি:
EXPLAIN QUERY PLAN
SELECT order_id, status FROM orders WHERE customer_id IN (1, 3, 8);
CREATE INDEX idx_orders_customer ON orders(customer_id);
EXPLAIN QUERY PLAN
SELECT order_id, status FROM orders WHERE customer_id IN (1, 3, 8);প্রথম plan: SCAN orders — পুরো টেবিল পড়া, কারণ orders.customer_id-এ কোনো index নেই (নমুনা ডেটাবেসে শুধু foreign key ঘোষণা আছে)। index বানানোর পর: SEARCH orders USING INDEX idx_orders_customer (customer_id=?) — IN-এর প্রতিটি মানের জন্য index-এ সরাসরি খোঁজ। ১০ সারিতে পার্থক্য নেই, ১ কোটি সারিতে আকাশ-পাতাল। index-টি নিজেও একটি migration হয়ে কোডে ঢুকবে — অংশ ৬।
Bulk কাজ — লুপে নয়, একটি statement-এ
“সব Electronics পণ্যের দাম ১০% কমাও” ORM-এর সহজ পথে: সব object আনা, লুপে দাম বদলানো, commit — মানে N সারি পড়া আর N টি UPDATE. সেট-ভিত্তিক একটি UPDATE ... WHERE (পর্ব ১৪) একই কাজ এক statement-এ করে। প্রতিটি ORM-এ এর পথ আছে: SQLAlchemy-তে session.execute(update(Product).where(...).values(price=Product.price * 0.9)), Django-তে filter(...).update(price=F("price") * 0.9), JPA-তে JPQL update; বহু সারি ঢোকাতে bulk_create, insert()-এ তালিকা, বা Hibernate-এর JDBC batching. সতর্কতা: এই পথে ORM-এর object-স্তরের যন্ত্র (signal, callback, session-এর object) বাদ পড়ে — যেমন Django-র update() মডেলের save() বা signal চালায় না।
আরেকটি লুকানো বিপদ পর্ব ৪৬-এর lost update: p.stock = p.stock - 1 মানে আগে পড়া, পাইথনে বিয়োগ, পরে লেখা। দুজন একসাথে করলে একটি বিয়োগ হারায়। সমাধান — ডেটাবেসে হিসাব (F("stock") - 1, Product.stock - 1), অথবা optimistic locking: SQLAlchemy-র version_, JPA-র @Version.
Session আর transaction-এর সীমা ভুল করা
- Session বন্ধের পর lazy load: object session-এর বাইরে চলে গেলে (detached) সম্পর্ক ছুঁলে ত্রুটি — SQLAlchemy-তে
Detached, Hibernate-এInstance Error Lazy. সমাধান: যা লাগবে session থাকতেই eager-এ আনুন।Initialization Exception - অনন্তকাল খোলা session: পুরো অ্যাপে একটি global session মানে identity map ফুলতে থাকে, পুরোনো ডেটা দেখায়, আর দীর্ঘ transaction lock ধরে রাখে (পর্ব ৪৭)। নিয়ম: একটি web request = একটি session = সাধারণত একটি transaction.
- লুপের ভেতর commit: প্রতিটি সারিতে commit মানে প্রতিটিতে fsync (পর্ব ৪৮), আর মাঝপথে ব্যর্থ হলে অর্ধেক কাজ থেকে যায়। একসাথে যা হওয়া উচিত, তা একটি transaction-এ রাখুন।
- transaction-এর ভেতরে ধীর বাইরের কাজ: commit-এর আগে ইমেইল পাঠানো বা পেমেন্ট গেটওয়ে ডাকা মানে lock ধরে অপেক্ষা; আর rollback হলে ইমেইল তো ফেরানো যায় না।
৬ · Schema migration — schema-র সংস্করণ নিয়ন্ত্রণ
কোড git-এ থাকে, প্রতিটি বদল review হয়। কিন্তু ডেটাবেসের schema? কেউ production-এ হাতে ALTER TABLE চালালে staging আর developer-এর ল্যাপটপের সাথে মিল থাকে না — schema drift. Migration এর উত্তর: প্রতিটি schema বদল একটি ক্রমিক ফাইল, কোডের সাথে git-এ, review হয়ে, প্রতিটি পরিবেশে একই ক্রমে চলে।
NID-র তথ্য সংশোধনের কথা ভাবুন। কেউ রেজিস্টারে কেটে নতুন নাম লিখে দেয় না; প্রতিটি সংশোধন একটি নম্বরযুক্ত আবেদন, অনুমোদন হয়, আর কোন সংশোধন কবে হলো তার রেকর্ড থাকে। যেকোনো অফিস রেকর্ড দেখে বলতে পারে, এই কার্ড কোন সংস্করণে আছে। migration ঠিক এই নিয়ম schema-র জন্য।
| Tool | ইকোসিস্টেম | migration লেখা হয় | হিসাবের টেবিল |
|---|---|---|---|
| Alembic | Python, SQLAlchemy | Python ফাইল: upgrade(), downgrade(); autogenerate মডেল থেকে খসড়া দেয় | alembic_version |
| Django migrations | Django | makemigrations মডেল থেকে বানায়, migrate চালায় | django_migrations |
| Flyway | JVM, যেকোনো ডেটাবেস | SQL ফাইল: V3__add_city.sql; checksum যাচাই | flyway_ |
| Liquibase | JVM, যেকোনো ডেটাবেস | changeset (XML/YAML/JSON/SQL), rollback লেখা যায় | DATABASECHANGELOG (+ lock টেবিল) |
| Prisma Migrate | Node/TypeScript | schema.prisma থেকে SQL ফাইল; migrate dev বনাম migrate deploy | _prisma_migrations |
| Laravel migrations | PHP | PHP class: up(), down(); php artisan migrate | migrations |
| Rails migrations | Ruby | Ruby class: change বা up/down | schema_migrations |
| EF Core migrations | .NET | C# class; dotnet ef database update | __EFMigrationsHistory |
প্রায় সব tool-এর মূল একই: প্রতিটি migration-এর একটি up (এগোনো) আর ঐচ্ছিক down (ফেরানো) অংশ। একটি Alembic migration দেখতে এমন:
"""add customer email"""
from alembic import op
import sqlalchemy as sa
revision = "0005"
down_revision = "0004" # আগের migration — এভাবেই শিকল তৈরি হয়
def upgrade() -> None:
op.add_column("customers", sa.Column("email", sa.String(200), nullable=True))
op.create_index("ix_customers_email", "customers", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_customers_email", table_name="customers")
op.drop_column("customers", "email")tool ডেটাবেসের ভেতরের একটি টেবিলে লিখে রাখে কোন migration চলেছে। Rails-এ এর নাম schema_migrations, এই পর্বেও আমরা সেই নামই নেব। চালানোর সময় tool ফাইলের তালিকা আর টেবিলের তালিকা মেলায়: যা টেবিলে নেই, তা চালায়, ক্রমে। নিচের এডিটরে এমন একটি টেবিল (version, name, applied_at, checksum) আছে; সাথে একটি অস্থায়ী files টেবিলে রিপোজিটরির migration ফাইলের তালিকা:
SELECT version, name, applied_at FROM schema_migrations ORDER BY version;
CREATE TEMP TABLE files (version TEXT);
INSERT INTO files VALUES ('0001'), ('0002'), ('0003'), ('0004'), ('0005'), ('0006');
-- কোডে আছে, ডেটাবেসে চলেনি: এগুলো এখন চলবে
SELECT version AS pending FROM files
EXCEPT SELECT version FROM schema_migrations ORDER BY 1;
-- ডেটাবেসে চলেছে, কোডে নেই: সতর্কসংকেত
SELECT version AS unknown_to_code FROM schema_migrations
EXCEPT SELECT version FROM files;প্রথম ফল: পাঁচটি চলা migration, ০০০১ থেকে ০০০৪b. দ্বিতীয়: pending = 0005, 0006 — পরের deploy-এ এই দুটি এই ক্রমে চলবে। তৃতীয়: unknown_to_code = 0004b. অর্থাৎ এই ডেটাবেসে (ধরুন staging) কেউ অন্য branch-এর একটি migration চালিয়েছিলেন, যা মূল কোডে আর নেই। ডেটাবেসে এখন একটি অতিরিক্ত কলাম থাকতে পারে, যা কোড জানে না — অংশ ৮-এর conflict ঠিক এভাবেই ধরা পড়ে। (EXCEPT পর্ব ২২-এর।)
এবার পুরো যন্ত্রটি নিজে বানান। নিচের runner তিনটি সংস্করণযুক্ত migration চালায়, প্রতিটি নিজের transaction-এ, আর হিসাব রাখে schema_migrations-এ — সাথে একটি checksum, যাতে চলে যাওয়া migration পরে কেউ বদলালে ধরা পড়ে (Flyway ঠিক এটি করে):
import sqlite3, hashlib
MIGRATIONS = [ # (version, name, up SQL) -- in a real project: one file per migration
("001", "create_customers", "CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"),
("002", "create_orders", "CREATE TABLE orders (id INTEGER PRIMARY KEY, "
"customer_id INTEGER NOT NULL REFERENCES customers(id), status TEXT NOT NULL)"),
("003", "add_customer_city", "ALTER TABLE customers ADD COLUMN city TEXT"),
]
checksum = lambda sql: hashlib.sha256(sql.encode()).hexdigest()[:10]
def migrate(con, migrations):
con.execute("CREATE TABLE IF NOT EXISTS schema_migrations "
"(version TEXT PRIMARY KEY, name TEXT NOT NULL, checksum TEXT NOT NULL)")
done = dict(con.execute("SELECT version, checksum FROM schema_migrations"))
applied = 0
for version, name, sql in migrations:
if version in done:
if done[version] != checksum(sql):
raise RuntimeError(f"{version} was edited after it was applied")
continue
con.execute("BEGIN") # one transaction per migration
try:
con.execute(sql)
con.execute("INSERT INTO schema_migrations VALUES (?, ?, ?)", (version, name, checksum(sql)))
con.execute("COMMIT")
print(" applied", version, name); applied += 1
except sqlite3.Error as e:
con.execute("ROLLBACK")
print(" FAILED", version, "->", e)
return
if not applied: print(" nothing to do")
con = sqlite3.connect(":memory:", isolation_level=None) # we control BEGIN/COMMIT
print("day 1:"); migrate(con, MIGRATIONS[:2])
print("day 2:"); migrate(con, MIGRATIONS)
print("again:"); migrate(con, MIGRATIONS) # nothing to do
con.execute("INSERT INTO customers (name, city) VALUES ('Rahim', 'Dhaka')")
con.execute("INSERT INTO orders (customer_id, status) VALUES (1, 'pending')")
bad = ("004", "add_total", "ALTER TABLE orders ADD COLUMN total INTEGER NOT NULL")
print("day 3:"); migrate(con, MIGRATIONS + [bad])
print(con.execute("SELECT version, name, checksum FROM schema_migrations").fetchall())
print("orders columns:", [r[1] for r in con.execute("PRAGMA table_info(orders)")])
edited = MIGRATIONS[:2] + [("003", "add_customer_city", "ALTER TABLE customers ADD COLUMN town TEXT")]
try:
migrate(con, edited)
except RuntimeError as e:
print("error:", e)আউটপুট:
day 1:
applied 001 create_customers
applied 002 create_orders
day 2:
applied 003 add_customer_city
again:
nothing to do
day 3:
FAILED 004 -> Cannot add a NOT NULL column with default value NULL
[('001', 'create_customers', '478f22b99a'), ('002', 'create_orders', 'c8dcee34c5'), ('003', 'add_customer_city', '19883fe4dd')]
orders columns: ['id', 'customer_id', 'status']
error: 003 was edited after it was applied
দ্বিতীয় দিনে শুধু নতুন ০০৩ চলেছে, তৃতীয়বার কিছুই না — migration বারবার চালানো নিরাপদ, এটিই repeatable হওয়ার মানে। ০০৪ ব্যর্থ: সারি আছে এমন টেবিলে SQLite default ছাড়া NOT NULL কলাম যোগ করতে দেয় না (পুরোনো সারিতে কী বসবে?)। ROLLBACK-এর পর টেবিলে total কলাম নেই, আর হিসাবেও ০০৪ নেই — পরের বার ঠিক করা ফাইলটি আবার চলবে। শেষে ০০৩-এর লেখা বদলানোয় checksum মেলেনি, runner থেমে গেছে। শিক্ষা: চলে যাওয়া migration কখনো বদলাবেন না; ভুল সারাতে নতুন migration লিখুন।
migration-কে transaction-এ মোড়ানো তখনই কাজ করে, যখন ডেটাবেস DDL-কেও transaction-এ রাখতে পারে (transactional DDL, পর্ব ৩১)। PostgreSQL আর SQLite পারে; MySQL-এ প্রায় প্রতিটি DDL নিজে থেকে commit করে (implicit commit). ফলে MySQL-এ দুই ধাপের migration-এর দ্বিতীয় ধাপ ব্যর্থ হলে প্রথমটি থেকে যায় — অর্ধেক migration. নিচে একই অবস্থা দেখুন; এডিটর প্রথম ত্রুটিতে থামে:
BEGIN;
ALTER TABLE orders ADD COLUMN total REAL;
ALTER TABLE customers ADD COLUMN email TEXT NOT NULL;
COMMIT;প্রথম ALTER সফল (✓), দ্বিতীয়টিতে ত্রুটি: Cannot add a NOT NULL column with default value NULL — customers-এ ৮টি সারি আছে, তাদের email কী হবে? এডিটর এখানেই থামে, COMMIT আর চলে না। একটি migration tool এখানে ROLLBACK পাঠাত, আর SQLite বা PostgreSQL-এ প্রথম ALTER-ও উল্টে যেত — ডেটাবেস ঠিক আগের মতো। MySQL-এ orders.total থেকে যেত, আর হিসাবের টেবিলে migration-টি “চলেনি” — পরের চেষ্টায় প্রথম ALTER “duplicate column” ত্রুটি দিত। তাই MySQL-এ একটি migration-এ একটি DDL রাখা নিরাপদ অভ্যাস।
৭ · Downtime ছাড়া schema বদল — expand–contract
বড় অ্যাপে deploy মানে একসাথে সব সার্ভার বদলানো নয়: কয়েক মিনিট ধরে কিছু সার্ভারে পুরোনো কোড, কিছুতে নতুন। আর migration চলে deploy-এর আগে বা মাঝে। তাই প্রতিটি schema বদলকে পুরোনো আর নতুন দুই কোডের সাথেই চলতে হবে। কলামের নাম বদলানো বা NOT NULL কলাম যোগ করা এক ধাপে করলে পুরোনো কোড ভাঙে। পর্ব ২৩-এ দেখা expand–contract (parallel change নামেও পরিচিত) কাজটিকে ভাঙে নিরাপদ ছোট ধাপে।
উদাহরণ: products.price REAL (টাকা, দশমিক) — floating point-এ পয়সার হিসাব ভুল হতে পারে (পর্ব ১৩)। লক্ষ্য: পূর্ণসংখ্যায় পয়সা, price_paisa INTEGER NOT NULL, আর শেষে price বাদ।
| কলাম | |
| অ্যাপ পড়ে | |
| অ্যাপ লেখে | |
| NULL-ওয়ালা সারি | |
| পুরোনো কোড চলবে? |
- Expand. শুধু যোগ, কিছু বাদ নয়। nullable কলাম, default ছাড়া — PostgreSQL-এ এটি কেবল catalog-এর বদল, টেবিল আবার লেখা হয় না, তাই মুহূর্তেই শেষ। পুরোনো কোড নতুন কলাম জানেই না, তবু চলে।
- দুই দিকে লেখা। নতুন কোড দুটি কলামেই লেখে, যাতে এখন থেকে আসা সারি সম্পূর্ণ থাকে। যদি পুরোনো সার্ভারও তখনো লিখতে থাকে, ডেটাবেসের একটি trigger (পর্ব ২৯) দুটিকে মিলিয়ে রাখতে পারে।
- Backfill. পুরোনো সারিগুলো ভরা। এক UPDATE-এ ১ কোটি সারি মানে বিশাল transaction, দীর্ঘ lock আর WAL-এর বন্যা; তাই primary key-র পরিসর ধরে হাজার হাজার সারির ব্যাচে, মাঝে বিরতি দিয়ে। শেষে যাচাই — একটিও NULL নেই।
- পড়া বদল। এখন অ্যাপ নতুন কলাম পড়ে। এটি আলাদা release হওয়ায় সমস্যা দেখা দিলে আগের কোডে ফেরা যায়, কারণ পুরোনো কলাম এখনো সঠিক।
- নিয়ম কড়া করা। সরাসরি
SET NOT NULLPostgreSQL-এ পুরো টেবিল পড়ে, ACCESS EXCLUSIVE lock ধরে। নিরাপদ পথ: আগেNOT VALIDCHECK (নতুন সারিতে খাটে, পুরোনো পরীক্ষা হয় না), পরেVALIDATE(হালকা lock-এ পরীক্ষা), তারপর PostgreSQL 12+ সেই CHECK দেখে scan ছাড়াই NOT NULL দেয়। - Contract. পুরোনো কলাম ছোঁয় এমন কোনো কোড আর চলছে না — নিশ্চিত হয়ে তবেই DROP. এই ধাপ উল্টানো যায় না (ডেটা চলে গেছে), তাই সবার শেষে, আলাদা release-এ।
expand আর backfill লাইভ দেখুন। backfill-এ দুটি ব্যাচ, তারপর contract-এর আগের যাচাই:
-- M1 expand
ALTER TABLE products ADD COLUMN price_paisa INTEGER;
-- M2 backfill, ব্যাচে (বাস্তবে হাজার সারি করে)
UPDATE products SET price_paisa = CAST(ROUND(price * 100) AS INTEGER)
WHERE price_paisa IS NULL AND product_id BETWEEN 101 AND 105;
UPDATE products SET price_paisa = CAST(ROUND(price * 100) AS INTEGER)
WHERE price_paisa IS NULL AND product_id BETWEEN 106 AND 110;
-- contract-এর আগে যাচাই
SELECT COUNT(*) AS still_null FROM products WHERE price_paisa IS NULL;
SELECT product_id, price, price_paisa FROM products WHERE product_id IN (101, 109);ALTER-এ ✓, দুটি UPDATE-এ ৫টি করে সারি বদলেছে, still_null = 0, আর 101 | 15000 | 1500000, 109 | 260 | 26000. WHERE price_paisa IS NULL শর্তটি backfill-কে idempotent করে: মাঝপথে থেমে গেলে আবার চালালে শুধু বাকি সারি ধরে। ROUND আগে দেওয়ার কারণ: দশমিক দাম ১০০ দিয়ে গুণ করলে floating point-এ ৪১৯.৯৯৯…-এর মতো মান আসতে পারে, আর CAST শুধু কেটে ফেলে।
backfill চলার সময়েও পুরোনো কোড শুধু price লিখতে পারে। দুই কলাম মেলানোর একটি পথ — ক্ষণস্থায়ী sync trigger:
ALTER TABLE products ADD COLUMN price_paisa INTEGER;
CREATE TRIGGER products_sync_paisa AFTER UPDATE OF price ON products
BEGIN
UPDATE products SET price_paisa = CAST(ROUND(NEW.price * 100) AS INTEGER)
WHERE product_id = NEW.product_id;
END;
-- পুরোনো অ্যাপ শুধু price বদলায়
UPDATE products SET price = 1150 WHERE product_id = 102;
SELECT product_id, price, price_paisa FROM products WHERE product_id IN (102, 103);ফল: 102 | 1150 | 115000 — পুরোনো কোড না জেনেও নতুন কলাম হালনাগাদ রাখল। কিন্তু 103 | 1800 | NULL: যে সারি কেউ ছোঁয়নি, trigger তাতে কিছু করেনি। trigger শুধু নতুন বদল ধরে, পুরোনো সারির জন্য backfill লাগবেই। contract ধাপে trigger-টিও মুছে ফেলতে হবে — নইলে price কলাম মোছার পর এটি ভাঙবে।
SET lock_timeout = '3s'; -- lock না পেলে অপেক্ষা না করে ব্যর্থ হও
ALTER TABLE products
ADD CONSTRAINT price_paisa_not_null CHECK (price_paisa IS NOT NULL) NOT VALID;
ALTER TABLE products VALIDATE CONSTRAINT price_paisa_not_null;
ALTER TABLE products ALTER COLUMN price_paisa SET NOT NULL; -- PG 12+: scan নেই
ALTER TABLE products DROP CONSTRAINT price_paisa_not_null;
-- transaction-এর বাইরে চালাতে হয় (Alembic: autocommit_block, Django: atomic = False)
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders (customer_id);lock_ কেন? ALTER-এর lock পেতে সেটি চলমান দীর্ঘ query-র পেছনে লাইনে দাঁড়ায়, আর তার পেছনে বাকি সব query আটকে যায় — এক মুহূর্তের ALTER-ও পুরো সাইট থামিয়ে দিতে পারে। ব্যর্থ হয়ে পরে আবার চেষ্টা করা ভালো। MySQL-এ নতুন সংস্করণগুলোতে অনেক ALTER ALGORITHM=INSTANT বা online DDL-এ চলে; বড় টেবিলে GitHub-এর gh-ost বা Percona-র pt-online-schema-change-এর মতো online schema change tool ব্যবহার হয়।
Schema migration বনাম data migration
Data migration ডেটার রূপ বদলায় — backfill, একটি কলাম দুই ভাগ করা, পুরোনো status কোড নতুন নামে আনা। দুটি আলাদা রাখুন: schema migration ছোট আর দ্রুত, data migration দীর্ঘ আর ব্যাচে, প্রায়ই আলাদা job হিসেবে। data migration-এ ORM-এর বর্তমান মডেল import করবেন না — এক বছর পরে মডেল বদলে গেলে পুরোনো migration ভেঙে যাবে। Django তাই data migration-এ apps.get_model() দিয়ে সেই সময়ের “ঐতিহাসিক” মডেল দেয়; Alembic-এ সাধারণ পরামর্শ সরাসরি SQL বা Core ব্যবহার করা।
৮ · দলে migration — conflict, rollback, seeding আর পরিবেশ
| main-এর head | |
| staging DB | |
| deploy সম্ভব? |
- দুই branch, একই parent. Alice আর Bob দুজনেই ০০০৭-এর ওপর নিজের migration লিখেছেন, দুজনের নম্বর ০০০৮. আলাদাভাবে দুটিই ঠিক।
- প্রথম merge. Alice-এর migration main-এ ঢুকে staging-এ চলেছে। এখনো কোনো সমস্যা নেই।
- দ্বিতীয় merge — শিকলে শাখা। git-এ কোনো conflict নেই (ফাইলের নাম আলাদা), কিন্তু migration-এর শিকলে দুটি শেষ মাথা। কোনটি আগে চলবে, tool জানে না, তাই থামে। ক্রমিক সংখ্যার বদলে timestamp নাম (Rails-এর
20251012093000_...) দিলে নামে সংঘাত কমে, কিন্তু যৌক্তিক সংঘাত — দুজন একই কলাম বদলালে — থেকেই যায়। - সমাধান। সহজতম: পরে merge হওয়া migration-কে নতুনটির পরে বসানো (rebase). অথবা একটি খালি “merge migration”, যা দুই শাখা জোড়ে। দুই ক্ষেত্রেই দেখুন দুটি migration একে অপরের সাথে আসলে খাপ খায় কি না।
- আটকানোর অভ্যাস। CI-তে একটি পরীক্ষা: head একটিই কি না, আর খালি ডেটাবেসে সব migration শুরু থেকে চলে কি না। সাথে মডেল আর migration-এর মিল (Django-র
makemigrations --check).
Rollback — “down” কেন প্রায়ই কাগুজে
কাগজে প্রতিটি up-এর একটি down আছে। বাস্তবে: DROP COLUMN-এর down কলাম ফিরিয়ে আনে, কিন্তু ডেটা নয়। নতুন কলামে ঘণ্টাখানেক ধরে যে ডেটা লেখা হয়েছে, down চালালে তা হারায়। data migration (দুটি কলাম জোড়া, মান রূপান্তর) প্রায়ই উল্টানো অসম্ভব। আর down migration কেউ নিয়মিত চালায় না, তাই সেগুলো প্রায়ই পরীক্ষাহীন — বিপদের দিনে নিজেই ভাঙে। Prisma Migrate তাই নিজে থেকে down migration বানায় না, আর Flyway-এর undo শুধু পেইড সংস্করণে।
বাস্তবের নীতি roll forward: ভুল হলে আরেকটি নতুন migration দিয়ে সামনে এগিয়ে ঠিক করা। expand–contract এটি সহজ করে, কারণ শেষ ধাপের আগে প্রতিটি ধাপে পুরোনো কোড চলে — কোড ফেরানো যায়, schema ফেরাতে হয় না। আর বড় migration-এর আগে backup, যা থেকে restore পরীক্ষা করা (পর্ব ৫২)।
Seeding আর তিন পরিবেশ
Seeding মানে ডেটাবেসে শুরুর ডেটা বসানো। দুই রকম: reference data (বিভাগের তালিকা, ভূমিকা, দেশের কোড), যা সব পরিবেশে লাগে — এটি migration-এ রাখা যায়; আর নমুনা বা পরীক্ষার ডেটা (বানানো গ্রাহক, অর্ডার), শুধু dev আর test-এ — Django fixture, Rails db/seeds.rb, Laravel seeder, prisma db seed, আর factory_boy বা Faker-এর মতো library দিয়ে। production-এ বানানো ডেটা কখনো নয়।
| পরিবেশ | কে কী করে | migration-এর নিয়ম |
|---|---|---|
| dev (ল্যাপটপ) | নতুন migration বানানো, ডেটাবেস মুছে নতুন করা চলে | makemigrations, alembic revision --autogenerate, prisma migrate dev — তৈরি খসড়া হাতে পড়ে দেখা |
| staging | production-এর মতো schema, যতটা সম্ভব বাস্তব আকারের (গোপনীয়তা রক্ষা করা) ডেটা | ঠিক production-এর কমান্ডে চালানো; সময় মাপা — ১০ সারিতে ১ ms, ১ কোটিতে কত? |
| production | কেউ হাতে schema বদলায় না | শুধু review হওয়া ফাইল: alembic upgrade head, prisma migrate deploy, flyway migrate; আগে backup |
Autogenerate-এর ফাঁদ: মডেলে একটি কলামের নাম বদলালে Alembic-এর autogenerate সেটিকে “একটি কলাম মোছা + একটি নতুন যোগ” হিসেবে দেখে — চালালে কলামের সব ডেটা হারাবে। Django makemigrations জিজ্ঞেস করে “Did you rename …?”, কিন্তু সবসময় ধরতে পারে না। তৈরি করা প্রতিটি migration মানুষের চোখে পড়ে তবে commit করুন।
আরও গভীরে: unit of work-এর flush একটি topological sort (পর্ব ৪৫-এর precedence graph-এর মতো DAG): কোন টেবিলের সারি আগে ঢুকবে, তা foreign key-র নির্ভরতা দেখে ঠিক হয়, আর নিজেকেই নির্দেশ করা সম্পর্কে (self-reference) সারি ধরে সাজাতে হয়। Hibernate-এর dirty checking default-এ flush-এর সময় প্রতিটি managed entity-কে snapshot-এর সাথে তুলনা করে — হাজারো entity-র session-এ এটিই ধীর অংশ; bytecode enhancement দিয়ে বদলের মুহূর্তেই চিহ্ন রাখা যায়। আর query চালানোর আগে স্বয়ংক্রিয় flush (Hibernate-এর FlushMode.AUTO, SQLAlchemy-র autoflush) থাকে বলেই একই transaction-এ নিজের অসংরক্ষিত বদল query-র ফলে দেখা যায়।
৯ · AI-এর যুগে ORM আর migration
AI ORM-এর কোড চমৎকার লেখে — দেখতে পরিষ্কার, চলেও। কিন্তু ভুলগুলো ঠিক সেখানে, যেখানে কোড দেখে SQL বোঝা যায় না:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
| লুপের ভেতরে সম্পর্ক ছোঁয়া, eager loading ছাড়া | N+1 — ছোট ডেটায় দ্রুত, production-এ ধীর | SQL লগ চালু করে query গুনুন |
পুরোনো আর নতুন API মেশানো (SQLAlchemy 1.x-এর Query.get(), যা 2.0-তে পুরোনো বলে চিহ্নিত) | সতর্কবার্তা, ভবিষ্যতে ভাঙা কোড | নিজের সংস্করণের ডকুমেন্টেশন মিলিয়ে নিন |
Django-তে উল্টো দিকের সম্পর্কে select_related | ত্রুটি, বা ভুল ধারণায় অন্য সমাধান | এক দিক = select_related, অনেক দিক = prefetch_related |
| বড় টেবিলে NOT NULL কলাম সরাসরি যোগ, বা নাম বদল এক ধাপে | দীর্ঘ lock, পুরোনো কোড ভাঙা | expand–contract-এর ধাপে ভাগ করতে বলুন |
production-এ prisma migrate dev বা হাতে ALTER | ডেটাবেস রিসেটের ঝুঁকি, schema drift | শুধু deploy কমান্ড, review হওয়া ফাইল |
| অনুমান করা কলাম বা মডেলের নাম | migration বা query ত্রুটি | আসল schema (মডেল ফাইল, \d, PRAGMA table_info) AI-কে দিন |
AI-কে মডেল, সংস্করণ, ডেটার আকার আর পাঠানো SQL দেখতে চান — সব বলুন:
AI-এর ORM কোড বা migration মানার আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- N+1 সমস্যা কী, আর কীভাবে সারাবেন? — lazy loading-এ ১টি তালিকা query + প্রতি সারিতে ১টি; সমাধান JOIN বা IN দিয়ে eager loading.
- Lazy আর eager loading-এর পার্থক্য? — lazy প্রথম ব্যবহারে আনে, eager মূল query-র সাথেই; লুপে lazy মানে অনেক round trip.
- Identity map আর unit of work কী? — এক session-এ এক সারি = এক object; বদল জমিয়ে flush-এ একসাথে, একটি transaction-এ।
- Migration টেবিলের কাজ? — কোন সংস্করণ চলেছে তা রাখা, যাতে প্রতিটি migration প্রতিটি ডেটাবেসে ঠিক একবার, ক্রমে চলে।
- Downtime ছাড়া কলামের নাম বদল? — expand–contract: নতুন কলাম, দুই দিকে লেখা, backfill, পড়া বদল, পুরোনো মোছা।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Ted Neward ২০০৬ সালে ORM-কে বলেছিলেন “কম্পিউটার বিজ্ঞানের ভিয়েতনাম”। এই কঠোর তুলনার যুক্তি কী, আর তবু ORM টিকে গেল কেন?
Ted Neward-এর “The Vietnam of Computer Science” লেখাটির মূল যুক্তি: ORM প্রকল্প শুরু হয় সহজ সাফল্য দিয়ে — সাধারণ CRUD চমৎকার চলে। তারপর প্রতিটি নতুন চাহিদা (inheritance, জটিল query, গতি, একসাথে অনেক ব্যবহারকারী) একটু একটু বেশি জটিলতা টেনে আনে। ফেরার কোনো স্পষ্ট মুহূর্ত থাকে না, কারণ ততদিনে পুরো অ্যাপ ORM-এর ওপর দাঁড়িয়ে। তিনি দেখান impedance mismatch-এর কয়েকটি স্তর: object-এর পরিচয় বনাম primary key, inheritance বনাম টেবিল, object-এর graph ধরে চলা বনাম সেট-ভিত্তিক query. কোনোটিরই পুরো সমাধান নেই, শুধু কোনো না কোনো আপস।
২০১২ সালে Martin Fowler “OrmHate” লেখায় ভারসাম্যপূর্ণ উত্তর দেন। তাঁর যুক্তি: সমস্যাটি আসলেই কঠিন, আর ORM যা করে, তা নিজে হাতে লিখলে তার চেয়ে বেশি কোড আর বেশি ভুল হতো। সমস্যা দেখা দেয় যখন ORM-কে দিয়ে ডেটাবেস “লুকানোর” চেষ্টা হয় — ডেভেলপার ভাবেন SQL না জানলেও চলবে। আসল মূল্য ORM-এর হাতে-লেখা অনুবাদের একঘেয়ে কাজ বাঁচানোয়, ডেটাবেসকে অদৃশ্য করায় নয়।
ORM টিকে গেল কারণ বেশিরভাগ অ্যাপের বেশিরভাগ query সাধারণ: একটি সারি আনা, কয়েকটি বদলানো, সম্পর্ক ধরে চলা। এই অংশে ORM-এর লাভ বিশাল — দ্রুত উন্নয়ন, স্বয়ংক্রিয় parameter, migration-এর ভিত্তি। জটিল রিপোর্ট বা ব্যস্ত পথে অভিজ্ঞ দল raw SQL লেখে, আর প্রায় সব ORM সেই পথ খোলা রাখে। Neward-এর সতর্কবার্তা ভুল নয়; শুধু বাস্তবের উত্তর হলো “সব জায়গায় ORM নয়”, “ORM কখনো নয়” নয়।
মূল উপলব্ধি: ORM একটি সুবিধার স্তর, ডেটাবেসের বিকল্প নয়। ৮০% কাজে ORM, বাকি ২০%-এ SQL — আর দুটোই বোঝা — এই আপসই টিকে আছে।
প্র ০২Rails-এর Active Record আর Hibernate বা SQLAlchemy-র Data Mapper — দুই নকশার আসল পার্থক্য কী, আর কোন ধরনের অ্যাপে কোনটি ভালো?
দুটি নামই Martin Fowler-এর ২০০২ সালের বই থেকে। Active Record-এ একটি class একসাথে দুই কাজ করে: ব্যবসার নিয়ম ধরে রাখে, আর নিজেকে ডেটাবেসে পড়ে-লেখে (order.save(), Order.find(5)). ২০০৪ সালে David Heinemeier Hansson-এর Ruby on Rails এই নকশাকে বিখ্যাত করে — কয়েক লাইনে পুরো CRUD অ্যাপ। Laravel-এর Eloquent একই ধারা, Django-র মডেলও অনেকটা তাই।
Data Mapper-এ ডোমেইনের class ডেটাবেস সম্পর্কে কিছুই জানে না; আলাদা একটি স্তর (Hibernate-এর EntityManager, SQLAlchemy-র Session) mapping আর সংরক্ষণ সামলায়। ফলে class-এর গঠন আর টেবিলের গঠন আলাদা হতে পারে — একটি class দুই টেবিলে, value object-এর কলাম আরেক টেবিলের ভেতরে। ডোমেইনের নিয়ম ডেটাবেস ছাড়াই পরীক্ষা করা যায়। দাম হলো বেশি ধারণা শেখা: session, flush, object-এর অবস্থা (transient, persistent, detached).
Active Record সবচেয়ে ভালো যেখানে টেবিল আর ব্যবসার ধারণা প্রায় এক — ব্লগ, দোকানের অ্যাডমিন, ছোট SaaS. সেখানে Data Mapper-এর বাড়তি স্তর অপ্রয়োজনীয় আনুষ্ঠানিকতা। Data Mapper ভালো জটিল ডোমেইনে — ব্যাংকিং, বিমা, বড় ERP — যেখানে নিয়ম বেশি আর ডেটাবেসের গঠন ইতিহাসের কারণে অদ্ভুত (পুরোনো legacy schema). ভালো খবর: পার্থক্য ধীরে ধীরে কমছে। SQLAlchemy-র declarative মডেল দেখতে Active Record-এর মতো, আর Rails-এ বড় দল service object দিয়ে নিয়ম মডেল থেকে সরায়।
দুই নকশাতেই এই পর্বের ফাঁদগুলো একই: N+1, over-fetching, লুপে লেখা। Active Record-এ save() সহজ বলে লুপে save()-ও সহজ — unit of work না থাকায় প্রতিটি আলাদা UPDATE. Data Mapper-এর unit of work অন্তত বদলগুলো এক flush-এ জড়ো করে।
মূল উপলব্ধি: প্রশ্নটি স্বাদের নয়, ডোমেইনের জটিলতার। টেবিল = ধারণা হলে Active Record; ধারণা টেবিল থেকে স্বাধীন হলে Data Mapper.
প্র ০৩N+1 এত পরিচিত সমস্যা, তবু প্রায় সব ORM-এর default lazy loading কেন? আর ORM-গুলো সমস্যাটি আটকাতে কী কী যোগ করেছে?
lazy default হওয়ার কারণ সরল: ORM জানে না আপনি কী ব্যবহার করবেন। Customer-এর সাথে orders, addresses, reviews, payments — সব সম্পর্ক eager হলে একটি গ্রাহক আনতেই পুরো ডেটাবেসের অর্ধেক চলে আসত, আর সম্পর্কের সম্পর্ক ধরলে সীমাই নেই। lazy অন্তত সঠিক: যা ছোঁবেন, তা আসবে। ভুলটা সঠিকতার নয়, গতির — আর গতির ভুল ছোট ডেটায় দেখা যায় না। developer-এর ল্যাপটপে ১০ গ্রাহক, ১১টি query, কেউ টের পায় না; production-এ ১০,০০০।
JPA এখানে একটি অসংগতির উদাহরণ: @ManyToOne-এর default EAGER. ফলে Order আনলে তার customer-ও আসে — একটি করে আলাদা query-তে, যদি JPQL-এ fetch join না থাকে। তাই Hibernate-এর অভিজ্ঞ ব্যবহারকারীরা প্রায়ই সব সম্পর্ক LAZY করে, তারপর প্রতিটি use case-এ fetch join বা entity graph দিয়ে ঠিক যা লাগে তা আনে।
ORM আর সম্প্রদায় সময়ের সাথে সতর্কতার যন্ত্র যোগ করেছে। Rails-এর জন্য Bullet gem অনেক বছর ধরে development-এ N+1 দেখলেই সতর্ক করে; Rails 6.1 এনেছে strict_loading, যা lazy load-কে ত্রুটি বানায়। Laravel-এ Model::. SQLAlchemy-তে lazy="raise" বা raiseload(). Django-তে টেস্টে assertNumQueries, আর django-debug-toolbar প্রতিটি পাতার query দেখায়। Prisma শুরু থেকেই lazy loading-ই রাখেনি — সম্পর্ক চাইলে নিজে বলতে হয়।
দলের অভ্যাসে সবচেয়ে কাজের নিয়ম: গুরুত্বপূর্ণ API-র টেস্টে query-র সংখ্যা বেঁধে দেওয়া। কেউ নতুন ফিল্ড যোগ করে N+1 ঢোকালে টেস্ট লাল হয়, production-এ পৌঁছানোর আগেই।
মূল উপলব্ধি: lazy loading ভুল নয়, অদৃশ্য। সমাধান তাই দৃশ্যমান করা — লগ, query গোনা টেস্ট, আর lazy load-কে ত্রুটি বানানো।
প্র ০৪বড় প্রতিষ্ঠানগুলো চালু সিস্টেমে কোটি সারির টেবিল কীভাবে বদলায়? GitHub-এর gh-ost বা Stripe-এর মতো প্রতিষ্ঠানের লেখা অভিজ্ঞতা থেকে কী শিখি?
সমস্যার মূল lock. পুরোনো MySQL-এ অনেক ALTER TABLE পুরো টেবিল কপি করত আর সেই সময় লেখা আটকে রাখত — কোটি সারিতে ঘণ্টার পর ঘণ্টা। তাই এল online schema change tool: Percona-র pt-online-schema-change একটি নতুন খালি টেবিল নতুন গঠনে বানায়, পুরোনো থেকে ব্যাচে সারি কপি করে, trigger দিয়ে চলমান বদল নতুনটিতেও পাঠায়, শেষে এক মুহূর্তে নাম বদলে জায়গা অদলবদল করে।
GitHub ২০১৬ সালে gh-ost প্রকাশ করে, একই ধারণার ভিন্ন রূপ: trigger-এর বদলে MySQL-এর binary log পড়ে চলমান বদল কপি করে। কারণ, তাদের অভিজ্ঞতায় ব্যস্ত টেবিলে trigger মূল লেখার পথেই বাড়তি ভার আর lock-এর প্রতিযোগিতা আনত। gh-ost কাজ থামানো, গতি কমানো আর শেষ মুহূর্তের অদলবদল নিজের হাতে রাখতে দেয়। এখানে মূল শিক্ষা — বড় migration একটি নিয়ন্ত্রিত প্রক্রিয়া, একটি কমান্ড নয়।
Stripe-এর ইঞ্জিনিয়ারিং ব্লগে ২০১৭ সালের দিকে “Online migrations at scale” লেখায় চার ধাপের একটি নকশা বর্ণনা করা হয়: দুই জায়গায় লেখা (dual write), পড়া নতুন জায়গা থেকে, লেখা শুধু নতুনে, পুরোনো মোছা — মাঝে backfill আর দুই জায়গার ডেটা মিলিয়ে দেখা। এটি এই পর্বের expand–contract-এরই রূপ, কেবল এক কলামের বদলে পুরো ডেটা মডেলের জন্য। যেখানে ভুলের দাম টাকা, সেখানে প্রতিটি ধাপ আলাদা করে মাপা যায় আর ফেরানো যায় — এটিই আকর্ষণ।
PostgreSQL-এর দিকেও উন্নতি এসেছে: সংস্করণ ১১ থেকে স্থির default-ওয়ালা কলাম যোগে টেবিল আবার লেখা হয় না, আর CREATE INDEX CONCURRENTLY, NOT VALID constraint অনেকদিন ধরেই আছে। MySQL 8.0-তেও instant ADD COLUMN এসেছে।
মূল উপলব্ধি: বড় টেবিলে schema বদল ডেটাবেসের প্রশ্ন যতটা, তার চেয়ে বেশি অপারেশনের — ছোট উল্টানো যায় এমন ধাপ, ব্যাচে কাজ, মাপা আর যাচাই।
প্র ০৫নতুন প্রকল্পে ORM, query builder না raw SQL? sqlc, jOOQ বা Drizzle-এর মতো “SQL-কে কেন্দ্রে রাখা” tool-এর জনপ্রিয়তা কী বলে?
গত কয়েক বছরে একটি স্পষ্ট ঝোঁক দেখা যায়: SQL লুকানোর বদলে SQL-কে নিরাপদ করা। Go-তে sqlc নিজে লেখা SQL ফাইল পড়ে, schema-র সাথে মিলিয়ে type-যুক্ত Go ফাংশন বানায় — ভুল কলামের নাম compile-এর সময়ই ধরা পড়ে। Java-য় jOOQ ডেটাবেস থেকে class বানিয়ে SQL-এর মতো দেখতে type-safe query লিখতে দেয়। TypeScript-এ Kysely আর Drizzle query builder-কে type দিয়ে মজবুত করে; Drizzle-এর মূলমন্ত্রই “SQL জানলে Drizzle জানেন”।
এর কারণ ORM-এর ফাঁদগুলো: লুকানো query, গতির অপ্রত্যাশিত সমস্যা, জটিল query-তে ORM-এর ভাষার সাথে লড়াই। SQL-কেন্দ্রিক tool এগুলো এড়ায় — যা লেখেন তা-ই যায়। দাম: সম্পর্ক ধরে চলা, unit of work, change tracking নিজে সামলাতে হয়; CRUD-ভারী অ্যাপে বেশি কোড।
সিদ্ধান্তের কয়েকটি প্রশ্ন। দলের SQL দক্ষতা কেমন? ভালো হলে SQL-কেন্দ্রিক tool-এ সমস্যা কম; দুর্বল হলে ORM দ্রুত শুরু দেয়, কিন্তু পরে N+1-এর বিল আসে। অ্যাপের ধরন কী? ফর্ম-আর-তালিকার অ্যাডমিন অ্যাপে ORM-এর লাভ বেশি; বিশ্লেষণ, রিপোর্ট, জটিল যোগফলের অ্যাপে SQL. ইকোসিস্টেম কী দেয়? Django-র admin, Rails-এর generator ORM-নির্ভর — সেখানে ORM ছাড়া চলা মানে অনেক কিছু হারানো।
বাস্তবে উত্তর প্রায়ই মিশ্র, আর যেটিই বেছে নিন, migration tool আলাদা প্রশ্ন — Flyway বা Liquibase যেকোনো ভাষার সাথে চলে। AI-যুগে একটি নতুন দিকও আছে: AI SQL আর ORM কোড দুটোই লেখে, কিন্তু যাচাইয়ের দায় আপনার। যে স্তরে আপনি নিজে ফল যাচাই করতে পারেন, সেটিই নিরাপদ।
মূল উপলব্ধি: প্রশ্নটি “SQL লিখব কি না” নয় — SQL সবসময় চলছে। প্রশ্ন হলো সেটি কে লিখবে আর আপনি তা কতটা দেখতে পাবেন।
অনুশীলন
একটি কোয়েরি লেখা, একটি query গোনা, একটি migration পরিকল্পনা, একটি migration review.
-
কোয়েরি লিখুন: একটি ORM কোড
for p in products: print(p.name, p.category.name)— প্রতিটি পণ্যের বিভাগ lazy-তে আনে।db-shop-এর ১০টি পণ্যের জন্য একটি query-তে পণ্য আর বিভাগের নাম আনুন, product_id-এর ক্রমে। (অংশ ৪-এর যেকোনো এডিটরে চালাতে পারেন।) ORM-এ এটি কোন কৌশল?SELECT p.name AS product, c.name AS category FROM products p JOIN categories c ON c.category_id = p.category_id ORDER BY p.product_id;১০টি সারি: Mobile Phone, Headphone, Power Bank — Electronics; DSA Book, SQL Book — Books; Panjabi, Saree — Clothing; Rice 5kg, Mustard Oil 1L — Grocery; Smart Watch — Electronics. এটি “এক” দিকের (পণ্য → বিভাগ) সম্পর্ক, তাই JOIN-ভিত্তিক eager loading: SQLAlchemy
joinedload(Product.category), Djangoselect_related("category").products.category_idNULL হতে পারে, তাই ORM সাধারণত LEFT JOIN দেয়; এখানে সব পণ্যের বিভাগ আছে বলে ফল একই। -
Query গুনুন (হাতে trace):
db-shop-এর ১০টি অর্ডারের একটি পাতা:for o in all_orders: print(o.customer.name, len(o.items)). SQLAlchemy, দুটি সম্পর্কই lazy, একটি session. মনে রাখুন, many-to-one-এর lazy load আগে identity map দেখে — object আগেই থাকলে SQL পাঠায় না। ১০টি অর্ডার এসেছে ৭ জন আলাদা গ্রাহকের কাছ থেকে। কয়টি query যাবে? eager loading দিয়ে সর্বনিম্ন কত?অর্ডারের তালিকা ১টি। customer: প্রতিটি আলাদা গ্রাহকের জন্য প্রথমবার ১টি, পরেরবার identity map থেকে — ৭টি (Rahim-এর তিন অর্ডার আর Karima-র দুই অর্ডারে বাকিগুলো বাঁচে)। items: প্রতিটি অর্ডারের তালিকা আলাদা, identity map এখানে সাহায্য করে না (এটি “অনেক” দিক) — ১০টি। মোট ১ + ৭ + ১০ = ১৮. eager:
joinedload(Order.customer)আরselectinload(Order.items)দিলে ২টি (অর্ডার+গ্রাহক JOIN-এ, items একটি IN query-তে); দুটিই JOIN-এ নিলে ১টি, কিন্তু সারি বাড়ে। -
Migration পরিকল্পনা: চালু দোকানের
customers.cityকলামের নাম বদলেdistrictকরতে হবে, downtime ছাড়া, আর অ্যাপ কয়েকটি সার্ভারে চলে। migration আর deploy-এর ধাপগুলো ক্রমে লিখুন। কোন ধাপের পর আর ফেরা যায় না?- M1:
ADD COLUMN district TEXT(nullable). - অ্যাপ v2: দুটোতেই লেখে (বা sync trigger), পড়ে
city. - M2: backfill —
UPDATE customers SET district = city WHERE district IS NULL AND city IS NOT NULL, ব্যাচে; যাচাই: যেখানে city আছে, সেখানে district সমান। - অ্যাপ v3: পড়ে
district, লেখে দুটোই। - অ্যাপ v4: শুধু district লেখে; index থাকলে নতুন কলামে আগেই বানানো।
- M3 (contract): trigger থাকলে মোছা, তারপর
DROP COLUMN city.
শেষ ধাপটিই ফেরানো যায় না। লক্ষ করুন NOT NULL ধাপ এখানে নেই — city নিজেই NULL হতে পারত (Imran-এর শহর NULL). ছোট টেবিলে ও একটিমাত্র সার্ভারে
RENAME COLUMNচলত, কিন্তু একাধিক সার্ভারে পুরোনো কোড সাথে সাথে ভাঙত। - M1:
-
Migration review করুন: একটি PostgreSQL migration, ৩ কোটি সারির
ordersটেবিলে: (ক)ADD COLUMN total NUMERIC NOT NULLdefault ছাড়া; (খ) একই ফাইলেUPDATE orders SET total = ...সব সারিতে; (গ)CREATE INDEX idx_total ON orders(total); (ঘ) downgrade-এDROP COLUMN total. কী কী সমস্যা?- (ক) সারি আছে বলে default ছাড়া NOT NULL কলাম যোগ ব্যর্থ হবে। ঠিক পথ: nullable যোগ, backfill, তারপর NOT VALID CHECK → VALIDATE → SET NOT NULL.
- (খ) ৩ কোটি সারির এক UPDATE: বিশাল transaction, দীর্ঘ row lock, WAL-এর বন্যা আর replica-র পিছিয়ে পড়া; ব্যর্থ হলে সব আবার। আলাদা data migration, ব্যাচে।
- (গ) সাধারণ CREATE INDEX পুরো সময় টেবিলে লেখা আটকায়।
CREATE INDEX CONCURRENTLY, transaction-এর বাইরে, আলাদা migration-এ। - (ঘ) downgrade নিজে ঠিক, কিন্তু মনে রাখুন এটি নতুন লেখা সব total মুছে দেবে — বাস্তবে roll forward-ই পরিকল্পনা। আর সব ধাপের আগে
lock_timeout.
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমচালু সিস্টেমে একটি নতুন NOT NULL কলাম দিয়ে পুরোনো কলাম বদলানোর (expand–contract) ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- নতুন কলাম nullable হিসেবে যোগ করা
- অ্যাপ দুই কলামেই লেখে এমন সংস্করণ deploy করা
- পুরোনো সারিগুলো ব্যাচে backfill করে NULL নেই নিশ্চিত করা
- অ্যাপ নতুন কলাম থেকে পড়ে এমন সংস্করণ deploy করা
- নতুন কলামে NOT NULL চাপানো
- পুরোনো কলাম মুছে ফেলা
একটি পাতায় ৫০ জন গ্রাহকের নাম আর প্রত্যেকের অর্ডারের তালিকা দেখানো হয়। ORM-এ orders সম্পর্কটি lazy, আর কোডে কোনো eager loading নেই। পাতাটি দেখাতে মোট কয়টি query যাবে?
- ১টি
- ২টি
- ৫১টি
- প্রতিটি অর্ডারের জন্য একটি করে
Django-তে Customer মডেলের সাথে Order-এর foreign key আছে (একজন গ্রাহকের অনেক অর্ডার)। গ্রাহকের তালিকা আনার সময় প্রত্যেকের অর্ডার একটি অতিরিক্ত query-তে আনতে কোনটি ব্যবহার করবেন?
select_related("orders")prefetch_related("orders")only("orders")values("orders")
একটি ORM session-এ একই primary key-র সারি দুবার আনা হলো। identity map থাকলে কী ঘটে?
- দুবারই একই object পাওয়া যায়, আর দ্বিতীয়বার প্রায়ই কোনো SQL লাগে না
- দুটি আলাদা object তৈরি হয়, প্রতিটি নিজের মতো বদলানো যায়
- সারিটি ডেটাবেসে lock হয়ে যায়
- ORM ত্রুটি দেয়, কারণ সারিটি আগেই আনা হয়েছে
একটি migration tool ডেটাবেসের ভেতরে schema_migrations-এর মতো একটি টেবিল রাখে। এর মূল কাজ কী?
- প্রতিটি migration-এর আগে টেবিলের ডেটার কপি রাখা
- ORM-এর মডেলের সংজ্ঞা সংরক্ষণ করা
- প্রতিটি query-র লগ রাখা
- কোন migration চলেছে তা রাখা, যাতে প্রতিটি ডেটাবেসে প্রতিটি migration ঠিক একবার, ক্রমে চলে
একটি migration-এ দুটি DDL statement আছে, আর দ্বিতীয়টি ব্যর্থ হলো। পুরো migration একটি transaction-এ মোড়ানো। কোন DBMS-এ প্রথম statement-এর বদল থেকে যাওয়ার সম্ভাবনা সবচেয়ে বেশি?
- PostgreSQL
- SQLite
- MySQL
- তিনটিতেই সমান — DDL কখনো rollback হয় না
production-এ একটি কলাম মোছার migration চলেছে, আর তারপর কয়েক ঘণ্টা নতুন ডেটা লেখা হয়েছে। এখন সমস্যা দেখা দিল। “down” migration চালানোর বিরুদ্ধে সবচেয়ে শক্ত যুক্তি কোনটি?
- migration tool কখনো down চালাতে পারে না
- down কলাম ফেরায় কিন্তু মোছা ডেটা ফেরায় না, তাই নতুন migration দিয়ে সামনে এগিয়ে সারানোই বাস্তব
- down migration সবসময় up-এর চেয়ে ধীর
- down চালালে migration-এর হিসাবের টেবিল নষ্ট হয়ে যায়
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- ORM অনুবাদক: class ↔ টেবিল; session-এর identity map-এ এক সারি = এক object, change tracking বদল ধরে, unit of work flush-এ সব SQL একটি transaction-এ পাঠায়।
- N+1: লুপে lazy load = ১ + N query. “এক” দিক JOIN-এ (joinedload, select_related, join fetch), “অনেক” দিক IN query-তে (selectinload, prefetch_related, include).
- SQL দেখুন: ORM-এর লগ, EXPLAIN, foreign key-তে index; গোনা আর bulk কাজ ডেটাবেসে; read-modify-write-এ lost update থেকে সাবধান; এক request = এক session.
- Migration: ক্রমিক, review হওয়া, বারবার চালানো নিরাপদ স্ক্রিপ্ট; হিসাব থাকে একটি টেবিলে (checksum সহ)। চলে যাওয়া migration বদলাবেন না; MySQL-এ DDL implicit commit করে।
- চালু সিস্টেমে: expand → দুই দিকে লেখা → backfill → পড়া বদল → NOT NULL → contract. down-এর চেয়ে roll forward; conflict ধরতে CI-তে একটি head; seed ডেটা শুধু dev/test-এ।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৫১ · ডেটাবেস নিরাপত্তা — SQL injection প্রতিরোধ, privilege আর encryptionপরবর্তী পর্বparameter, least privilege আর এনক্রিপশন।
- পর্ব ৪৯ · অ্যাপ থেকে ডেটাবেস — driver, prepared statement আর connection poolফিরে দেখুনORM-এর নিচের স্তর।
- পর্ব ২৩ · ALTER TABLE, constraint আর referential actionফিরে দেখুনschema বদলের SQL আর expand–contract-এর শুরু।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — ১০টি মডিউল।
- Python · SQLAlchemy ORMসম্পর্কিত কোর্সমডেল, session আর relationship হাতে-কলমে।
- Java · Spring Data JPAসম্পর্কিত কোর্সHibernate, entity আর repository.