PHP 金额计算用 float?一个坑吃掉两个隐患
在给某后台管理系统做代码审查时,翻到一段钱包扣款逻辑。乍看没什么问题——减法一行,写库一行——但仔细一看,这两行代码同时埋了两颗雷:一颗是精度漂移,一颗是 SQL 注入。
现象
- 退款接口返回
actualAmount: 99.99000000000001,财务同事对账时一脸懵。 - 用户余额在经历多次充值 / 提现后偏移了几分钱,对账报告不平。
- Code Review 发现大量形如
Db::raw('balance - ' . $amount)的写法,既有精度问题,又有字符串拼接。
根因
PHP 的 float 是 IEEE 754 双精度浮点——这意味着 0.1 + 0.2 的结果在二进制里并不等于 0.3。哪怕数据库列定义成 decimal(10,2) 会在落库时四舍五入,PHP 里的中间计算结果已经漂了,连续多次操作之后误差会累积,最终差出一分钱甚至几分钱。
Db::raw('balance - ' . $amount) 则同时带来第二个问题:
- 绕过了 PDO 参数绑定——如果
$amount哪怕有一个路径来自用户输入,就直接开了注入后门。 - 把运算逻辑分散到 SQL 里——后续要加「冻结金额检查」「余额下限保护」等逻辑,就必须一半写 PHP、一半写 SQL,维护地狱。
坑:float 运算 + Db::raw 拼接,一行代码两个生产级隐患
// 看似简洁,实则危险
$afterBalance = $beforeBalance - $amount; // float 精度漂移
$db->name('p_wallet')->where('id', $walletId)->update([
'balance' => Db::raw('balance - ' . $amount), // 注入 + 精度双杀
]);$amount 若来自表单或外部接口,Db::raw 就是裸拼 SQL;即便数据源可信,float 减法也会产生 99.99000000000001 这类幽灵值写进数据库。
正解
所有金额运算一律走 bcmath:bcadd / bcsub / bcmul / bcdiv,精度参数(scale)货币取 2,中间汇率取 4。写库时用普通参数绑定,不用 Db::raw。
正解:bcmath + 参数绑定,顺带记账日志
use think\facade\Db;
// 1. 强制转 string,bcmath 只接受字符串
$beforeBalance = (string) $wallet['balance'];
$amountStr = (string) $amount;
// 2. 余额检查也用 bccomp,不用 >/<
if (bccomp($beforeBalance, $amountStr, 2) < 0) {
throw new \RuntimeException('余额不足');
}
// 3. 精确减法,scale=2
$afterBalance = bcsub($beforeBalance, $amountStr, 2);
// 4. 普通参数绑定写库,无 Db::raw
$db->name('p_wallet')
->where('id', $walletId)
->update([
'balance' => $afterBalance,
'update_time' => date('Y-m-d H:i:s'),
]);
// 5. 同一事务内写流水日志,方便对账
$db->name('p_balance_log')->insert([
'wallet_id' => $walletId,
'before_balance' => $beforeBalance,
'after_balance' => $afterBalance,
'delta' => $amountStr,
'kind' => 'refund',
'create_time' => date('Y-m-d H:i:s'),
]);两个常见换算场景
微信支付传的是分(整数),需要在分和元之间转换:
// 元 → 分(传给微信支付)
$fen = intval(bcmul((string) $yuan, '100'));
// 佣金计算(5% 费率,中间结果保留 4 位小数再截断到 2 位)
$commission = bcdiv(bcmul($amount, $rate, 4), '100', 2);快速审计
项目里有没有遗漏的危险写法,一条 grep 扫一遍:
# 找 Db::raw 里拼接金额变量的写法
grep -rn "Db::raw.*\$" app/ | grep -iE "amount|balance|fee|price"
# 找直接用 +/-/* 操作余额的 PHP 赋值(排除已经用 bcmath 的)
grep -rn "balance\s*[+\-]=" app/ | grep -v bcadd | grep -v bcsub一句话外卖
PHP 里凡是涉及「钱」的数字,开头先
(string),计算全用bcmath,写库用参数绑定——这三件事不讲条件。