可靠事件實現微服務下最終一致性事務

通過《消費者實現應用內分佈式事務》、《生產者實現應用內分佈式事務管理》、《實現持久訂閱消費者》三個章節的實踐,其實我們已經可以通過消息隊列實現多應用的分佈式事務,應用內的事務保證了消息不會被重複生產消費、持久化訂閱保證了消息一定會被消費(進入死信隊列特殊處理),但其對於業務來說耦合性還是太強,在進行業務處理的同時消息處理名,其採用的仍然是應用內的事務處理,並不適合在大型高性能高併發系統實踐,那麼本章將通過本地事務+消息隊列+外部事件定義表+定時任務實現解耦。
(目前主要實現微服務下最終一致性事務的方案主要是:可靠事件;補償模式;TCC(Try-Confirm-Cancel);三種,本案例爲可靠事件方式)

場景描述:
本場景就拿最常用的轉賬業務闡述:
在工行ICBC有賬號Card001,其中存於500元;
在中行BOC有賬號Card002,其中也存有500元;
此時從Card001賬號轉賬至Card002賬號300元。

系統設計:
工行系統ICBCPro,該工程主要實現兩個功能(實現轉出金額生成轉賬事件;定時任務發出轉賬事件至消息隊列),主要參考《生產者實現應用內分佈式事務管理》實現;
中行系統BOCPro,該工程主要實現兩個功能(從消息隊列下載轉賬事件;定時任務對轉賬事件處理並更新轉入賬號金額),主要參考《消費者實現應用內分佈式事務》實現;
此場景僅需要通過P2P消息模式即可。

構建ICBCPro工程

A、實現轉出金額生成轉賬事件
1、構建數據庫相關表以及基礎數據:
轉出賬號數據

轉出事件記錄

消息隊列

消息控制檯

2、執行單元測試代碼實現轉賬,此時賬戶扣除與轉賬事件記錄均在本地事務內:

//ICBC中賬戶card001轉出300元

@Test
	public void tranfer(){
		EventLog eventLog = new EventLog();
		eventLog.setAmount(new BigDecimal(300));
		eventLog.setFromcard("card001");
		eventLog.setTocard("card002");
		eventLog.setEventstate(EventState.NEW);
		eventLog.setTransferDate(new Date());
		eventLogService.transfer(eventLog,new BigDecimal(300));	
	}

賬戶信息:

事件記錄


B、定時任務發出轉賬事件至消息隊列

對於事件記錄表,我們可以定義一個定時任務,將所有的NEW狀態事件全部發出,此時需要保證消息的可靠性,採用XA事務實現,但已經不影響我們業務的響應了,實現解耦、快速響應,下面貼出核心實現代碼:
1、首選實現數據排它鎖場景下的查詢與更新:
/**
	 * 在排它鎖場景下數據更新,保證數據的可靠性
	 */
	@Override
	public void updateEventstateById(String id, EventState eventState) {
		EventLog eventLog=findEventLog4Update(id);
		eventLog.setEventstate(eventState);
		emJ1.merge(eventLog);
	}

	/**
	 * 實現排它鎖查詢
	 */
	@Override
	public EventLog findEventLog4Update(String id){
		EventLog eventLog=emJ1.find(EventLog.class, id, LockModeType.PESSIMISTIC_WRITE);
		return eventLog;
	}

2、在service定義查詢所有NEW狀態的事件、並採用XA事務管理NEW狀態事件的發送與更新(爲了驗證了事務生效,設定了一個fromcard爲空的數據觸發異常),在異常情況下我們也需要保證countDownLatch執行,避免線程阻塞:
@Service
public class EventLogService {
	@Autowired
	private EventLogRepository eventLogRepository;
	@Resource(name="jmsQueueMessagingTemplate")
	private JmsMessagingTemplate jmsQueueMessagingTemplate;
	@Autowired
	@Qualifier("icbc2boc")
	private Queue icbc2boc;
....
	
	/**
	 * 根據eventstate獲取EventLog數據集
	 * @param eventstate
	 * @return
	 */
	@Transactional(transactionManager="transactionManager1",propagation=Propagation.SUPPORTS,readOnly=true)
	public List<EventLog> findByEventState(EventState eventstate){
		return eventLogRepository.findByEventstate(eventstate);
	}
	
	/**
	 * XA事務
	 * @param id
	 * @param eventstate
	 */
	@Transactional(transactionManager="transactionManagerJTA",propagation=Propagation.REQUIRES_NEW)
	public void transferToMQ(EventLog eventLog,EventState eventstate,CountDownLatch countDownLatch){
		try {
			System.out.println(Thread.currentThread().getName()+"本次處理數據:"+eventLog.getFromcard()+"、"+eventLog.getEventstate());
			//再次數據庫查詢判斷,此時用到排它鎖--在兩個定時任務連續執行,一旦出現程序提交事務命令至數據庫,
			//但數據庫還未執行,此時我們全表查詢的結果中當前數據行仍爲修改前數據,故會造成重複消費
			eventLog=eventLogRepository.findEventLog4Update(eventLog.getId());
			if(EventState.Publish.equals(eventLog.getEventstate())){
				System.out.println(Thread.currentThread().getName()+"數據:"+eventLog.getFromcard()+"無需處理");
				return;
			}
			//payload
			jmsQueueMessagingTemplate.convertAndSend(icbc2boc,eventLog);
			eventLogRepository.updateEventstateById(eventLog.getId(), eventstate);
			//構造異常場景驗證XA事務
			if(eventLog.getFromcard()==null){
				System.out.println(Thread.currentThread().getName()+"數據異常,不處理");
				System.out.println(1/0);
			}else{
				System.out.println(Thread.currentThread().getName()+":"+eventLog.getFromcard()+"數據處理成功");
			}
		} finally {
			countDownLatch.countDown();
		}
	}
}
3、定義Job,實現轉出任務,並通過線程池異步處理待處理事件集合,通過併發提高處理性能,通過countDownLatch保證了每個任務所有線程處理完成後啓動下一次任務;
/**
 * 轉出任務
 * @author song
 */
@PersistJobDataAfterExecution
@DisallowConcurrentExecution //保證每次任務執行完畢,設置爲串行執行
public class TransferJob  extends QuartzJobBean {
	private Logger logger=LoggerFactory.getLogger(TransferJob.class);
	@Autowired
	@Qualifier("quartzThreadPool")
	private ThreadPoolTaskExecutor quartzThreadPool;
	@Autowired
	private EventLogService eventLogService;
	
	@Override
	protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
		logger.info("本次批處理開始");
		//獲取所有未發送狀態的Event
		List<EventLog> list=eventLogService.findByEventState(EventState.NEW);
		//
		final CountDownLatch countDownLatch=new CountDownLatch(list.size());
		
		//遍歷發送
		for(final EventLog eventLog:list){
			//通過線程池提交任務執行,大大提高處理集合效率
			quartzThreadPool.submit(new Runnable() {
				
				@Override
				public void run() {
					eventLogService.transferToMQ(eventLog,EventState.Publish,countDownLatch);
				}
			});
		}
		
		//保證所有線程執行完成後退出
		try {
			countDownLatch.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		logger.info("本次批處理完成");
	}
}
4、定義轉出任務、觸發器、調度器以及處理線程池:
@Bean(name="tranferJob")
	public JobDetailFactoryBean tranferJob(){
		JobDetailFactoryBean factoryBean=new JobDetailFactoryBean();
		//定義任務類
		factoryBean.setJobClass(TransferJob.class);
		//表示任務完成之後是否依然保留到數據庫,默認false
		factoryBean.setDurability(true);
		//爲Ture時當Quartz服務被中止後,再次啓動或集羣中其他機器接手任務時會嘗試恢復執行之前未完成的所有任務,默認false
		factoryBean.setRequestsRecovery(true);
		return factoryBean;
	}
	
	/**
	 * 註冊job1的觸發器
	 * @return
	 */
	@Bean(name="transferJobTrigger")
	public CronTriggerFactoryBean transferJobTrigger(){
		//觸發器
		CronTriggerFactoryBean cronTriggerFactoryBean = new CronTriggerFactoryBean();
		cronTriggerFactoryBean.setCronExpression("*/5 * * * * ?");
		cronTriggerFactoryBean.setJobDetail(tranferJob().getObject());
		//調度工廠實例化後,經過5秒開始執行調度
		cronTriggerFactoryBean.setStartDelay(30000);
		cronTriggerFactoryBean.setGroup("tranfer");
		cronTriggerFactoryBean.setName("tranfer");
		return cronTriggerFactoryBean;
	}

	/**
	 * 調度工廠,加載觸發器,並設置自動啓動、啓動時延
	 * @return
	 */
	@Bean(name="transferSchedulerFactoryBean")
	public SchedulerFactoryBean transferSchedulerFactoryBean(){
		//調度工廠
		SchedulerFactoryBean schedulerFactoryBean= new SchedulerFactoryBean();
		schedulerFactoryBean.setConfigLocation(new ClassPathResource("quartz.properties"));
		schedulerFactoryBean.setApplicationContextSchedulerContextKey("applicationContextKey");
		//集羣Cluster下設置dataSource
//		schedulerFactoryBean.setDataSource(dataSource);
		//QuartzScheduler啓動時更新己存在的Job,不用每次修改targetObject後刪除qrtz_job_details表對應記錄了
		schedulerFactoryBean.setOverwriteExistingJobs(true);
		//QuartzScheduler延時啓動20S,應用啓動完後 QuartzScheduler 再啓動
		schedulerFactoryBean.setStartupDelay(20);
		//自動啓動
		schedulerFactoryBean.setAutoStartup(true);
		schedulerFactoryBean.setTriggers(transferJobTrigger().getObject());
		//自定義的JobFactory解決job中service的bean注入
		schedulerFactoryBean.setJobFactory(jobFactory);
		return schedulerFactoryBean;
	}
	
	/**
	 * 用於處理待轉賬數據發至消息隊列的線程池
	 * @return
	 */
	@Bean(name="quartzThreadPool")
	public ThreadPoolTaskExecutor getThreadPoolTaskExecutor(){
		ThreadPoolTaskExecutor pool=new ThreadPoolTaskExecutor();
		pool.setCorePoolSize(10);
		pool.setQueueCapacity(100);
		pool.setMaxPoolSize(10);
		pool.setKeepAliveSeconds(10);
		//避免應用關閉,任務沒有執行完成,起到shutdownhook鉤子的作用
		pool.setWaitForTasksToCompleteOnShutdown(true);
		//空閒時核心線程也不退出
		pool.setAllowCoreThreadTimeOut(false);
		//設置拒絕策略,不可執行的任務將被拋棄
		pool.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
		return pool;
	}

小結
特別注意:
1、週期時間剛好兩個定時任務連續執行,出現java程序提交事務緊接第二個任務啓動,但數據庫未完成命令,此時後續任務已經查詢數據,全表過濾能夠再次獲取未提交數據行原始數據,會造成二次消費,故需要對其採用排它鎖方式,二次查詢判斷後決定是否消費,從而規避二次消費問題;
2、注意在Service中不能隨便catch異常,避免分佈式事務未回滾,造成重複消費;
3、通過CountDownLatch,實現任務線程等待所有的子任務線程執行完畢後方可退出本次任務,執行下一個任務,故其一定要在finally中實現countdown,避免造成任務線程阻塞;
4、需要設置OpenEntityManagerInViewInterceptor攔截器,避免提示session過早關閉問題;
5、數據庫DataSource必須定義好destroyMethod,避免程序關閉,事務還未提交的情況下出現連接池已經關閉;
6、設置好連接池需要等待已提交任務完成後方可shutdown;

優化空間:
1、根據數據特徵進行任務分割,比如自增ID場景下,根據0、1、2等最後一位尾數分割不同的定時任務,配置任務集羣,從而實現分佈式高可用集羣處理;
2、在數據查詢處理過程中,優化sql,提高單次查詢性能;
3、添加獨立的定時任務,將Publish已消費數據轉儲,減輕單表壓力;
4、目前已經加入線程池異步處理數據集合,提高單次任務執行效率;
5、一旦數據庫壓力比較大的情況下,也可以將Event分庫操作,減輕服務器數據庫連接、IO壓力;
6、採用微服務模式,將兩個功能實現服務分離;
7、也可以在定時任務中添加比如50MS的sleep時長,保證數據庫服務器端事務提交成功,取消排它鎖將進一步提高性能較小數據庫死鎖問題;

遺留問題:
1、在開發環境下,手動關閉程序MQ連接會過早關閉,修改數據後事務未提交,出現MySQL數據庫行已經被執行排他鎖;

構建BOCPro工程

A、從消息隊列下載轉賬事件
1、構建數據庫BOC數據庫相關表以及基礎數據:

事件表暫時爲空

消息隊列,有一條轉賬數據


2、配置隊列消息模板:
@Configuration
public class JmsMessageConfiguration {
	@Autowired
	@Qualifier(value="jmsQueueTemplate")
	private JmsTemplate jmsQueueTemplate;
	
	/**
	 * 定義點對點隊列
	 * @return
	 */
	@Bean(name="icbc2boc")
	public Queue queue() {
		return new ActiveMQQueue("icbc2boc");
	}
	
	/**
	 * 創建處理隊列消息模板
	 * @return
	 */
	@Bean(name="jmsQueueMessagingTemplate")
	public JmsMessagingTemplate jmsQueueMessagingTemplate() {
		JmsMessagingTemplate jmsMessagingTemplate =new JmsMessagingTemplate(jmsQueueTemplate);
		//通過MappingJackson2MessageConverter實現Object轉換
		jmsMessagingTemplate.setMessageConverter(new MappingJackson2MessageConverter());
		return new JmsMessagingTemplate(jmsQueueTemplate);
	}
	
}
3、配置監聽器,監聽轉賬事件消息:
@Component
public class TraferIn {

	@Autowired
	@Qualifier("icbc2boc")
	private Queue queue;
	@Autowired
	@Qualifier("jmsQueueMessagingTemplate")
	private JmsMessagingTemplate jmsQueueMessagingTemplate;
	@Autowired
	private EventLogService eventLogService;
	
	/**
	 * 定義監聽轉賬事件監聽
	 * @param text
	 * @throws Exception
	 */
	@JmsListener(destination = "icbc2boc",containerFactory="jmsListenerContainerFactory4Queue")//ActiveMQ.DLQ
	public void receiveQueue(EventLog eventLog) throws Exception {
		System.out.println("接受到的事件數據:"+eventLog.toString());
		eventLogService.mq2transfer(eventLog, new BigDecimal(300));
	}
}

4、採用分佈式事務管理下載的消息隊列事件,模擬事務失效,驗證成功:
/**
	 *  XA事務
	 * @param eventLog
	 * @param amount
	 */
	@Transactional(transactionManager="transactionManagerJTA",propagation=Propagation.REQUIRED)
	public void mq2transfer(EventLog eventLog,BigDecimal amount){
		//保存事件日誌
		eventLogRepository.saveEvetLog(eventLog);
//		System.out.println(1/0);
	}
5、需要採用XA事務,故我們不能直接通過EventLogRepository保存數據,定義自定義保存方法:
/**
	 * 採用分佈式事務數據源保存事件
	 */
	@Override
	public EventLog saveEvetLog(EventLog eventLog) {
		return emJ1.merge(eventLog);
	}
6、啓動程序監聽後,收到事件

數據庫添加了一條NEW狀態事件

消費後消息隊列被清空



B、定時任務對轉賬事件處理並更新轉入賬號金額

通過定時任務掃描下載的所有事件,並啓動線程池異步快速處理所有的轉賬事件,可能一批次事件中會出現同個賬號多次記錄的場景,更新操作爲非冪等操作,故我們需要採用排它鎖的方式對數據行更新。並且通過本地事務的方式管理事件和賬號表更新,從而大大提高了業務處理速度。通過此方式也實現了業務和事件的解耦。
1、本地爲本地事務處理,故我們可以很方便在EventLogRepository通過接口定義即可解決查詢、更新,主要包含查詢所有的NEW狀態事件、查詢單個t_card表實現排它鎖(解決多線程下的冪等性)、更新事件狀態,特別關注在接口中如何實現原生SQL排它鎖查詢的註解定義:
/**
	 * 實現排它鎖
	 * @param id
	 * @return
	 */
	@Query(value="select t.id from t_card t where t.id=:id for update",nativeQuery=true)
	void findCard4UpdateById(@Param("id")String id);

	/**
	 * 更新EventLog狀態
	 * @param id
	 * @param eventstate
	 * @return
	 */
	@Modifying
	@Query(value = "update EventLog e set e.eventstate=:eventstate  where e.id = :id ")
	int updateEventstateById(@Param("id")String id,@Param("eventstate")EventState eventstate);
	
	/**
	 * 根據EventState查詢所有EventLog
	 * @param EventState
	 * @return
	 */
	List<EventLog> findByEventstate(EventState eventState);
2、採用ICBCCPro項目中一樣的原生SQL更新語句,主要處理賬號金額調整,其實也可以通過接口定義實現,多個方式玩玩:
/**
	 * 執行原生語句實現更新
	 */
	@Override
	public int executeUpdateNativeSQL(String strSQL) {
		return em1.createNativeQuery(strSQL, Integer.class).executeUpdate();
	}
3、定義service中真正處理轉賬事件的邏輯,在其中我們在多線程的場景下,對事件表和Card表採用了不一樣的鎖機制,事件表通過樂觀鎖避免重複消費,保證事件處理冪等性:
/**
	 * 本地事務
	 * @param id
	 * @param eventstate
	 */
	@Transactional(transactionManager="transactionManager1",propagation=Propagation.REQUIRES_NEW)
	public void transfer(EventLog eventLog,EventState eventstate,CountDownLatch countDownLatch){
		try {
			System.out.println(Thread.currentThread().getName()+"本次處理數據轉入賬號:"+eventLog.getTocard()+"、"+eventLog.getEventstate());
			//通過樂觀鎖方式再次判斷,保證事件的可靠消息,僅在極端情況下會出現重複消費,故採用樂觀鎖
			int updateCount=eventLogRepository.updateEventstateById(eventLog.getId(),eventstate);
			//如果等於則表明已經處理過
			if(updateCount==0){
				System.out.println(Thread.currentThread().getName()+"數據收款卡號:"+eventLog.getTocard()+"無需處理");
				return;
			}
			//沒有被處理過需要繼續更新賬戶金額
			//更新查詢,採用排它鎖方式,避免在多線程任務下,出現多個線程修改同一個卡號,從而事務冪等性
			eventLogRepository.findCard4UpdateById(eventLog.getTocard());
			//更新賬戶信息,轉入累加,屬於非冪等操作
			eventLogRepository.executeUpdateNativeSQL("update t_card set amount=amount+"+eventLog.getAmount()+" where id='"+eventLog.getTocard()+"'");
//			System.out.println(1/0);
			System.out.println(Thread.currentThread().getName()+":"+eventLog.getFromcard()+"數據處理成功");
		} finally {
			countDownLatch.countDown();
		}
	}
4、定義定時任務,其主要注入了一個線程池協助我們快速異步處理所有事件,通過CountDownLatch 保證了同一次任務所有事件處理完成後方可退出任務線程,然後啓動下一次任務,保證任務串行執行:
@PersistJobDataAfterExecution
@DisallowConcurrentExecution //保證每次任務執行完畢,設置爲串行執行
public class TransferJob  extends QuartzJobBean {
	private Logger logger=LoggerFactory.getLogger(TransferJob.class);
	@Autowired
	@Qualifier("quartzThreadPool")
	private ThreadPoolTaskExecutor quartzThreadPool;
	@Autowired
	private EventLogService eventLogService;
	
	@Override
	protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
		logger.info("本次批處理開始");
		//獲取所有未發送狀態的Event
		List<EventLog> list=eventLogService.findByEventState(EventState.NEW);
		//
		final CountDownLatch countDownLatch=new CountDownLatch(list.size());
		
		//遍歷發送
		for(final EventLog eventLog:list){
			//通過線程池提交任務執行,大大提高處理集合效率
			quartzThreadPool.submit(new Runnable() {
				
				@Override
				public void run() {
					eventLogService.transfer(eventLog, EventState.Publish, countDownLatch);
				}
			});
		}
		
		//保證所有線程執行完成後退出
		try {
			countDownLatch.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		logger.info("本次批處理完成");
	}
}

5、啓動定時任務後

數據庫,事件表狀態更新:

金額中card金額累計:


小結
特別注意:
1、對應不同的表和操作,我們需要採用不一樣的鎖機制,首先判斷操作是否具有冪等性;
2、注意在Service中不能隨便catch異常,避免分佈式事務未回滾,造成重複消費;
3、通過CountDownLatch,實現任務線程等待所有的子任務線程執行完畢後方可退出本次任務,執行下一個任務,故其一定要在finally中實現countdown,避免造成任務線程阻塞;
4、需要設置OpenEntityManagerInViewInterceptor攔截器,避免提示session過早關閉問題;
5、數據庫DataSource必須定義好destroyMethod,避免程序關閉,事務還未提交的情況下出現連接池已經關閉;
6、設置好連接池需要等待已提交任務完成後方可shutdown;

優化空間:
1、採用微服務模式,將兩個功能實現服務分離;
2、A功能:根據隊列消息的特性,在有多個消費者的情況下,其也僅僅會被消費一次,故我們可以構建多個消費者服務器,從而實現異步下載壓力水平分攤;
3、設定合理的數據庫連接池大小,從而實現限流作用,避免數據庫服務器壓力過大;
4、B功能:根據數據特徵進行任務分割,比如自增ID場景下,根據0、1、2等最後一位尾數分割不同的定時任務,配置任務集羣,從而實現分佈式高可用集羣處理;
5、在數據查詢處理過程中,優化sql,提高單次查詢性能;
6、添加獨立的定時任務,將Publish已消費數據轉儲,減輕單表壓力;
7、目前已經加入線程池異步處理數據集合,提高單次任務執行效率;
8、一旦數據庫壓力比較大的情況下,也可以根據賬號的分庫情況將Event分庫操作,減輕服務器數據庫連接、IO壓力;

總結

1、通過微服務理念,實現服務分離,更加容易進行服務治理與水平擴展;
2、通過本地事務處理業務實現高性能;
3、通過P2P模式消息+Mysql+排它鎖+分佈式事務+串行任務保證了事件不會重複發送、不會重複消費(可靠傳遞),並且實現了系統解耦、異步處理、流量銷峯;
4、通過對事件的生產+消費實現了最終一致性事務;









發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章