使用 Spring 實作讀寫和只讀事務路由
1. 引言
在本教程中,我們將使用 Spring 實作基於事務的路由,將寫入操作傳送到主資料庫,將唯讀操作傳送至副本資料庫。這在利用資料庫複製來提升讀取速度的應用程式中是一種常見的模式。
2. 為什麼要路由交易?
如果沒有路由,每個查詢都會存取同一個資料庫。隨著流量增長,這可能會成為瓶頸,因此複製機制透過維護與主資料庫鏡像的副本來緩解這個問題。這種策略可以提高讀取速度,但應用程式仍然需要知道將每個查詢發送到哪裡。我們將採用的方法讓事務元資料自動決定:
透過使用 Spring 的AbstractRoutingDataSource ,並結合@Transactional註解的readOnly標誌,我們可以攔截每個連接請求並將其定向到合適的資料庫。
3. 場景設定
我們先從一個簡單的實體和一個用於範例的儲存庫開始。這一層不需要任何特殊配置。
3.1 建立實體
我們先從一個具有基本屬性的Order實體開始。我們將產生策略明確設定為IDENTITY ,以便資料庫的自增列提供 ID:
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
// default getters and setters and constructors
}
3.2 建立儲存庫
接下來,我們將建立一個 Spring Data 儲存庫:
public interface OrderRepository
extends JpaRepository<Order, Long> {
}
現在我們已經具備了實現路由資料來源所需的一切。
4. 實作路由DataSource
我們將擴展AbstractRoutingDataSource ,使其能夠根據當前事務決定使用哪個DataSource 。
4.1 定義DataSource類型
首先,讓我們建立一個enum來表示配置時DataSource類型:
public enum DataSourceType {
READ_WRITE, READ_ONLY
}
我們將使用READ_WRITE作為主DataSource (所有inserts 、 updates和deletes都會進行到這裡)的查找鍵,而使用READ_ONLY作為副本(專門用於select查詢)的查找鍵。
4.2 建立路由邏輯
現在,我們將擴展AbstractRoutingDataSource並重寫determineCurrentLookupKey() 。此方法透過TransactionSynchronizationManager檢查目前事務是否為唯讀:
public class TransactionRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
boolean readOnly = TransactionSynchronizationManager
.isCurrentTransactionReadOnly();
if (readOnly) {
return DataSourceType.READ_ONLY;
}
return DataSourceType.READ_WRITE;
}
}
Spring 在需要連線時會呼叫此方法,因此我們將使用它來將請求路由到主伺服器或副本伺服器。
5. 配置DataSources
我們需要定義兩個資料來源的屬性,然後將它們作為 bean 連接起來。請注意,此設定僅控制給定事務使用哪個連線。路由層不會阻止向副本連線發出寫入請求,因為此強制措施是在資料庫層級配置的。
在現實世界中,這意味著副本使用者通常只被授予SELECT權限,或副本資料庫本身被配置為拒絕寫入。
5.1 定義應用程式屬性
讓我們使用「 spring.datasource 」前綴在application.properties中定義連線屬性:
spring.datasource.readwrite.url=jdbc:h2:mem:primary;DB_CLOSE_DELAY=-1
spring.datasource.readwrite.username=sa
spring.datasource.readwrite.driverClassName=org.h2.Driver
spring.datasource.readonly.url=jdbc:h2:mem:replica;DB_CLOSE_DELAY=-1
spring.datasource.readonly.username=sa
spring.datasource.readonly.driverClassName=org.h2.Driver
每個資料來源都指向其自身的記憶體 H2 資料庫。我們將DB_CLOSE_DELAY設為-1這樣在最後一個連線關閉時,這些資料庫就不會被丟棄。
5.2. 配置Bean的連接
由於我們定義了一個自訂的DataSource bean,因此不會使用 Spring Boot 的 JPA 自動設定。這意味著除非我們明確聲明,否則OrderRepository將不會包含EntityManagerFactory或TransactionManager @EnableJpaRepositories註解允許我們將儲存庫掃描器指向我們將在此類中定義的 bean:
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackageClasses = OrderRepository.class,
entityManagerFactoryRef = "routingEntityManagerFactory",
transactionManagerRef = "routingTransactionManager"
)
public class DataSourceConfiguration {
// ...
}
basePackageClasses屬性會從我們為元件掃描提供的類別中提取包名。為了能夠掃描所有需要的元件, OrderRepository和Order必須位於同一個套件中。
我們首先根據各自的屬性前綴建立兩個DataSource bean。首先,讀取「 spring.datasource.readwrite.* 」屬性:
@Bean
@ConfigurationProperties("spring.datasource.readwrite")
public DataSourceProperties readWriteProperties() {
return new DataSourceProperties();
}
然後,讀取“ spring.datasource.readonly.* ”屬性:
@Bean
@ConfigurationProperties("spring.datasource.readonly")
public DataSourceProperties readOnlyProperties() {
return new DataSourceProperties();
}
接下來,我們建構實際的DataSource實例。首先,對於讀寫資料來源:
@Bean
public DataSource readWriteDataSource() {
return readWriteProperties()
.initializeDataSourceBuilder()
.build();
}
然後,對於唯讀來源:
@Bean
public DataSource readOnlyDataSource() {
return readOnlyProperties()
.initializeDataSourceBuilder()
.build();
}
5.3 設定我們的TransactionRoutingDataSource
現在,我們在TransactionRoutingDataSource bean 中連接路由DataSource :
@Bean
public TransactionRoutingDataSource routingDataSource() {
TransactionRoutingDataSource routingDataSource =
new TransactionRoutingDataSource();
// ...
return routingDataSource;
}
我們將把這兩個目標註冊到一個映射表中,在呼叫setTargetDataSources()時會用到這個映射表:
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put(DataSourceType.READ_WRITE, readWriteDataSource());
dataSourceMap.put(DataSourceType.READ_ONLY, readOnlyDataSource());
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(readWriteDataSource());
我們也呼叫了setDefaultTargetDataSource() ,它定義了當例如程式碼在交易之外運行時要使用的回退資料來源。
5.4 定義惰性DataSource
由於 JPA 在交易同步設定只讀標誌之前就已經取得了連接,我們需要將路由DataSource包裝在LazyConnectionDataSourceProxy中。這樣,實際的連線就會延遲到第一個 SQL 語句執行時才會被建立:
@Bean
@Primary
public DataSource dataSource() {
return new LazyConnectionDataSourceProxy(routingDataSource());
}
我們將此 bean 標記為 @Primary LocalContainerEntityManagerFactoryBean這樣**@Primary**自動**組裝****DataSource,**就不會使用非延遲載入的routingDataSource() 。然後,我們配置EntityManagerFactory來使用我們的延遲載入代理程式。 LocalContainerEntityManagerFactoryBean 是手動管理 JPA 配置時的標準選擇,因為它與 Spring 的生命週期整合:
@Bean
public LocalContainerEntityManagerFactoryBean routingEntityManagerFactory(
EntityManagerFactoryBuilder builder) {
return builder
.dataSource(dataSource())
.packages(OrderRepository.class)
.build();
}
我們還需要一個與工廠關聯的TransactionManager 。我們傳回一個JpaTransactionManager ,以便@Transactional方法將事務管理器的readOnly標誌傳遞給我們的路由邏輯:
@Bean
public PlatformTransactionManager routingTransactionManager(
LocalContainerEntityManagerFactoryBean routingEntityManagerFactory) {
return new JpaTransactionManager(
Objects.requireNonNull(routingEntityManagerFactory.getObject()));
}
完成所有這些配置後,我們就可以進入服務層了。
6. 建立服務層
我們將建立一個使用@Transactional註解來控制路由的服務:
@Service
public class OrderService {
@Autowired
OrderRepository orderRepository;
// ...
}
帶有readOnly = true註解的方法會被路由到副本,而其他交易則會被路由到主DataSource :
@Transactional
public Order save(Order order) {
return orderRepository.save(order);
}
@Transactional(readOnly = true)
public List<Order> findAllReadOnly() {
return orderRepository.findAll();
}
@Transactional
public List<Order> findAllReadWrite() {
return orderRepository.findAll();
}
在這個例子中,我們為每個DataSource定義了一個查找方法。這在後面的測試中會很有用。
7. 設定測試
在實際場景中,副本會持續與主節點同步資料。但為了簡化測試,我們將保持副本與主節點不同步的狀態。我們將利用這種隔離來驗證路由功能是否正常。
因此,在我們的例子中,如果只讀查詢傳回由讀寫事務寫入的數據,我們就知道它存取的是主庫而不是副本庫。
讓我們來設定測試類別:
@SpringBootTest
class TransactionRoutingIntegrationTest {
@Autowired
OrderService orderService;
// ...
}
首先,我們驗證讀寫事務是否保留在主資料庫上,以便保存的訂單立即可見:
@Test
void whenSaveAndReadWithReadWrite_thenFindsOrder() {
Order saved = orderService.save(new Order("laptop"));
List<Order> result = orderService.findAllReadWrite();
assertThat(result)
.anyMatch(o -> o.getId().equals(saved.getId()));
}
然後,我們確認只讀交易已路由到副本。由於副本是一個獨立的、未同步的資料庫,因此它無法存取保存在主DataSource中的order :
@Test
void whenSaveAndReadWithReadOnly_thenOrderNotFound() {
Order saved = orderService.save(new Order("keyboard"));
List result = orderService.findAllReadOnly();
assertThat(result)
.noneMatch(o -> o.getId().equals(saved.getId()));
}
我們明確檢查findAllReadOnly()是否傳回已儲存訂單的 ID,以驗證該特定訂單是否已路由至副本。
8. 注意事項
讓我們來探討一下在使用此路由模式時需要注意的一些實際方面。
8.1.複製滯後
寫入主庫到資料同步到副本庫之間存在延遲。延遲時間的長短取決於我們的基礎架構。
因此,對於時間敏感型流程(例如寫入實體並立即讀取),將讀取操作路由到副本可能會傳回過時的資料。在這種情況下,更安全的做法是在單一讀寫事務中執行這兩個操作。
8.2. 嵌套交易
在 Spring 的預設事務傳播機制下,如果在@Transactional(readOnly = true)方法內部呼叫 ` @Transactional(readOnly = false)方法,則會使用已存在的交易。這意味著只有第一個建立的事務才會生效,後續@Transactional方法中的任何其他標誌都會被忽略,因此查詢仍然會傳送到主資料庫。**要真正將內部呼叫路由到副本,我們需要使用Propagation.REQUIRES_NEW來暫停外部事務並啟動一個唯讀事務。**
另請注意,如果這兩個方法都在同一個 bean 中,Spring 將不會攔截內部呼叫。
8.3. 多個唯讀副本
也可以使用多個只讀副本來分發流量。一種方法是擴展我們的路由邏輯,透過使用唯讀DataSources清單來輪換使用這些副本。 並使用determineCurrentLookupKey()中的輪詢實作來選擇一個,但這超出了本文的範圍。
更穩健的方法是將唯讀連線委託給負載平衡的連線 URL。根據我們的基礎架構,有許多可用於生產環境的替代方案,例如 PgBouncer 或 ProxySQL。
9. 結論
本文中,我們使用 Spring 的AbstractRoutingDataSource實作了基於交易的DataSource路由。我們探討如何分離讀寫流量和唯讀流量,為什麼需要LazyConnectionDataSourceProxy才能正確路由,以及如何透過整合測試驗證路由行為。
和往常一樣,原始碼可以在 GitHub 上找到。