Sửa lỗi 'MongoError: Cannot use a session that has ended' trong MongoDB Transactions

intermediate🍃 MongoDB2026-04-10| Node.js (v14+), MongoDB (v4.0+ Replica Set), Mongoose (v5.x/v6.x/v7.x)

Error Message

MongoError: Cannot use a session that has ended
#mongodb#session#transaction#mongoose#nodejs

Phân tích lỗi

Quản lý tính toàn vẹn dữ liệu với các transaction (giao dịch) trong MongoDB rất mạnh mẽ, nhưng nó thường dẫn đến một rào cản cụ thể và gây khó chịu: MongoError: Cannot use a session that has ended. Lỗi này xuất hiện khi ứng dụng Node.js của bạn cố gắng thực hiện một truy vấn bằng ClientSession đã được đóng bởi session.endSession(). Về cơ bản, bạn đang cố gắng sử dụng một công cụ đã được cất đi.

Bạn có thể thấy một stack trace tương tự như sau:

MongoError: Cannot use a session that has ended
    at ClientSession.applySession (/node_modules/mongodb/lib/core/sessions.js:145:12)
    at Collection.insertOne (/node_modules/mongodb/lib/collection.js:456:15)
    at /app/services/orderService.js:42:24

Các nguyên nhân gốc rễ phổ biến

Tôi đã dành nhiều giờ để lục lọi các bản ghi log production để tìm hiểu tại sao các session này lại kết thúc sớm. Hầu như mọi lúc, vấn đề đều nằm ở cách event loop bất đồng bộ tương tác với vòng đời của session. Dưới đây là những "thủ phạm" thường gặp nhất:

1. Cuộc đua Promise "không được await"

JavaScript sẽ không đợi cơ sở dữ liệu của bạn chỉ vì bạn đang ở trong một transaction. Nếu bạn bỏ lỡ một từ khóa await duy nhất bên trong khối transaction, engine sẽ bỏ qua và chuyển thẳng đến khối finally. Mã của bạn có thể đạt đến session.endSession() trong vòng chưa đầy 1ms, trong khi hoạt động của cơ sở dữ liệu vẫn đang mất 20-50ms để truyền qua mạng. Session bị đóng, truy vấn đến muộn và driver sẽ ném ra lỗi.

2. Dọn dẹp quá sớm trong logic dùng chung

Quản lý session thủ công rất dễ bị lỗi. Nếu bạn truyền một session vào một hàm tiện ích cũng có khối dọn dẹp try/finally riêng, hàm con đó có thể đóng session trước khi hàm cha hoàn thành. Điều này tạo ra một cơn ác mộng về "quyền sở hữu chung" (shared ownership), nơi hàm đầu tiên kết thúc sẽ làm hỏng toàn bộ chuỗi xử lý.

3. Tái sử dụng Session sau khi Abort

Việc hủy (abort) một transaction sẽ làm thay đổi trạng thái nội bộ của session. Nếu logic của bạn bắt được lỗi và sau đó cố gắng chạy một truy vấn dọn dẹp bằng chính đối tượng session đó mà không khởi động lại transaction, MongoDB sẽ từ chối truy vấn đó vì không hợp lệ.

Cách khắc phục lỗi

Cách 1: Sử dụng helper withTransaction (Khuyên dùng)

Hãy coi withTransaction là chế độ "an toàn là trên hết" cho MongoDB. Kể từ Mongoose v5.2.0, helper này đã trở thành tiêu chuẩn để tránh các lỗi liên quan đến vòng đời. Nó tự động xử lý các logic bắt đầu (start), xác nhận (commit) và hủy (abort). Quan trọng nhất, nó đảm bảo session chỉ kết thúc sau khi tất cả các promise bên trong đã được giải quyết hoàn toàn.

const mongoose = require('mongoose');

async function updateInventoryAndCreateOrder(orderData) {
  const session = await mongoose.startSession();
  
  try {
    // withTransaction xử lý vòng đời và thử lại các lỗi tạm thời
    await session.withTransaction(async () => {
      const product = await mongoose.model('Product')
        .findOne({ _id: orderData.productId })
        .session(session);

      if (product.stock < orderData.quantity) {
        throw new Error('Out of stock');
      }

      product.stock -= orderData.quantity;
      await product.save({ session });

      await mongoose.model('Order').create([orderData], { session });
    });
  } catch (err) {
    console.error('Transaction aborted:', err.message);
  } finally {
    session.endSession();
  }
}

Cách 2: Kỷ luật nghiêm ngặt khi xử lý Transaction thủ công

Nếu quy trình làm việc của bạn quá phức tạp đối với withTransaction, bạn phải cực kỳ cẩn thận với các từ khóa await. Mọi thao tác đơn lẻ—ngay cả việc ghi log hoặc các bản cập nhật nhỏ—đều phải được await trước khi bạn đi tới khối dọn dẹp.

const session = await mongoose.startSession();
session.startTransaction();

try {
  await User.updateOne({ _id: userId }, { $inc: { balance: -10 } }, { session });
  
  const logEntry = new Log({ action: 'purchase', userId });
  // NGUY HIỂM: Nếu bạn quên 'await' ở đây, session sẽ kết thúc trước khi log được lưu
  await logEntry.save({ session }); 

  await session.commitTransaction();
} catch (error) {
  await session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

Cách 3: Phối hợp các hoạt động song song

Các truy vấn song song nhanh hơn, nhưng chúng là nguồn gốc phổ biến nhất của lỗi session đã kết thúc. Khi sử dụng Promise.all, hãy xác minh rằng session được truyền vào mọi lệnh gọi và hàm cha phải đợi toàn bộ mảng được giải quyết trước khi gọi endSession().

// Đợi tất cả các thao tác hoàn thành trước khi chuyển sang khối 'finally'
await Promise.all([
  User.updateOne({ _id: u1 }, { $set: { status: 'active' } }, { session }),
  User.updateOne({ _id: u2 }, { $set: { status: 'active' } }, { session })
]);

Các bước xác minh

Việc xác nhận đã sửa lỗi thành công đòi hỏi nhiều hơn là chỉ thấy lỗi biến mất. Hãy sử dụng ba bước kiểm tra sau để đảm bảo cơ sở dữ liệu của bạn ở trạng thái khỏe mạnh:

  • Theo dõi các chỉ số Session: Chạy db.serverStatus().logicalSessionRecordCache.activeSessionsCount trong Mongo shell của bạn. Nếu con số này tăng lên vô hạn, có thể bạn đã xóa endSession() để sửa lỗi, điều này gây ra hiện tượng rò rỉ bộ nhớ (memory leak).
  • Mô phỏng độ trễ mạng: Sử dụng một middleware để chèn khoảng thời gian trễ 100ms vào các lệnh gọi DB trong quá trình phát triển local. Nếu việc quản lý session của bạn có sai sót, độ trễ này sẽ gây ra tình trạng race condition và làm lỗi xuất hiện ngay lập tức.
  • Kiểm tra Database Logs: Tìm kiếm abortTransaction theo sau là endSessions. Một vòng đời khỏe mạnh sẽ hiển thị các lệnh này theo một thứ tự rõ ràng, tuần tự mà không có các lỗi chồng chéo.

Các thực hành tốt nhất để phòng ngừa

  • Ưu tiên dùng withTransaction: Chỉ sử dụng startTransaction thủ công khi bạn có các ràng buộc kiến trúc cụ thể ngăn cản việc sử dụng helper này.
  • Bật bảo mật ESLint: Sử dụng quy tắc no-floating-promises. Nó sẽ đánh dấu bất kỳ lệnh gọi cơ sở dữ liệu nào thiếu await, ngăn chặn nguyên nhân phổ biến nhất của lỗi này trước khi đưa lên production.
  • Tập trung quyền sở hữu Session: Chỉ hàm gọi startSession mới được phép gọi endSession. Hãy coi session như một công cụ đi mượn: trả lại cho chủ sở hữu khi bạn dùng xong, chứ đừng tự ý vứt nó đi.
  • Nâng cấp Mongoose: Nếu bạn vẫn đang sử dụng v5.x, việc chuyển sang v6 hoặc v7 sẽ cung cấp các thông báo lỗi mô tả chi tiết hơn nhiều, giúp xác định chính xác thao tác nào bị lỗi.

Related Error Notes