写在前面
做人事系统之前,我以为"假期余额"就是个数字字段——发多少、用多少、减一减就完事了。
真开始写才发现,这里面的坑比想象中多得多:员工同时提交两份请假申请怎么办?审批到一半被人撤回,那部分额度怎么处理?网络抖动导致同一个请求重试了两次,会不会扣两遍?
这篇记录一下我在 Workforce Hub 里设计假期余额账务模型的过程,核心就三件事:余额要分账、并发要加锁、操作要幂等。
一、为什么不能只有一个余额字段
最直觉的做法是给员工一张表,里面一个 balance 字段:
-- 错误示范
UPDATE leave_account SET balance = balance - 480 WHERE user_id = ?;
这在单线程世界里没问题。但一旦引入审批流就漏了:
请假提交后不是立刻生效的,中间要经过审批。这段时间里额度该算什么状态?
如果提交时就直接扣,审批被驳回,得再补回来——补的过程又可能失败。
如果提交时不扣,审批通过才扣,那员工可以在审批期间把余额全提走,超支。
所以在流程上把余额拆成了两个数:

流程变成三段:
提交申请 → available 减少,frozen 增加 (冻结)
审批通过 → frozen 减少 (扣减)
审批驳回 → frozen 减少,available 增加 (解冻)
关键在于额度在任何时刻都有归属:要么可用、要么冻结,不会"凭空消失"或"两边都能花"。审批期间想再请假,可用余额已经不够了,自然超支不了。
二、四类流水,而不是直接改字段
只维护两个余额字段还不够——出了问题要能查清来龙去脉。所以我做了一张流水表,每次余额变动都记一笔:
private static final String TXN_TYPE_ADJUST = "ADJUST"; // 管理员调整
private static final String TXN_TYPE_FREEZE = "FREEZE"; // 提交时冻结
private static final String TXN_TYPE_UNFREEZE = "UNFREEZE"; // 驳回时解冻
private static final String TXN_TYPE_DEDUCT = "DEDUCT"; // 通过时扣减
每笔流水除了变动量,还记下变动后的余额(balanceAfter):
leaveRepository.insertAccountTxn(
IdGenerator.nextId(),
account.accountId(),
TXN_TYPE_FREEZE,
-durationMinutes, // 本次变动
availableAfter, // 变动后可用余额
SOURCE_TYPE_WORKFLOW_REQUEST,
requestId, // 来源单据
sourceTxnKey, // 幂等键
LocalDateTime.now()
);
这是从会计记账借来的思路:不要只看当前余额,要看每一笔怎么来的。员工来问"我这个月年假怎么少了 8 小时",翻流水就能一条条对上。
三、并发:行锁 + 乐观锁双保险
余额扣减是典型的并发敏感操作。假如同一个员工在两个设备上同时提交请假,两个请求都读到"可用 3 天",都判断"够扣",结果就超扣了。
我的处理是两层:
// 第一层:SELECT FOR UPDATE 行锁,防止并发扣减
LeaveAccountRow account = leaveRepository.getAccountForUpdate(userId, leaveTypeId);
if (account == null || account.availableMinutes() < durationMinutes) {
throw new BizException(400, "假期余额不足");
}
int availableAfter = account.availableMinutes() - durationMinutes;
int frozenAfter = account.frozenMinutes() + durationMinutes;
// 第二层:乐观锁,带上查询时的 version
leaveRepository.updateAccountBalances(
account.accountId(),
availableAfter,
frozenAfter,
operatorId,
account.version() // ← 版本号对不上就更新失败
);
行锁保证"读余额 → 判够不够 → 写回"这三步是串行的,不会被穿插。乐观锁则是最后一道防线:万一有别的路径绕过了行锁,版本号不匹配就会更新失败,而不是静默覆盖。
有人会问加两层是不是多余。我的看法是:余额出错是业务事故(员工请不了假、或者超支),多一层校验的成本远低于事后对账。能用锁解决的问题,不要留给人工排查。
四、幂等:不只是"重复就跳过"
这是我觉得最有意思的部分。
审批流会触发余额操作,而审批流本身可能重试——网络超时、MQ 重投、用户重复点击,都会让同一个 requestId 的操作被执行多次。如果不做幂等,同一份请假会冻结好几遍余额。
我的做法是给每个操作生成一个幂等键:
private String workflowFreezeSourceTxnKey(Long requestId) {
return SOURCE_TYPE_WORKFLOW_REQUEST + ":" + requestId + ":FREEZE";
}
执行前先查这个键有没有用过:
LeaveAccountTxnRow existingTxn = leaveRepository.getTxnBySourceTxnKey(sourceTxnKey);
if (existingTxn != null) {
// 已经有记录了
ensureWorkflowFreezeIdempotencyFingerprint(...);
return;
}
到这一步是常规操作——“键存在就跳过”。但我多加了一步指纹校验:
private void ensureWorkflowFreezeIdempotencyFingerprint(
LeaveAccountTxnRow existingTxn, LeaveAccountRow existingAccount,
Long userId, Long leaveTypeId, int durationMinutes, Long requestId) {
boolean sameFingerprint = TXN_TYPE_FREEZE.equals(existingTxn.txnType())
&& SOURCE_TYPE_WORKFLOW_REQUEST.equals(existingTxn.sourceType())
&& requestId.equals(existingTxn.sourceId())
&& existingTxn.changeMinutes().equals(-durationMinutes)
&& existingAccount.userId().equals(userId)
&& existingAccount.leaveTypeId().equals(leaveTypeId);
if (!sameFingerprint) {
throw new BizException(409, "幂等键已被不同假期冻结操作使用");
}
}
为什么不只判断"键存在"?
因为"键存在"这件事对应两种截然不同的情况:

第二种情况意味着数据出问题了——比如有人改了单号、或者两个不同操作复用了同一个 key。这时候如果也静默返回成功,脏数据就被掩盖了,等到对账时才发现,追溯成本极高。
所以我让它在参数不一致时明确抛错,宁可让调用方失败,也不要留下说不清的账。
五、一个兼容历史数据的取舍
扣减逻辑里有一段看起来有点绕的分支:
// 优先从冻结余额扣,降级到可用余额(兼容历史无冻结记录的申请)
LeaveAccountTxnRow freezeTxn = leaveRepository.getTxnBySourceTxnKey(
workflowFreezeSourceTxnKey(requestId));
if (account.frozenMinutes() >= durationMinutes) {
// 正常路径:从冻结里扣
balanceAfter = account.availableMinutes();
leaveRepository.updateAccountBalances(..., account.frozenMinutes() - durationMinutes, ...);
} else if (freezeTxn == null && account.availableMinutes() >= durationMinutes) {
// 历史申请没有冻结记录,直接扣可用余额
balanceAfter = account.availableMinutes() - durationMinutes;
leaveRepository.updateAccountBalance(..., balanceAfter, ...);
} else {
throw new BizException(400, "冻结假期余额不足");
}
为什么要留这个"降级"分支?因为系统上线前已经有一批历史申请,它们在冻结机制做出来之前就提交了,账上根本没有对应的冻结记录。如果强行要求"没有冻结记录就报错",这些单据审批通过时全部会失败。
所以策略是:先走正常路径(扣冻结),扣不动再检查是不是历史单,是的话扣可用余额,都不是才报错。这种情况在生产系统里很常见——新机制上线时,老数据总得有个兼容通道。
六、小结
三点体会:
余额要分账。 可用与冻结分离,额度在任何时刻都有明确归属。这是所有后续逻辑的地基。
记账比存状态更重要。 四类流水 + 变动后余额,让每一次变动可追溯。出问题时能对账,而不是猜。
幂等要区分"重复"和"冲突"。 键存在就跳过是初级做法;校验指纹能区分"安全重试"与"数据异常",后者必须报错。
如果你也在做类似的账务型功能(余额、积分、库存、额度),这三个思路基本是通用的。真正难的不是把数字减下去,而是保证它在任何异常路径下都对得上账。
默认评论
Halo系统提供的评论