PHP/Laravel分组拆分月度营收性能过慢问题及优化需求
解决Laravel中限时许可证月度收入拆分性能问题及格式修正
问题背景
我们的客户购买限时许可证(如6个月期限),需要将许可证总金额按覆盖的月份拆分计算占比:先算总天数得出每日金额,再按每个月的实际天数计算对应金额。原MariaDB分组查询速度极慢,转而用PHP/Laravel处理,但现有代码因循环遍历每一天导致运行异常缓慢,且输出格式不符合需求。
原输入示例:
Product: product1 Payment date: 2024-01-01 Start date: 2024-01-01 End date: 2024-03-31 Total excluding tax: $29.99
期望拆分结果:
January 2024: 31 * $0.32956 = $10.216 February 2024: 29 * $0.32956 = $9.557 March 2024: 31 * $0.32956 = $10.216
期望输出JSON格式:
{ "product1": { "January 2024": 2255209.2525, "February 2024": 5252525.5336, "March 2024": 35363.3636 }, "product2": { "December 2023": 309906.3532, "January 2024": 3059035.9092 } }
性能瓶颈分析
原代码的核心问题在splitRevenueByMonth方法:对每一条付款记录循环遍历每一天,当数据量达数千条且许可证期限较长时,循环次数呈指数级增长(比如一条6个月的记录要循环180次),直接导致性能急剧下降。
优化方案
1. 替换每日循环为月度批量计算
不再遍历每一天,而是直接计算付款周期覆盖的所有月份,统计每个月内的有效天数,再乘以每日金额得到月度金额,每个付款记录最多循环3-4次(跨月最多的情况),大幅减少计算量。
2. 修正输出格式
- 将分组键从
product_id替换为产品名称 - 移除多余的
total字段,匹配期望的JSON结构
优化后完整代码
public function revenueWaterfall(Request $request) { // 获取产品列表并建立ID到名称的映射 $products = $request->get('product_id') == 'all' ? Product::all() : Product::whereId($request->get('product_id'))->get(); $productMap = $products->pluck('name', 'id')->toArray(); $bookedPeriodTo = Carbon::parse($request->get('bookedPeriodTo')); $bookedPeriodFrom = Carbon::parse($request->get('bookedPeriodFrom')); $productId = $products->count() == 1 ? $products->first()->id : null; // 获取所有数据并按product_id分组 $allData = Invoice::getAllForPeriod($bookedPeriodFrom, $bookedPeriodTo, $productId) ->groupBy('product_id'); // 处理拆分并转换为产品名称作为键 $splitValues = collect(); foreach ($allData as $productId => $productPayments) { $productName = $productMap[$productId] ?? "product_{$productId}"; $splitValues->put($productName, $this->splitRevenueByMonth($productPayments)); } return response()->json($splitValues); } function splitRevenueByMonth($payments) { $byMonth = []; foreach ($payments as $payment) { $start = Carbon::parse($payment->period_start)->startOfDay(); $end = Carbon::parse($payment->period_end)->startOfDay(); $daysInPeriod = $end->diffInDays($start); if ($daysInPeriod < 1) { throw new \Exception("Invalid period detected for payment ID: {$payment->id}"); } $amountPerDay = $payment->total_excluding_tax / $daysInPeriod; $currentMonth = clone $start; // 循环处理每个覆盖的月份 while ($currentMonth->lessThanOrEqualTo($end)) { // 获取当前月份的最后一天 $monthEnd = $currentMonth->copy()->endOfMonth(); // 计算当前月份内的有效天数:取当前月最后一天和周期结束日的较小值,减去周期开始日(或当月第一天) $periodStartInMonth = $currentMonth->greaterThan($start) ? $currentMonth : $start; $periodEndInMonth = $monthEnd->lessThan($end) ? $monthEnd : $end; $daysInMonth = $periodEndInMonth->diffInDays($periodStartInMonth) + 1; // 包含首尾两天 $monthKey = $currentMonth->format('F Y'); // 输出"January 2024"格式 $byMonth[$monthKey] = ($byMonth[$monthKey] ?? 0) + ($amountPerDay * $daysInMonth); // 跳到下一个月 $currentMonth->addMonth()->startOfMonth(); } } // 对月份按时间排序(可选,提升可读性) ksort($byMonth); return $byMonth; }
关键改动说明
- 产品映射:用
pluck建立产品ID到名称的映射,确保输出用产品名称作为键 - 月度批量计算:
- 遍历付款周期覆盖的每个月份,而非每一天
- 计算每个月内的有效天数(处理跨月开始/结束的情况)
- 直接计算月度金额,避免大量循环
- 格式修正:移除原代码中多余的
total字段,严格匹配期望的JSON结构 - 错误信息优化:增加付款ID便于定位无效数据
- 可选排序:对月份键排序,让输出更直观
性能提升效果
优化后,计算复杂度从O(总天数)降至O(总记录数×平均跨月数),对于数千条记录的场景,性能提升可达数十倍甚至上百倍。
内容的提问来源于stack exchange,提问作者TravelingFox
相关产品推荐
相关产品推荐

