AgentSkillsCN

transaction

事务管理规则:涵盖传播、隔离、回滚以及常见陷阱

SKILL.md
--- frontmatter
name: transaction
description: Transaction management rules including propagation, isolation, rollback, and common pitfalls
triggers:
  - transaction
  - propagation
  - isolation
  - rollback
argument-hint: ""

Transaction management

Overview

This document defines rules for managing transactions in Spring applications, including propagation, isolation, Master/Slave routing, and common pitfalls.

Key Principle: Keep transactions small and fast. No external I/O. Watch for self-invocation.

For JPA-specific topics (Lazy Loading, N+1, Locking, Entity State), see skill: jpa-rules.

GuidelineDescription
Keep it smallOnly include necessary DB operations
No external I/OAvoid HTTP calls, file I/O, messaging inside transactions
No self-invocationSame-class method calls bypass proxy
Fail fastValidate before starting transaction

@Transactional defaults

PropertyDefaultDescription
PropagationREQUIREDJoins existing or creates new
IsolationDB defaultUsually READ_COMMITTED
RollbackUnchecked onlyRuntimeException, Error
TimeoutNoneNo limit
kotlin
@Transactional  // Uses all defaults
fun createUser(name: String): User = userRepository.save(User(name = name))

Propagation levels

LevelBehaviorUse Case
REQUIREDJoin existing or create newDefault, most operations
REQUIRES_NEWAlways create new, suspend currentAudit logs, independent operations
NESTEDSavepoint within existingPartial rollback support
SUPPORTSUse if exists, else noneRead operations
NOT_SUPPORTEDSuspend current, run withoutNon-transactional operations
kotlin
// REQUIRES_NEW - commits independently
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun auditLog(message: String) {
    logRepository.save(AuditLog(message))  // Persists even if parent rolls back
}

Isolation levels

LevelPreventsPerformance
READ_UNCOMMITTEDNothingFastest
READ_COMMITTEDDirty readsRecommended default
REPEATABLE_READDirty + non-repeatable readsSlower
SERIALIZABLEAll anomaliesSlowest
kotlin
@Transactional(isolation = Isolation.REPEATABLE_READ)
fun transferFunds(fromId: Long, toId: Long, amount: BigDecimal) {
    // Repeated reads return same values
}

Read-only transactions

kotlin
@Transactional(readOnly = true)
fun getUsers(): List<User> = userRepository.findAll()

Benefits:

  • JDBC driver optimizations
  • Database query planning hints
  • Prevents accidental writes
  • Master/Slave routing: readOnly = true automatically routes to Slave (Reader)

Master/Slave DataSource routing

IMPORTANT: The system automatically routes connections to Master or Slave based on the @Transactional(readOnly = true) setting.

Routing rules

TransactionDataSourcePurpose
@Transactional (default)Master (Writer)INSERT, UPDATE, DELETE
@Transactional(readOnly = true)Slave (Reader)SELECT (read-only)
No transactionMaster (default)Default behavior

How it works

DataSourceConfig uses AbstractRoutingDataSource + LazyConnectionDataSourceProxy for routing:

code
@Transactional entry
  -> Spring sets the readOnly flag
    -> LazyConnectionDataSourceProxy defers connection acquisition until actual SQL execution
      -> AbstractRoutingDataSource checks readOnly flag to route to Master/Slave
kotlin
// Routed to Slave (read-only)
@Service
@Transactional(readOnly = true)
class HolidayQueryApplication(
    private val holidayService: HolidayService,
) {
    fun findAll(): List<HolidayInfo> = holidayService.findAll()
}

// Routed to Master (write)
@Service
@Transactional
class HolidayCommandApplication(
    private val holidayService: HolidayService,
) {
    fun create(request: CreateHolidayRequest): HolidayInfo =
        HolidayInfo.from(holidayService.create(request))
}

Configuration properties

yaml
# Master/Slave routing activates only when the master jdbc-url property exists
# Without the property (e.g., embed profile), the default H2 auto-configuration is used
spring:
  datasource:
    master:
      hikari:
        jdbc-url: jdbc:mysql://master-host:3306/mydb
        username: user
        password: pass
    slave:
      hikari:
        jdbc-url: jdbc:mysql://slave-host:3306/mydb
        username: user
        password: pass

Considerations

ConsiderationDescription
Missing readOnlyUsing only @Transactional routes read queries to Master as well
QueryApplication requires readOnlyQuery Application classes must use @Transactional(readOnly = true)
CommandApplication uses defaultCommand Application classes use @Transactional (default)
LazyConnection requiredWithout LazyConnectionDataSourceProxy, the connection is acquired before the readOnly flag is set, causing routing failure

Transaction timeout

kotlin
@Transactional(timeout = 5)  // 5 seconds
fun processLargeData() { ... }

Global default:

yaml
spring:
  transaction:
    default-timeout: 30

Rollback rules

Exception TypeDefault BehaviorOverride
RuntimeExceptionRollbacknoRollbackFor
ErrorRollbacknoRollbackFor
Checked exceptionNo rollbackrollbackFor
kotlin
@Transactional(rollbackFor = [IOException::class])
fun loadData() { ... }

@Transactional(noRollbackFor = [ValidationException::class])
fun validate() { ... }

External calls after commit

Move external calls outside transactions:

kotlin
// Bad: HTTP call inside transaction
@Transactional
fun createUser(request: CreateUserRequest): User {
    val user = userRepository.save(User.from(request))
    paymentService.createCustomer(user.email)  // Holds DB connection
    return user
}

// Good: External calls after commit
fun createUser(request: CreateUserRequest): User {
    val user = saveUser(request)  // Transaction ends here
    paymentService.createCustomer(user.email)  // No DB connection held
    return user
}

@Transactional
fun saveUser(request: CreateUserRequest): User = userRepository.save(User.from(request))

Using @TransactionalEventListener

kotlin
@Transactional
fun createUser(request: CreateUserRequest): User {
    val user = userRepository.save(User.from(request))
    eventPublisher.publishEvent(UserCreatedEvent(user))
    return user
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handleUserCreated(event: UserCreatedEvent) {
    emailService.sendWelcome(event.user.email)  // Runs after commit
}

Common pitfalls

PitfallProblemSolution
Self-invocation@Transactional ignoredMake caller transactional, inject self, or extract to another service
Non-public methodsProxy only works on publicAlways use public methods
Checked exceptionsNo automatic rollbackUse rollbackFor
External I/O in transactionConnection held, inconsistent stateMove outside transaction
Long transactionsLock contention, connection exhaustionKeep transactions small

Self-invocation problem

kotlin
@Service
class UserService(private val userRepository: UserRepository) {

    @Transactional
    fun createUser(name: String): User = userRepository.save(User(name = name))

    fun register(name: String): User {
        return createUser(name)  // No transaction! Direct call bypasses proxy
    }
}

Solutions:

  1. Make caller transactional (recommended)
  2. Inject self: @Autowired private lateinit var self: UserService
  3. Extract to separate service

Testing

kotlin
@DataJpaTest  // Includes @Transactional with auto-rollback
class UserRepositoryTest(
    @Autowired private val userRepository: UserRepository,
) {
    @Test
    fun `should save user`() {
        val user = userRepository.save(User(name = "John"))
        assertNotNull(user.id)
        // Automatically rolled back after test
    }
}