假期余额怎么做才不出错:冻结、扣减与幂等的账务模型

SailTrack
2026-09-17
点 赞
0
热 度
1
评 论
1
  1. 首页
  2. 系统设计
  3. 假期余额怎么做才不出错:冻结、扣减与幂等的账务模型

写在前面

做人事系统之前,我以为"假期余额"就是个数字字段——发多少、用多少、减一减就完事了。

真开始写才发现,这里面的坑比想象中多得多:员工同时提交两份请假申请怎么办?审批到一半被人撤回,那部分额度怎么处理?网络抖动导致同一个请求重试了两次,会不会扣两遍?

这篇记录一下我在 Workforce Hub 里设计假期余额账务模型的过程,核心就三件事:余额要分账、并发要加锁、操作要幂等

一、为什么不能只有一个余额字段

最直觉的做法是给员工一张表,里面一个 balance 字段:

-- 错误示范
UPDATE leave_account SET balance = balance - 480 WHERE user_id = ?;

这在单线程世界里没问题。但一旦引入审批流就漏了:

请假提交后不是立刻生效的,中间要经过审批。这段时间里额度该算什么状态?

  • 如果提交时就直接扣,审批被驳回,得再补回来——补的过程又可能失败。

  • 如果提交时不扣,审批通过才扣,那员工可以在审批期间把余额全提走,超支。

所以在流程上把余额拆成了两个数:

余额分账:可用与冻结

字段

含义

available_minutes

可用余额

frozen_minutes

冻结余额

流程变成三段:

提交申请  →  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, "幂等键已被不同假期冻结操作使用");
    }
}

为什么不只判断"键存在"?

因为"键存在"这件事对应两种截然不同的情况:

幂等校验的两种结果:一致则通过,不一致则报错

情况

含义

正确处理

指纹一致

真的是重复请求

静默跳过,返回成功

指纹不一致

同一个单号,但参数不同

报错 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, "冻结假期余额不足");
}

为什么要留这个"降级"分支?因为系统上线前已经有一批历史申请,它们在冻结机制做出来之前就提交了,账上根本没有对应的冻结记录。如果强行要求"没有冻结记录就报错",这些单据审批通过时全部会失败。

所以策略是:先走正常路径(扣冻结),扣不动再检查是不是历史单,是的话扣可用余额,都不是才报错。这种情况在生产系统里很常见——新机制上线时,老数据总得有个兼容通道。

六、小结

三点体会:

  1. 余额要分账。 可用与冻结分离,额度在任何时刻都有明确归属。这是所有后续逻辑的地基。

  2. 记账比存状态更重要。 四类流水 + 变动后余额,让每一次变动可追溯。出问题时能对账,而不是猜。

  3. 幂等要区分"重复"和"冲突"。 键存在就跳过是初级做法;校验指纹能区分"安全重试"与"数据异常",后者必须报错。

如果你也在做类似的账务型功能(余额、积分、库存、额度),这三个思路基本是通用的。真正难的不是把数字减下去,而是保证它在任何异常路径下都对得上账


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

具有版权性

请您在转载、复制时注明本文 作者、链接及内容来源信息。 若涉及转载第三方内容,还需一同注明。

具有时效性

目录

欢迎来到SailTrack的站点,为您导航全站动态

42 文章数
11 分类数
5 评论数
53标签数