工资计算看着是四则运算,实际有两个坑:精度和批量更新。 两个坑都能真跑出来,而且都会真的算错钱。 一条是「用 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 秒实验
- 把累加次数从 10 万降到 1 万,看误差变成多少——误差和次数大致成正比。
- 把
Decimal("1000.10")换成Decimal(1000.10), 看会不会报错或者算错——这就是「必须用字符串构造」的原因。 - 把批量提交的批大小改成 100、1000、5000,各量一次,看总耗时随批大小怎么变, 自己找那个拐点。
常见误区
- 「金额用
float,显示时round两位就好」。 存进去那刻就错了,round只是把错误藏起来。 - 「算得慢就上多线程」。 瓶颈在写库,多线程写同一张表只会互相等锁。
- 「批量提交越大越快」。 太大会撑爆回滚段,而且中途失败要全部重来。
- 「
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 条一次。**下一篇进系统维护:权限和备份。 权限用一个整数装六种能力,备份用文件复制。
- ☐ (本篇完,下一篇 19.9 系统维护模块设计)
相关笔记
- 人事管理模块设计
- 系统维护模块设计
- 数据库设计
-
- 🐬 MySQL必知必会/00-索引
- 🏺 Oracle从入门到精通 · 索引