สัมภาษณ์ Spring Boot: การกระจายธุรกรรม

เชี่ยวชาญการกระจายธุรกรรมใน Spring Boot: REQUIRED, REQUIRES_NEW, NESTED และอื่น ๆ 12 คำถามสัมภาษณ์พร้อมโค้ดและกับดักทั่วไป

Spring Boot การกระจายธุรกรรม: คำถามสัมภาษณ์และตัวอย่างจริง

การกระจายธุรกรรมเป็นแนวคิดพื้นฐานใน Spring Boot ที่ได้รับการประเมินอย่างสม่ำเสมอในการสัมภาษณ์ทางเทคนิค การเข้าใจว่าธุรกรรมโต้ตอบกันอย่างไรระหว่างเมธอดที่มีคำอธิบาย @Transactional ช่วยหลีกเลี่ยงข้อบกพร่องเล็กน้อยในระบบโปรดักชันและช่วยออกแบบสถาปัตยกรรมที่แข็งแกร่ง

คำแนะนำในการเตรียมตัว

ผู้สัมภาษณ์จะทดสอบความสามารถในการเลือกระดับการกระจายที่เหมาะสมตามบริบททางธุรกิจ การสามารถอธิบายว่าทำไมจึงใช้ REQUIRES_NEW แทน REQUIRED ในกรณีที่เฉพาะเจาะจงจะสร้างความแตกต่าง

พื้นฐานของการกระจายธุรกรรม

คำถามที่ 1: การกระจายธุรกรรมใน Spring คืออะไร?

การกระจายกำหนดพฤติกรรมของเมธอดที่เป็นธุรกรรมเมื่อถูกเรียกในบริบทของธุรกรรมที่มีอยู่ มันตอบคำถาม: "จะเกิดอะไรขึ้นเมื่อเมธอด @Transactional เรียกเมธอดอื่นที่มีคำอธิบายเช่นเดียวกัน?"

OrderService.javajava
// แสดงแนวคิดของการกระจาย
@Service
public class OrderService {

    private final PaymentService paymentService;
    private final OrderRepository orderRepository;

    // ธุรกรรมหลัก - เริ่มต้นธุรกรรมใหม่
    @Transactional
    public void createOrder(OrderRequest request) {
        // บันทึกคำสั่งซื้อในธุรกรรมปัจจุบัน
        Order order = orderRepository.save(new Order(request));

        // เรียกเมธอด @Transactional อื่น
        // การกระจายตัดสิน: ธุรกรรมเดียวกันหรือใหม่?
        paymentService.processPayment(order.getId(), request.getAmount());
    }
}

@Service
public class PaymentService {

    // การกระจายเริ่มต้น: REQUIRED
    // เข้าร่วมธุรกรรมที่มีอยู่จาก createOrder()
    @Transactional
    public void processPayment(Long orderId, BigDecimal amount) {
        // ดำเนินการในธุรกรรมเดียวกันกับ createOrder()
        // หากเมธอดนี้ล้มเหลว คำสั่งซื้อจะถูก rollback ด้วย
    }
}

Spring มีระดับการกระจายเจ็ดระดับ ซึ่งแต่ละระดับเหมาะกับความต้องการทางธุรกิจที่เฉพาะเจาะจง การเลือกส่งผลโดยตรงต่อความสอดคล้องของข้อมูลและประสิทธิภาพ

คำถามที่ 2: อธิบายพฤติกรรมของ REQUIRED (การกระจายเริ่มต้น)

REQUIRED คือการกระจายเริ่มต้น หากมีธุรกรรม เมธอดจะเข้าร่วมกับมัน มิฉะนั้นจะมีการสร้างธุรกรรมใหม่ นี่เป็นพฤติกรรมที่พบบ่อยที่สุดและเป็นธรรมชาติที่สุด

UserService.javajava
// REQUIRED: พฤติกรรมเริ่มต้น
@Service
public class UserService {

    private final UserRepository userRepository;
    private final AuditService auditService;

    @Transactional(propagation = Propagation.REQUIRED)
    public void updateUser(Long userId, UserUpdateRequest request) {
        // เริ่มธุรกรรมหากไม่มี
        User user = userRepository.findById(userId).orElseThrow();
        user.setEmail(request.getEmail());
        userRepository.save(user);

        // Audit เข้าร่วมธุรกรรมเดียวกัน
        auditService.logUpdate(userId, "EMAIL_CHANGED");
    }
}

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    @Transactional(propagation = Propagation.REQUIRED)
    public void logUpdate(Long userId, String action) {
        // เข้าร่วมธุรกรรมของ updateUser()
        // Commit หรือ rollback ร่วมกัน
        auditLogRepository.save(new AuditLog(userId, action, Instant.now()));
    }
}

ไดอะแกรมด้านล่างแสดงการไหลของธุรกรรม:

text
updateUser() เริ่ม TX-1
    ├── save(user)          → TX-1
    └── logUpdate()         → เข้าร่วม TX-1 (REQUIRED)
           └── save(audit)  → TX-1

หาก logUpdate() ล้มเหลว → rollback TX-1 → user และ audit ถูกยกเลิก

คำถามที่ 3: เมื่อใดควรใช้ REQUIRES_NEW แทน REQUIRED?

REQUIRES_NEW จะหยุดธุรกรรมที่มีอยู่และสร้างธุรกรรมอิสระใหม่ มีประโยชน์เมื่อการดำเนินการต้องได้รับการ commit โดยไม่คำนึงถึงผลของธุรกรรมหลัก

PaymentService.javajava
// REQUIRES_NEW: ธุรกรรมอิสระ
@Service
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final PaymentAuditService auditService;

    @Transactional
    public void processPayment(Long orderId, BigDecimal amount) {
        Payment payment = new Payment(orderId, amount);
        paymentRepository.save(payment);

        // Audit ต้องถูกบันทึกแม้การชำระเงินจะล้มเหลวภายหลัง
        auditService.logPaymentAttempt(orderId, amount);

        // จำลองข้อผิดพลาดหลัง audit
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new InvalidAmountException("Negative amount");
        }
    }
}

@Service
public class PaymentAuditService {

    private final PaymentAuditRepository auditRepository;

    // REQUIRES_NEW: commit แยกจากธุรกรรมหลัก
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logPaymentAttempt(Long orderId, BigDecimal amount) {
        // ธุรกรรมใหม่ TX-2 ถูกสร้างขึ้น
        // TX-1 (processPayment) ถูกหยุดชั่วคราว
        auditRepository.save(new PaymentAudit(orderId, amount, "ATTEMPTED"));
        // TX-2 commit ที่นี่ แยกจาก TX-1
    }
}

การไหลของธุรกรรมด้วย REQUIRES_NEW:

text
processPayment() เริ่ม TX-1
    ├── save(payment)              → TX-1
    ├── logPaymentAttempt()        → TX-1 ถูกหยุดชั่วคราว
    │       └── เริ่ม TX-2          → ธุรกรรมใหม่
    │       └── save(audit)        → TX-2
    │       └── COMMIT TX-2        → audit ถูกบันทึก
    │       └── TX-1 ดำเนินต่อ
    └── throw InvalidAmountException
        └── ROLLBACK TX-1          → การชำระเงินถูกยกเลิก แต่ audit ถูกเก็บรักษา
ระวัง deadlock

REQUIRES_NEW อาจทำให้เกิด deadlock หากธุรกรรมใหม่เข้าถึงทรัพยากรเดียวกันที่ถูกล็อกโดยธุรกรรมที่ถูกหยุดชั่วคราว หลีกเลี่ยงการใช้ REQUIRES_NEW เพื่อแก้ไขตารางเดียวกับธุรกรรมหลัก

คำถามที่ 4: อธิบายการกระจาย NESTED และความแตกต่างจาก REQUIRES_NEW

NESTED สร้าง savepoint ภายในธุรกรรมปัจจุบัน หากเมธอดล้มเหลว เฉพาะการเปลี่ยนแปลงตั้งแต่ savepoint จะถูก rollback ไม่ใช่ธุรกรรมหลักทั้งหมด

BatchProcessingService.javajava
// NESTED: savepoint ในธุรกรรมหลัก
@Service
public class BatchProcessingService {

    private final ItemRepository itemRepository;
    private final ItemProcessor itemProcessor;

    @Transactional
    public BatchResult processBatch(List<Item> items) {
        BatchResult result = new BatchResult();

        for (Item item : items) {
            try {
                // แต่ละรายการประมวลผลด้วย savepoint
                itemProcessor.processItem(item);
                result.addSuccess(item.getId());
            } catch (ProcessingException e) {
                // Rollback เฉพาะรายการนี้ ไม่ใช่ batch ทั้งหมด
                result.addFailure(item.getId(), e.getMessage());
            }
        }

        return result; // Commit รายการที่สำเร็จ
    }
}

@Service
public class ItemProcessor {

    private final ItemRepository itemRepository;

    // NESTED: สร้าง savepoint สามารถ rollback บางส่วนได้
    @Transactional(propagation = Propagation.NESTED)
    public void processItem(Item item) {
        item.setStatus("PROCESSING");
        itemRepository.save(item);

        // การตรวจสอบทางธุรกิจ
        if (!isValid(item)) {
            throw new ProcessingException("Invalid item");
            // Rollback ไปที่ savepoint → เฉพาะรายการนี้
        }

        item.setStatus("COMPLETED");
        itemRepository.save(item);
    }
}

การเปรียบเทียบ NESTED กับ REQUIRES_NEW:

text
NESTED:
├── ใช้ savepoint ใน TX หลัก
├── เมื่อล้มเหลว → rollback ไปที่ savepoint
├── หาก TX หลัก rollback → NESTED ก็ rollback ด้วย
└── ประสิทธิภาพดีกว่า (ไม่มีการเชื่อมต่อใหม่)

REQUIRES_NEW:
├── สร้างธุรกรรมที่อิสระอย่างสมบูรณ์
├── เมื่อล้มเหลว → rollback เฉพาะ TX ลูก
├── หาก TX หลัก rollback → TX ลูก commit ไปแล้ว
└── ต้องการการเชื่อมต่อใหม่
การรองรับ NESTED

การกระจาย NESTED ต้องการการรองรับ savepoint ของ JDBC ฐานข้อมูลสมัยใหม่ส่วนใหญ่ (PostgreSQL, MySQL, Oracle) รองรับสิ่งนี้ ตรวจสอบความเข้ากันได้ก่อนใช้

ประเภทการกระจายขั้นสูง

คำถามที่ 5: เมื่อใดควรใช้ SUPPORTS และ NOT_SUPPORTED?

SUPPORTS ทำงานในธุรกรรมที่มีอยู่หากมี มิฉะนั้นไม่มีธุรกรรม NOT_SUPPORTED หยุดธุรกรรมที่มีอยู่ใด ๆ และทำงานโดยไม่มีธุรกรรม

ReportingService.javajava
// SUPPORTS: ธุรกรรมเสริม
@Service
public class ReportingService {

    private final ReportRepository reportRepository;

    // SUPPORTS: ทำงานได้ทั้งมีและไม่มีธุรกรรม
    // มีประโยชน์สำหรับการอ่านที่ไม่ต้องการการรับประกันธุรกรรม
    @Transactional(propagation = Propagation.SUPPORTS)
    public Report generateReport(Long reportId) {
        // หากเรียกจากเมธอด @Transactional → ใช้ TX ของมัน
        // หากเรียกโดยตรง → ไม่มีธุรกรรม (อ่าน OK)
        return reportRepository.generateComplexReport(reportId);
    }
}

@Service
public class ExternalNotificationService {

    private final ExternalApiClient apiClient;

    // NOT_SUPPORTED: ไม่อยู่ในธุรกรรม
    // หลีกเลี่ยงการบล็อก TX ระหว่างการเรียกภายนอกที่ช้า
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void sendExternalNotification(String message) {
        // TX หลักถูกหยุดชั่วคราวระหว่างการเรียก
        apiClient.send(message); // การเรียก HTTP ที่อาจช้า
        // TX หลักดำเนินต่อหลังจากนั้น
    }
}
OrderService.javajava
// ตัวอย่างการใช้งานร่วมกัน
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final ReportingService reportingService;
    private final ExternalNotificationService notificationService;

    @Transactional
    public void completeOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.setStatus("COMPLETED");
        orderRepository.save(order);

        // สร้างรายงานใน TX เดียวกัน (SUPPORTS)
        Report report = reportingService.generateReport(orderId);

        // การแจ้งเตือนภายนอก นอกธุรกรรม (NOT_SUPPORTED)
        // หลีกเลี่ยง timeout ของ TX หาก API ภายนอกช้า
        notificationService.sendExternalNotification(
            "Order " + orderId + " completed"
        );
    }
}

คำถามที่ 6: อธิบาย MANDATORY และ NEVER

MANDATORY ต้องการธุรกรรมที่มีอยู่ และจะโยน exception หากไม่มี NEVER ต้องการไม่มีธุรกรรม และจะโยน exception หากมี

AuditService.javajava
// MANDATORY: ต้องเรียกจากภายในธุรกรรม
@Service
public class AuditService {

    private final AuditRepository auditRepository;

    // MANDATORY: ปฏิเสธการทำงานโดยไม่มีธุรกรรม
    // รับประกันว่า audit เป็น atomic กับการดำเนินการที่ตรวจสอบเสมอ
    @Transactional(propagation = Propagation.MANDATORY)
    public void logCriticalAction(String action, Long entityId) {
        // หากเรียกโดยไม่มีธุรกรรม → IllegalTransactionStateException
        auditRepository.save(new AuditLog(action, entityId, Instant.now()));
    }
}

// CacheService.java
// NEVER: ห้ามอยู่ในธุรกรรม
@Service
public class CacheService {

    private final CacheManager cacheManager;

    // NEVER: cache ไม่ควรเข้าร่วมธุรกรรม
    // หลีกเลี่ยงความไม่สอดคล้องระหว่าง cache และ DB หลัง rollback
    @Transactional(propagation = Propagation.NEVER)
    public void invalidateCache(String cacheKey) {
        // หากเรียกจาก @Transactional → IllegalTransactionStateException
        cacheManager.getCache("entities").evict(cacheKey);
    }
}
SecurityService.javajava
// การใช้งาน MANDATORY ที่ถูกต้อง
@Service
public class SecurityService {

    private final AuditService auditService;

    @Transactional
    public void changeUserPassword(Long userId, String newPassword) {
        // การดำเนินการที่ละเอียดอ่อน...
        updatePassword(userId, newPassword);

        // Audit ต้องอยู่ในธุรกรรมเดียวกัน
        // MANDATORY บังคับใช้ข้อจำกัดทางสถาปัตยกรรมนี้
        auditService.logCriticalAction("PASSWORD_CHANGE", userId);
    }

    // ข้อผิดพลาด: เรียกโดยตรงโดยไม่มีธุรกรรม
    public void badUsage() {
        // โยน IllegalTransactionStateException เพราะไม่มี TX
        auditService.logCriticalAction("TEST", 1L);
    }
}

พร้อมที่จะพิชิตการสัมภาษณ์ Spring Boot แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

กับดักทั่วไปในการสัมภาษณ์

คำถามที่ 7: ทำไม @Transactional ไม่ทำงานในการเรียกภายใน?

หนึ่งในกับดักที่พบบ่อยที่สุด การเรียกเมธอดภายใน (self-invocation) จะข้าม proxy ของ Spring ทำให้ปิดการจัดการธุรกรรม

BrokenService.javajava
// ข้อผิดพลาดคลาสสิก: self-invocation
@Service
public class BrokenService {

    private final ItemRepository itemRepository;

    public void processItems(List<Long> itemIds) {
        for (Long id : itemIds) {
            // กับดัก: การเรียกภายใน → ข้าม proxy
            // @Transactional บน processItem() ถูกละเลย
            processItem(id);
        }
    }

    @Transactional
    public void processItem(Long itemId) {
        // ธุรกรรมนี้ไม่เคยถูกสร้างในการเรียกภายใน
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.setStatus("PROCESSED");
        itemRepository.save(item);
    }
}

วิธีแก้ปัญหาเพื่อหลีกเลี่ยงกับดักนี้:

java
// วิธีที่ 1: Self-injection
@Service
public class FixedServiceWithSelfInjection {

    private final ItemRepository itemRepository;

    @Lazy
    @Autowired
    private FixedServiceWithSelfInjection self; // Self-injection

    public void processItems(List<Long> itemIds) {
        for (Long id : itemIds) {
            // เรียกผ่าน proxy → @Transactional ทำงาน
            self.processItem(id);
        }
    }

    @Transactional
    public void processItem(Long itemId) {
        // ธุรกรรมถูกสร้างอย่างถูกต้อง
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.setStatus("PROCESSED");
        itemRepository.save(item);
    }
}

// วิธีที่ 2: แยกเป็นสอง service (แนะนำ)
@Service
public class ItemOrchestrator {

    private final ItemProcessor processor;

    public void processItems(List<Long> itemIds) {
        for (Long id : itemIds) {
            // เรียกไปยัง bean อื่น → proxy ทำงาน
            processor.processItem(id);
        }
    }
}

@Service
public class ItemProcessor {

    private final ItemRepository itemRepository;

    @Transactional
    public void processItem(Long itemId) {
        // ธุรกรรมจัดการอย่างถูกต้อง
        Item item = itemRepository.findById(itemId).orElseThrow();
        item.setStatus("PROCESSED");
        itemRepository.save(item);
    }
}

คำถามที่ 8: จัดการ exception และ rollback อย่างไร?

โดยเริ่มต้น Spring จะ rollback เฉพาะ RuntimeException และ Error Checked exception จะไม่กระตุ้น rollback อัตโนมัติ

TransactionRollbackDemo.javajava
// พฤติกรรม rollback ตามประเภท exception
@Service
public class TransactionRollbackDemo {

    private final OrderRepository orderRepository;

    // RuntimeException → ROLLBACK อัตโนมัติ
    @Transactional
    public void methodWithRuntimeException() {
        orderRepository.save(new Order());
        throw new RuntimeException("Error"); // ROLLBACK
    }

    // Checked Exception → ไม่ rollback โดยเริ่มต้น
    @Transactional
    public void methodWithCheckedException() throws IOException {
        orderRepository.save(new Order());
        throw new IOException("File error"); // COMMIT แม้กระนั้น!
    }

    // บังคับ rollback บน checked exception
    @Transactional(rollbackFor = IOException.class)
    public void methodWithRollbackFor() throws IOException {
        orderRepository.save(new Order());
        throw new IOException("Error"); // ROLLBACK เพราะ rollbackFor
    }

    // ยกเว้น RuntimeException จาก rollback
    @Transactional(noRollbackFor = BusinessException.class)
    public void methodWithNoRollbackFor() {
        orderRepository.save(new Order());
        throw new BusinessException("Warning"); // COMMIT แม้มี exception
    }
}

การกำหนดค่าที่แนะนำสำหรับกรณีทางธุรกิจ:

BaseTransactionalService.javajava
// การกำหนดค่าธุรกรรมที่สอดคล้อง
@Service
public abstract class BaseTransactionalService {

    // Rollback บน exception ทั้งหมด (checked และ unchecked)
    @Transactional(rollbackFor = Exception.class)
    protected void executeInTransaction(Runnable operation) {
        operation.run();
    }
}

// PaymentService.java
@Service
public class PaymentService extends BaseTransactionalService {

    private final PaymentRepository paymentRepository;

    @Transactional(
        rollbackFor = Exception.class,
        noRollbackFor = InsufficientFundsException.class
    )
    public PaymentResult processPayment(PaymentRequest request) {
        Payment payment = new Payment(request);
        paymentRepository.save(payment);

        if (request.getAmount().compareTo(getBalance()) > 0) {
            // ไม่ rollback - ต้องการเก็บบันทึกความพยายาม
            throw new InsufficientFundsException("Insufficient balance");
        }

        return new PaymentResult(payment.getId(), "SUCCESS");
    }
}

คำถามที่ 9: การแยกธุรกรรมทำงานกับการกระจายอย่างไร?

การแยกและการกระจายเสริมซึ่งกันและกัน การแยกกำหนดการมองเห็นข้อมูลระหว่างธุรกรรมที่เกิดขึ้นพร้อมกัน

IsolationDemo.javajava
// การรวมการแยก + การกระจาย
@Service
public class IsolationDemo {

    private final AccountRepository accountRepository;

    // READ_COMMITTED: เห็นข้อมูลที่ commit โดยธุรกรรมอื่น
    @Transactional(
        isolation = Isolation.READ_COMMITTED,
        propagation = Propagation.REQUIRED
    )
    public BigDecimal getAccountBalance(Long accountId) {
        // อาจเห็นค่าต่างกันหากอ่านซ้ำระหว่างธุรกรรม
        return accountRepository.findById(accountId)
            .map(Account::getBalance)
            .orElse(BigDecimal.ZERO);
    }

    // REPEATABLE_READ: รับประกันการอ่านเดียวกันระหว่างธุรกรรม
    @Transactional(
        isolation = Isolation.REPEATABLE_READ,
        propagation = Propagation.REQUIRED
    )
    public void transferWithConsistentRead(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        Account to = accountRepository.findById(toId).orElseThrow();

        // แม้ธุรกรรมอื่นจะแก้ไข 'from' ในระหว่างนี้
        // เราเห็นค่าเริ่มต้นเสมอ (snapshot)
        from.debit(amount);
        to.credit(amount);

        accountRepository.save(from);
        accountRepository.save(to);
    }

    // SERIALIZABLE: การแยกสูงสุด ไม่มีการทำงานพร้อมกัน
    @Transactional(
        isolation = Isolation.SERIALIZABLE,
        propagation = Propagation.REQUIRES_NEW
    )
    public void criticalOperation(Long accountId) {
        // บล็อกธุรกรรมอื่นใด ๆ บนข้อมูลนี้
        // ใช้อย่างประหยัด - มีผลกระทบต่อประสิทธิภาพ
        Account account = accountRepository.findById(accountId).orElseThrow();
        account.performCriticalUpdate();
        accountRepository.save(account);
    }
}

ตารางสรุประดับการแยก:

text
| การแยก           | Dirty Read | Non-Repeatable | Phantom |
|------------------|------------|----------------|---------|
| READ_UNCOMMITTED | เป็นไปได้  | เป็นไปได้      | เป็นไปได้|
| READ_COMMITTED   | ไม่        | เป็นไปได้      | เป็นไปได้|
| REPEATABLE_READ  | ไม่        | ไม่            | เป็นไปได้|
| SERIALIZABLE     | ไม่        | ไม่            | ไม่     |
ผลกระทบต่อประสิทธิภาพ

ยิ่งการแยกเข้มงวดมากเท่าใด ประสิทธิภาพยิ่งได้รับผลกระทบจากล็อกมากขึ้น SERIALIZABLE อาจทำให้เกิดการแย่งชิงอย่างมากในโปรดักชันที่มีการเข้าใช้สูง

รูปแบบขั้นสูง

คำถามที่ 10: ใช้รูปแบบ "transactional outbox" อย่างไร?

รูปแบบ outbox รับประกันความสอดคล้องระหว่างการแก้ไขฐานข้อมูลและการส่งข้อความ/เหตุการณ์ แม้ในกรณีที่ล้มเหลว

OutboxService.javajava
// รูปแบบ Transactional Outbox
@Service
public class OutboxService {

    private final OutboxRepository outboxRepository;

    // บันทึกเหตุการณ์ในธุรกรรมเดียวกันกับเอนทิตีทางธุรกิจ
    @Transactional(propagation = Propagation.MANDATORY)
    public void saveEvent(String aggregateType, Long aggregateId, String eventType, String payload) {
        OutboxEvent event = OutboxEvent.builder()
            .aggregateType(aggregateType)
            .aggregateId(aggregateId)
            .eventType(eventType)
            .payload(payload)
            .status("PENDING")
            .createdAt(Instant.now())
            .build();
        outboxRepository.save(event);
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxService outboxService;

    @Transactional
    public Order createOrder(OrderRequest request) {
        // สร้างคำสั่งซื้อ
        Order order = new Order(request);
        orderRepository.save(order);

        // เหตุการณ์ outbox ในธุรกรรมเดียวกัน (MANDATORY)
        // หาก commit สำเร็จ → ทั้งสองถูกบันทึก
        // หาก rollback → ไม่มีอะไรถูกบันทึก
        outboxService.saveEvent(
            "ORDER",
            order.getId(),
            "ORDER_CREATED",
            toJson(new OrderCreatedEvent(order))
        );

        return order;
    }
}

// OutboxPublisher.java
// กระบวนการแยกที่เผยแพร่เหตุการณ์
@Service
public class OutboxPublisher {

    private final OutboxRepository outboxRepository;
    private final MessageBroker messageBroker;

    // ธุรกรรมอิสระสำหรับการเผยแพร่แต่ละครั้ง
    @Scheduled(fixedDelay = 1000)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void publishPendingEvents() {
        List<OutboxEvent> events = outboxRepository
            .findByStatusOrderByCreatedAt("PENDING");

        for (OutboxEvent event : events) {
            try {
                messageBroker.publish(event.getEventType(), event.getPayload());
                event.setStatus("PUBLISHED");
                event.setPublishedAt(Instant.now());
            } catch (Exception e) {
                event.setStatus("FAILED");
                event.setError(e.getMessage());
            }
            outboxRepository.save(event);
        }
    }
}

คำถามที่ 11: ทดสอบพฤติกรรมการกระจายต่าง ๆ อย่างไร?

การทดสอบการกระจายต้องการความสนใจเป็นพิเศษเพื่อตรวจสอบพฤติกรรมธุรกรรมที่คาดหวัง

TransactionPropagationTest.javajava
// การทดสอบพฤติกรรมการกระจาย
@SpringBootTest
@Transactional
class TransactionPropagationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private PaymentService paymentService;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void required_shouldShareTransaction() {
        // Given
        Order order = orderService.createOrder(new OrderRequest());

        // When - ชำระเงินในธุรกรรมเดียวกัน (REQUIRED)
        paymentService.processPayment(order.getId(), BigDecimal.TEN);

        // Then - ทั้งสองมองเห็นได้ก่อน commit
        entityManager.flush();
        assertThat(entityManager.find(Order.class, order.getId())).isNotNull();
        assertThat(entityManager.find(Payment.class, order.getId())).isNotNull();
    }

    @Test
    void requiresNew_shouldCommitIndependently() {
        // Given
        Long orderId = null;

        try {
            // When - audit ใน REQUIRES_NEW
            orderId = orderService.createOrderWithAudit(new OrderRequest());
            throw new RuntimeException("Simulated failure after audit");
        } catch (RuntimeException e) {
            // ธุรกรรมหลัก rollback
        }

        // Then - audit (REQUIRES_NEW) ยังคง commit
        assertThat(findAuditLog(orderId)).isNotNull();
        // แต่คำสั่งซื้อ rollback
        assertThat(findOrder(orderId)).isNull();
    }

    @Test
    void mandatory_shouldThrowWithoutTransaction() {
        // Given - ไม่มีธุรกรรมที่ใช้งานอยู่

        // When/Then - ต้องโยน exception
        assertThatThrownBy(() -> auditService.logCriticalAction("TEST", 1L))
            .isInstanceOf(IllegalTransactionStateException.class)
            .hasMessageContaining("No existing transaction");
    }
}
PropagationIntegrationTest.javajava
// การทดสอบการรวมระบบกับ rollback และ commit จริง
@SpringBootTest
class PropagationIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private AuditLogRepository auditLogRepository;

    @Test
    void nested_shouldRollbackOnlyNestedOnFailure() {
        // Given
        int initialCount = orderRepository.findAll().size();

        // When - การประมวลผล batch ด้วย NESTED
        BatchResult result = orderService.processBatchWithNested(
            List.of(validItem(), invalidItem(), validItem())
        );

        // Then - เฉพาะรายการที่ถูกต้องถูกบันทึก
        assertThat(result.getSuccessCount()).isEqualTo(2);
        assertThat(result.getFailureCount()).isEqualTo(1);
        assertThat(orderRepository.findAll().size()).isEqualTo(initialCount + 2);
    }
}

คำถามที่ 12: แนวปฏิบัติที่ดีที่สุดสำหรับการกำหนดค่าธุรกรรมคืออะไร?

การกำหนดค่าธุรกรรมที่สอดคล้องในระดับโครงการช่วยหลีกเลี่ยงความประหลาดใจและทำให้บำรุงรักษาง่ายขึ้น

TransactionConfig.javajava
// การกำหนดค่าธุรกรรมแบบรวมศูนย์
@Configuration
@EnableTransactionManagement
public class TransactionConfig {

    // Timeout เริ่มต้นสำหรับธุรกรรมทั้งหมด
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        DataSourceTransactionManager tm = new DataSourceTransactionManager(dataSource);
        tm.setDefaultTimeout(30); // 30 วินาทีสูงสุดต่อธุรกรรม
        return tm;
    }
}

// BaseService.java
// คำอธิบายเริ่มต้นสำหรับ service
@Service
@Transactional(
    readOnly = true, // อ่านอย่างเดียวโดยเริ่มต้น
    rollbackFor = Exception.class // Rollback บน exception ใด ๆ
)
public abstract class BaseService {
    // เมธอดอ่านสืบทอด readOnly = true
}

// OrderService.java
// Service พร้อมการกำหนดค่าที่สอดคล้อง
@Service
public class OrderService extends BaseService {

    private final OrderRepository orderRepository;

    // สืบทอด readOnly = true
    public Order findById(Long id) {
        return orderRepository.findById(id).orElseThrow();
    }

    // Override สำหรับการเขียน
    @Transactional(readOnly = false)
    public Order createOrder(OrderRequest request) {
        return orderRepository.save(new Order(request));
    }

    // การกำหนดค่าอย่างชัดเจนสำหรับกรณีสำคัญ
    @Transactional(
        readOnly = false,
        propagation = Propagation.REQUIRES_NEW,
        isolation = Isolation.SERIALIZABLE,
        timeout = 10
    )
    public void criticalOperation(Long orderId) {
        // การดำเนินการด้วยการแยกสูงสุดและ timeout สั้น
    }
}

รายการตรวจสอบแนวปฏิบัติที่ดีที่สุด:

text
การกำหนดค่าธุรกรรม - รายการตรวจสอบ

✅ readOnly = true โดยเริ่มต้น override อย่างชัดเจนสำหรับการเขียน
✅ rollbackFor = Exception.class เพื่อรวม checked exceptions
✅ Timeout ที่เหมาะสมตามประเภทการดำเนินการ
✅ หลีกเลี่ยงการเรียกภายใน (@Transactional ถูกละเลย)
✅ REQUIRES_NEW เฉพาะเมื่อต้องการ commit อิสระ
✅ MANDATORY เพื่อรับประกันบริบทธุรกรรม
✅ ทดสอบพฤติกรรม rollback อย่างชัดเจน
✅ ตรวจสอบธุรกรรมที่ทำงานนาน
✅ บันทึกการเลือกการกระจายที่ไม่เป็นมาตรฐาน

พร้อมที่จะพิชิตการสัมภาษณ์ Spring Boot แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

สรุป

การกระจายธุรกรรมเป็นแนวคิดพื้นฐานที่ได้รับการประเมินในการสัมภาษณ์ Spring Boot ประเด็นสำคัญที่ต้องจดจำ:

ประเภทการกระจายทั่วไป:

  • ✅ REQUIRED (เริ่มต้น): เข้าร่วมหรือสร้างธุรกรรม
  • ✅ REQUIRES_NEW: ธุรกรรมอิสระ commit แยก
  • ✅ NESTED: savepoint สำหรับ rollback บางส่วน
  • ✅ MANDATORY: ต้องการธุรกรรมที่มีอยู่

กับดักที่ต้องหลีกเลี่ยง:

  • ✅ Self-invocation: ข้าม proxy, @Transactional ถูกละเลย
  • ✅ Checked exception: ไม่ rollback โดยเริ่มต้น
  • ✅ REQUIRES_NEW บนข้อมูลเดียวกัน: ความเสี่ยง deadlock
  • ✅ ไม่มี timeout: ธุรกรรมถูกบล็อกอย่างไม่จำกัด

แนวปฏิบัติที่ดีที่สุด:

  • ✅ readOnly = true โดยเริ่มต้น
  • ✅ rollbackFor = Exception.class อย่างเป็นระบบ
  • ✅ แยก service เพื่อหลีกเลี่ยง self-invocation
  • ✅ ทดสอบพฤติกรรม rollback อย่างชัดเจน

การเชี่ยวชาญการกระจายธุรกรรมแสดงถึงความเข้าใจอย่างลึกซึ้งใน Spring และการจัดการข้อมูล แนวคิดเหล่านี้จำเป็นในการออกแบบแอปพลิเคชันที่แข็งแกร่งและผ่านการสัมภาษณ์ทางเทคนิคได้อย่างประสบความสำเร็จ

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#spring boot
#transactions
#propagation
#java
#interview

แชร์

บทความที่เกี่ยวข้อง