起因及介紹
在早期的賬戶系統中,但凡有賬戶變動,就會執行一次資料庫操作。這樣在有複雜一些業務操作的時候,例如單筆交易涉及多個使用者多個費用的資金劃撥,一個事務內運算元據庫幾十次也就大量的存在。而觀察這樣的場景,其本質可能只涉及少數幾方的賬戶。
這時,在一次處理過程中,合併同一個賬戶的所有操作,最後只提交一次,就能帶來很大的優化空間。
處理方法
1. 初始化一個收集器ExecuteParam,用來存放有變動的賬戶、待新增的資金記錄、待處理的凍結資料和待新增的凍結記錄。
final ExecuteParam param = ExecuteParam.instance();
public class ExecuteParam {
private final Map<String, FinanceAccount> cache = Maps.newHashMap();
private final List<FinanceLog> financeLogs = Lists.newArrayList();
private final Map<String, AccFundManagementRecord> freezeRecords = Maps.newHashMap();
private final List<AccFundManagementHistory> freezeHistorys = Lists.newArrayList();
public static ExecuteParam instance() {
return new ExecuteParam();
}
public Map<String, FinanceAccount> getCache() {
return cache;
}
public List<FinanceLog> getFinanceLogs() {
return financeLogs;
}
public Map<String, AccFundManagementRecord> getFreezeRecords() {
return freezeRecords;
}
public List<AccFundManagementHistory> getFreezeHistorys() {
return freezeHistorys;
}
}
2. 根據業務需要,進行增、減、轉賬、凍結、解凍操作。
public interface FundTransactionService {
/** 調增 */
void addCredit(TransactionCommandParam command, final ExecuteParam param);
/** 調減 */
void addDebit(TransactionCommandParam command, final ExecuteParam param);
/** 轉賬 */
void addTransfer(TransactionCommandParam command, final ExecuteParam param);
/** 凍結 */
String addFreeze(TransactionCommandParam command, final ExecuteParam param);
/** 解凍 */
BigDecimal addUnfreeze(TransactionCommandParam command, final ExecuteParam param);
/** 更新DB */
void execute(String proofId, ExecuteParam param);
}
public static TransactionCommandParam createTransfer(...);
public static TransactionCommandParam createFreeze(...);
public static TransactionCommandParam createUnfreeze(...);
public static TransactionCommandParam createCredit(...);
public static TransactionCommandParam createDebit(...);
3. 所有資金操作在底層都按照:校驗操作型別->修改賬戶餘額->資金記錄的流程執行
@Override
public void addCredit(TransactionCommandParam command, final ExecuteParam param) {
/** 1.校驗 */
/** 2.調賬 */
FinanceAccount receiverFa = credit(command.getReceiverOwnerId(), command.getReceiverRoleId(), command.getAmount(), param.getCache());
/** 3.資金記錄 */
param.getFinanceLogs().add(...);
}
4. 其中修改賬戶餘額的方法,會先嚐試從ExecuteParam中查詢該賬戶是否已經被操作過,如果沒有才查詢一次DB。這樣就確保了同一個賬戶在一次處理過程中,無論有多少資金操作,只會查詢一次DB。
private FinanceAccount credit(Long ownerId, Long roleId, BigDecimal amount, Map<String, FinanceAccount> cache) {
final String cacheKey = getCacheKey(ownerId, roleId);
FinanceAccount fa = cache.get(cacheKey);
if (fa == null) {
// 此處只查詢一次DB
fa = getFinanceAccount(ownerId, roleId);
cache.put(cacheKey, fa);
}
// 調增:
fa.credit(amount);
return fa;
}
5. 當所有業務操作完成之後,一次性提交本次處理過程中的所有賬戶
fundTransactionService.execute(proof.getProofId(), param);
@Override
public void execute(String proofId, ExecuteParam param) {
/** FinanceAccount統一更新 */
for (FinanceAccount account : param.getCache().values()) {
account.setProofId(proofId);
// 熱點賬戶延遲更新
if (isHotAccount(account.getId())) {
continue;
}
// DB update
this.updateAccount(account);
logger.info("賬戶更新[{}]", account);
}
/** FinanceLog統一批量記錄 */
financeLogDao.addFinanceLog(param.getFinanceLogs());
/** 凍結記錄統一批量更新 */
for (AccFundManagementRecord freezeRecord : param.getFreezeRecords().values()) {
if (freezeRecord.getId() != null) {
// DB update
} else {
// DB insert
}
logger.info(LoggerUtil.createInfoLog("execute","凍結記錄[{}]"), freezeRecord);
}
/** 凍結歷史統一批量更新 */
for (AccFundManagementHistory history : param.getFreezeHistorys()) {
// DB insert
}
}
總結和思考
這次優化不僅大幅減少了資料庫的負擔,而且也因為資料庫訪問次數少了,處理速度也快了(例如還款,原先的處理時間約為1到2s,優化後的處理時間約為40ms)。處理速度快了,使用樂觀鎖控制的併發異常也相應減少了。
另外值得思考的地方是,在第一步初始化收集器ExecuteParam的時候,將所有容器都建立出來了,並不是所有業務都會用到全部的容器,這裡是否有必要?
我的想法是讓步於開發便利性。
誠然是可以根據不同的場景有選擇性的初始化相應的容器,但是這樣開發人員在使用的時候需要思考的更多,需要做選擇,不夠簡單明瞭。而且省去一兩個容器的初始化帶來的好處可以並不大。