R2DBC vs JDBC: Choosing the Right Persistence for Reactive Spring Boot Projects
This article compares Spring Data JDBC and Spring Data R2DBC for Spring Boot 3.x/4.x projects, detailing setup, Maven dependencies, configuration, entity definitions, repository and service implementations, transaction handling, performance trade‑offs, and provides a hybrid approach that wraps JDBC calls in reactive Monos for WebFlux, helping developers decide which persistence layer best fits their reactive architecture.
Background and Problem
When building a reactive architecture with Spring WebFlux, developers often get stuck at the persistence layer: whether to keep using the mature, blocking JDBC or adopt the fully reactive R2DBC.
1. Baseline Environment Alignment
Both implementations target Spring Boot 3.3.x/4.x and MySQL 8.x, keeping entities and business logic identical to focus on persistence differences.
2. Solution 1 – Spring Data JDBC
2.1 Maven core dependencies
<dependencies>
<!-- Web MVC, synchronous blocking web layer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JDBC + HikariCP connection pool -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<!-- MySQL driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>2.2 application.yml configuration
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# HikariCP pool settings
hikari:
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 30000
idle-timeout: 60000
max-lifetime: 1800000
logging:
level:
org.springframework.data.jdbc: DEBUG2.3 Entity class
package com.example.jdbc.entity;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Data
@Table("sys_user")
public class SysUser {
@Id
private Long id;
private String username;
private String password;
private String phone;
private Integer deptId;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}2.4 Repository (data‑access layer)
package com.example.jdbc.repository;
import com.example.jdbc.entity.SysUser;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserRepository extends CrudRepository<SysUser, Long> {
// 1. Method‑name generated SQL
Optional<SysUser> findByUsername(String username);
List<SysUser> findByDeptIdAndStatus(Integer deptId, Integer status);
// 2. Pagination query
Page<SysUser> findByStatus(Integer status, Pageable pageable);
// 3. Custom SQL query
@Query("""
SELECT u.* FROM sys_user u
WHERE u.dept_id = :deptId
AND u.status = 1
ORDER BY u.create_time DESC
LIMIT :limit OFFSET :offset
""")
List<SysUser> listUserByDept(@Param("deptId") Integer deptId,
@Param("limit") Integer limit,
@Param("offset") Integer offset);
// 4. Count query
long countByStatus(Integer status);
}2.5 Service layer (including transactions)
package com.example.jdbc.service;
import com.example.jdbc.entity.SysUser;
import com.example.jdbc.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public SysUser getById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found"));
}
public Page<SysUser> pageUser(Integer status, int pageNum, int pageSize) {
return userRepository.findByStatus(status, PageRequest.of(pageNum - 1, pageSize));
}
@Transactional(rollbackFor = Exception.class)
public SysUser createUser(SysUser user) {
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
user.setStatus(1);
return userRepository.save(user);
}
@Transactional(rollbackFor = Exception.class)
public void updateStatus(Long userId, Integer status) {
SysUser user = getById(userId);
user.setStatus(status);
user.setUpdateTime(LocalDateTime.now());
userRepository.save(user);
if (status == 0) {
// do something else...
}
}
public List<SysUser> listByDept(Integer deptId) {
return userRepository.listUserByDept(deptId, 100, 0);
}
}2.6 Controller (REST endpoints)
package com.example.jdbc.controller;
import com.example.jdbc.entity.SysUser;
import com.example.jdbc.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public Result<SysUser> getById(@PathVariable Long id) {
return Result.success(userService.getById(id));
}
@PostMapping
public Result<SysUser> create(@RequestBody SysUser user) {
return Result.success(userService.createUser(user));
}
}JDBC characteristics: linear code execution, clear logic, out‑of‑the‑box transactions, mature MyBatis ecosystem for complex SQL.
3. Solution 2 – Spring Data R2DBC (Fully Reactive)
The same business logic is rewritten with a reactive stack to highlight differences.
3.1 Maven core dependencies
<dependencies>
<!-- WebFlux reactive web layer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring Data R2DBC + r2dbc‑pool -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<!-- MySQL R2DBC driver -->
<dependency>
<groupId>com.github.jasync-sql</groupId>
<artifactId>jasync-r2dbc-mysql</artifactId>
<version>2.2.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>3.2 application.yml configuration
spring:
r2dbc:
url: r2dbc:mysql://127.0.0.1:3306/test_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 127.0.0.1
pool:
enabled: true
initial-size: 3
max-size: 10
max-idle-time: 60s
max-life-time: 30m
acquire-timeout: 3s
logging:
level:
org.springframework.data.r2dbc: DEBUGNote: R2DBC does not need dozens of connections; ten connections can sustain high concurrency, and excessive connections increase database load.
3.3 Entity class (package differs only by import)
package com.example.r2dbc.entity;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Data
@Table("sys_user")
public class SysUser {
@Id
private Long id;
private String username;
private String password;
private String phone;
private Integer deptId;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}3.4 Reactive Repository
package com.example.r2dbc.repository;
import com.example.r2dbc.entity.SysUser;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Repository
public interface UserRepository extends ReactiveCrudRepository<SysUser, Long> {
// 1. Method‑name query, returns Mono (0/1 result)
Mono<SysUser> findByUsername(String username);
// 2. Returns Flux (multiple results)
Flux<SysUser> findByDeptIdAndStatus(Integer deptId, Integer status);
// 3. Custom SQL query
@Query("""
SELECT u.* FROM sys_user u
WHERE u.dept_id = :deptId
AND u.status = 1
ORDER BY u.create_time DESC
LIMIT :limit OFFSET :offset
""")
Flux<SysUser> listUserByDept(@Param("deptId") Integer deptId,
@Param("limit") Integer limit,
@Param("offset") Integer offset);
// 4. Count query
Mono<Long> countByStatus(Integer status);
// 5. Batch status update
@Query("UPDATE sys_user SET status = :status WHERE dept_id = :deptId")
Mono<Integer> updateStatusByDept(@Param("deptId") Integer deptId,
@Param("status") Integer status);
}3.5 Reactive Service (transaction handling)
package com.example.r2dbc.service;
import com.example.r2dbc.entity.SysUser;
import com.example.r2dbc.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public Mono<SysUser> getById(Long id) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("User not found")));
}
public Flux<SysUser> pageUser(Integer status, int pageNum, int pageSize) {
int offset = (pageNum - 1) * pageSize;
return userRepository.findByDeptIdAndStatus(null, status)
.skip(offset)
.take(pageSize);
}
@Transactional(rollbackFor = Exception.class)
public Mono<SysUser> createUser(SysUser user) {
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
user.setStatus(1);
return userRepository.save(user);
}
@Transactional(rollbackFor = Exception.class)
public Mono<Void> updateStatus(Long userId, Integer status) {
return getById(userId)
.flatMap(user -> {
user.setStatus(status);
user.setUpdateTime(LocalDateTime.now());
return userRepository.save(user);
})
.flatMap(user -> {
// second step inside the same transaction, automatic rollback on error
return Mono.just(user);
})
.then();
}
public Flux<SysUser> listByDept(Integer deptId) {
return userRepository.listUserByDept(deptId, 100, 0);
}
}3.6 Reactive Controller
package com.example.r2dbc.controller;
import com.example.r2dbc.entity.SysUser;
import com.example.r2dbc.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public Mono<Result<SysUser>> getById(@PathVariable Long id) {
return userService.getById(id)
.map(Result::success);
}
@PostMapping
public Mono<Result<SysUser>> create(@RequestBody Mono<SysUser> userMono) {
return userMono
.flatMap(userService::createUser)
.map(Result::success);
}
// Stream list, supports SSE line‑by‑line push
@GetMapping(value = "/stream", produces = "text/event-stream")
public Flux<SysUser> streamAll() {
return userService.listByDept(null);
}
}3.7 Advanced dynamic query with R2dbcEntityTemplate
package com.example.r2dbc.repository;
import com.example.r2dbc.entity.SysUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.r2dbc.core.Query;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
@Repository
@RequiredArgsConstructor
public class UserDynamicRepository {
private final R2dbcEntityTemplate template;
public Flux<SysUser> listByCondition(UserQueryDTO query) {
Criteria criteria = Criteria.empty();
if (query.getDeptId() != null) {
criteria = criteria.and("dept_id").is(query.getDeptId());
}
if (query.getStatus() != null) {
criteria = criteria.and("status").is(query.getStatus());
}
if (StrUtil.isNotBlank(query.getKeyword())) {
criteria = criteria.and("username").like("%" + query.getKeyword() + "%");
}
return template.select(SysUser.class)
.matching(Query.query(criteria)
.offset((long) (query.getPageNum() - 1) * query.getPageSize())
.limit(query.getPageSize())
.orderBy(Sort.by(Sort.Direction.DESC, "create_time")))
.all();
}
}R2DBC characteristics: full‑stack non‑blocking, few connections support high concurrency, native WebFlux integration, streamable results, but a steep learning curve and more complex transaction and debugging handling.
4. Hybrid Approach – WebFlux + JDBC
Wrap blocking JDBC calls in Mono.fromCallable and schedule them on Schedulers.boundedElastic() so the reactive WebFlux layer stays non‑blocking while reusing the mature JDBC ecosystem.
4.1 Core implementation idea
All JDBC blocking calls are submitted to Schedulers.boundedElastic() thread pool.
The outer layer wraps results as Mono or Flux, seamlessly integrating with the reactive Web layer.
This retains JDBC stability while not wasting WebFlux concurrency.
4.2 Complete code example
package com.example.hybrid.service;
import com.example.jdbc.entity.SysUser;
import com.example.jdbc.repository.UserJdbcRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class UserHybridService {
private final UserJdbcRepository jdbcRepository;
/** Wrap JDBC call as reactive Mono */
public Mono<SysUser> getById(Long id) {
return Mono.fromCallable(() -> jdbcRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found")))
.subscribeOn(Schedulers.boundedElastic());
}
/** Transaction still works because it runs in the calling thread */
@Transactional(rollbackFor = Exception.class)
public Mono<SysUser> createUser(SysUser user) {
return Mono.fromCallable(() -> {
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
user.setStatus(1);
return jdbcRepository.save(user);
})
.subscribeOn(Schedulers.boundedElastic());
}
/** List query wrapped as Flux */
public Flux<SysUser> listByDept(Integer deptId) {
return Mono.fromCallable(() -> jdbcRepository.listUserByDept(deptId, 100, 0))
.subscribeOn(Schedulers.boundedElastic())
.flatMapMany(Flux::fromIterable);
}
}4.3 Suitable scenarios
WebFlux is already used but SQL is complex and the team lacks R2DBC experience.
The project depends on MyBatis, MyBatis‑Plus or other JDBC ecosystems.
Stability is prioritized over maximal performance.
5. Comparison Summary (side‑by‑side)
The article provides a table comparing latency, throughput, complex SQL support, transaction maturity, learning cost, debugging difficulty, driver coverage, connection‑pool size, and recommended team size for JDBC versus R2DBC. In short, JDBC offers lower latency, mature transactions, and a rich ecosystem, while R2DBC delivers higher concurrency with fewer connections but requires more expertise.
6. Final Recommendation
Choose JDBC if you have a traditional Spring MVC stack, complex SQL, no reactive expertise, or need support for niche databases. Choose R2DBC for an all‑WebFlux stack, simple CRUD workloads, high‑concurrency read/write patterns, and a team comfortable with reactive programming. Choose the hybrid approach when you want WebFlux concurrency but must keep the proven JDBC ecosystem.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
