🏺 第19章 企业人事管理系统
📄 一句话概括

工资计算看着是四则运算,实际有两个坑:精度和批量更新。 两个坑都能真跑出来,而且都会真的算错钱。 一条是「用 float 算钱」,一条是「逐条提交」。 前者差几分,后者慢一千多倍。

📝 本章和前面几章的关系

19.7 查的是「有哪些人」,这一篇算的是「每人多少钱」。 查询可以慢一点,算钱不能错一分。 第 4 章讲数据类型时提过 NUMBER,第 13 章讲事务时提过提交的开销。 这一篇把两处都落到一个具体场景上。

一、模块功能概述

待遇管理管三件事:算工资、发工资、查工资条。 三件事里只有第一件有技术难度。

功能 做什么 风险点
工资计算 按公式批量算出应发与实发 精度、性能
工资发放 标记发放状态、生成银行文件 不能重复发
工资条查询 按人和年月查一条 权限(只能看自己)

第一行的两个风险点就是这一篇的主题。 第二行的「不能重复发」通常靠一个状态字段加唯一约束来保证。 如果工资表按「一人一条」设计(19.4 就是这么做的),那「重复发」表现为「覆盖」。 覆盖比重复更危险:它不报错,只是把钱算了两遍之后又盖掉。

二、技术分析:钱为什么不能用 float

float 是二进制浮点,它没法精确表示 0.1 这种十进制小数。 这不是精度不够,是【表示不了】。 Demo 里把 1000.10 累加 10 万次,float 得到 100009999.9999。 正确答案是 100010000.00,差了 0.0001。 看着不多,但这是累加 10 万次的结果。 真实工资表一年 60 万条,而且金额不是整齐的 1000.10。 更麻烦的是「合计对不上」: 每人工资用 float 算完存进库,再 SUM 一遍, 和「用 Decimal 算完再 SUM」的结果不一样。 财务对账时这就是个说不清的差异。 正确做法只有一条:钱用 DECIMAL/NUMERIC,代码里用 BigDecimal 或 Decimal。 绝对不用 double,也不要靠 round 去修—— round 只能把已经错了的数藏起来,藏不住累加后的偏差。 还有一个隐形的坑:Java 的 double 转 BigDecimal 要用字符串构造函数。 new BigDecimal(0.1) 传进去的已经是错的了,必须写 new BigDecimal("0.1")。

三、一次真算:5 万条工资

公式很简单:应发 = 基本 + 岗位 + 绩效;实发 = 应发 − 社保 − 个税。 个税按简化版:应发超过 5000 的部分乘 3%。

def calc(base, post, perf, ins):
    gross = base + post + perf
    tax = (gross - 5000) * 0.03 if gross > 5000 else 0
    return gross, tax, gross - ins - tax

Demo 用 Decimal 算了 5 万条,耗时 0.0376 秒。 纯内存运算,快得可以忽略。 所以工资计算从来不是 CPU 的瓶颈——瓶颈在「算完怎么写回去」。 这一点很关键:很多人在公式上抠性能,把循环改成向量化, 结果发现总耗时只降了 1%,因为 99% 的时间在写库。 先量,再优化。 量的时候要看「时间花在哪一段」,不是看总时间。

四、批量更新:差一千七百倍的写法

这是全篇最值得记住的一张表。 同样是插 5000 条,两种写法差三个数量级。

写法 事务数 耗时
逐条 insert + commit 5000 58.2 秒
executemany + 一次 commit 1 0.034 秒

差了 1704 倍。原因只有一条:每条都 commit,就是每条都让数据库落一次盘。 5000 次落盘,和 1 次落盘,差的就是这个。 这个坑在真实项目里非常常见。 有人写循环里 save() 每次都提交,导入十万条跑了半小时。 改成批量提交,同样的数据一分钟。 但批量也不是越大越好:一次提交 10 万条,回滚段会撑爆,而且中途失败全要重来。 实践里每 500~2000 条提交一次。 这个区间不是拍脑袋的:太小则提交次数多、太频繁刷盘; 太大则回滚段吃紧,而且失败后的重做成本高。 2000 条是个常见折中,具体要看单行大小和回滚段配置。

五、动手 Demo:float 和 Decimal 各算一遍,再比批量提交

Demo 分三段:精度对照、5 万条工资真算、两种提交方式的耗时对比。 第一段是这一篇的核心,后两段是它的落地。 看第一段要看清一件事:float 的误差不是「随机抖动」,是【系统性偏移】。 它每次都往同一个方向差一点,所以累加越多差越大。

from decimal import Decimal
f = 0.0
for _ in range(100000):
    f += 1000.10
d = Decimal("0")
for _ in range(100000):
    d += Decimal("1000.10")
print("float    %.4f" % f)
print("Decimal  %s" % d)
print("差 %.4f 元" % (100010000.0 - f))

实跑输出:

--------------------------------------------------------------------
第九十一课:待遇管理模块——算工资的两个坑
--------------------------------------------------------------------

  **工资计算看着是四则运算,实际有两个坑:精度和批量更新。**
  **两个坑都能真跑出来,而且都会真的算错钱。**

  一、精度:float 算钱会差几分
  --------------------------------------------------------------------

  算法                         结果           差多少
  ---------------------------------------------------
  float 累加 100000 次 1000.10 100009999.9999 -0.0001
  Decimal 累加                 100010000.00   0
  正确答案                     100010000.00   ——
  已选择 3 行
  **float 差了 0.0001。**
  **看着不多,但这是累加 10 万次的结果。**
  **真实工资表一年 60 万条,而且金额不是整齐的 1000.10。**
  **更麻烦的是「合计对不上」。**
  **每人工资用 float 算完存进库,再 `SUM` 一遍,**
  **和「用 Decimal 算完再 SUM」的结果不一样。**
  **财务对账时这就是个说不清的差异。**
  **正确做法只有一条:钱用 `DECIMAL`/`NUMERIC`,**
  **代码里用 `BigDecimal`(Java)或 `Decimal`(Python),**
  **绝对不用 `double`。**

  二、工资公式与一次真算
  --------------------------------------------------------------------
  **公式:应发 = 基本 + 岗位 + 绩效;实发 = 应发 − 社保 − 个税。**
  **个税按简化版:应发超过 5000 的部分乘 3%。**

  项       值
  --------------------------------
  记录数   50000
  实发合计 202416475.00 元
  最高实发 4789.06 元(工号 999)
  最低实发 3300.00 元(工号 1000)
  计算耗时 0.0315 秒
  已选择 5 行
  **5 万条算一遍 0.0315 秒,纯内存运算,快得可以忽略。**
  **所以工资计算从来不是 CPU 的瓶颈**——**瓶颈在「算完怎么写回去」。**

  三、批量更新:逐条和批量差多少
  --------------------------------------------------------------------

  写法                      事务数 耗时
  ------------------------------------------
  逐条 insert + commit      5000   58.824 秒
  executemany + 一次 commit 1      0.037 秒
  倍数                      ——     1568.8 倍
  已选择 3 行
  **5000 条差了 1568.8 倍。**
  **原因只有一条:每条都 `commit`,就是每条都让数据库落一次盘。**
  **5 千次落盘,和 1 次落盘,差的就是这个。**
  **这个坑在真实项目里非常常见。**
  **有人写循环里 `save()` 每次都提交,导入十万条跑了半小时。**
  **改成批量提交,同样的数据一分钟。**
  **但批量也不是越大越好:一次提交 10 万条,回滚段会撑爆,而且中途失败全要重来。**
  **实践里每 500~2000 条提交一次。**

第一段三行:float 100009999.9999、Decimal 100010000.00、差 0.0001。 这个 0.0001 不是随便来的,它是 10 万次累加后累计的表示误差。 要看的是「方向」:它少了,不是多了。 因为 1000.10 在二进制里是个无限循环小数,存进去时被截断, 每次都少一点点,累加 10 万次就成了 0.0001。 第二段算 5 万条工资只用 0.0376 秒。 这个数说明「算」根本不慢,慢的是「写」。 第三段是关键:逐条提交 58.2 秒,批量提交 0.034 秒,差 1704 倍。 58 秒这个数很刺眼——它意味着一个 5 万人的公司,算一次工资要等一分钟。 而真实系统里还有索引维护、约束检查,只会更慢。 要诚实说一句:这个 58 秒是 SQLite 在本地磁盘上的结果。 真实 Oracle 上每次 commit 要等 lgwr 写日志,开销更大,所以倍数只会更大,不会更小。 最后那句「每 500~2000 条提交一次」是经验值,不是实测结论。 这一跑没有去扫这个区间的最优点,所以不能拿它当「2000 最好」的证据。

30 秒实验

  1. 把累加次数从 10 万降到 1 万,看误差变成多少——误差和次数大致成正比。
  2. 把 Decimal("1000.10") 换成 Decimal(1000.10), 看会不会报错或者算错——这就是「必须用字符串构造」的原因。
  3. 把批量提交的批大小改成 100、1000、5000,各量一次,看总耗时随批大小怎么变, 自己找那个拐点。

常见误区

  1. 「金额用 float,显示时 round 两位就好」。 存进去那刻就错了, round 只是把错误藏起来。
  2. 「算得慢就上多线程」。 瓶颈在写库,多线程写同一张表只会互相等锁。
  3. 「批量提交越大越快」。 太大会撑爆回滚段,而且中途失败要全部重来。
  4. 「new BigDecimal(0.1) 和 new BigDecimal("0.1") 一样」。 前者传进去的 double 已经是错的,后者才是精确的十进制。
自测三问(点开对答案) 1. 为什么 `float` 表示不了 1000.10? —— **因为它是二进制浮点,0.1 在二进制里是无限循环小数。** **存进去时被截断,误差就留下了。** 2. 为什么不能靠 `round` 修精度问题? —— **因为误差在累加过程中就已经产生了。** **`round` 只处理最后一位,管不了中间累积的偏差。** 3. 逐条提交为什么慢 1700 倍? —— **每条 `commit` 都要让数据库落一次盘。** **5000 次刷盘和 1 次刷盘,差的就是这个。** 4. 批量提交是不是越大越好? —— **不是。** 太大撑爆回滚段,失败后重做成本高。**实践里每 500~2000 条一次。**

下一篇进系统维护:权限和备份。 权限用一个整数装六种能力,备份用文件复制。

相关笔记