16. 用Go重写商家结余表导数脚本

前段时间在导出商家结余表时,发现有些大商家数据比较多,而一直使用的PHP导数脚本是同步脚本,未改为多进程导数,导出效果特别差强人意,有一些甚至等待一天才勉强收到邮件通知。刚好这段时间公司项目上没有什么新需求要做,项目处于维护期和日常工作期,鉴于Go语言的异步优势,就用Go重写了该脚本。一来将Go的学习用于实际场景编写,巩固和运用;二来也比较一下动态语言和静态语言的思维方式和执行效率。

开始编写时,确实遇到很多难处。一开始想到的,是如何做数据库连接,幸好Go对这些常见的场景都有现成的库可使用,稍微在GitHub上搜索就能有好的选择。然后是对数据类型的描述上,确实跟动态语言有明显的不同,但是无法否认,静态语言在编写和编译期间就能减少很多低级的Bug。

也发现了一些公司的服务问题,比如邮件服务器总是x.509证书问题而无法使用,网易163也存在这样的问题,但是QQ邮箱可以正常使用。官方库中已经提供了CSV格式的支持,使用起来相当的方便,就不提供excel方式了。

下面简单梳理一下编写该脚本的过程和需要注意的地方。

1. 我们最终需要导出一个这样的excel表格给其他业务部门查看

需要导出的样例表格

表格中省略了部分内容,只是为了呈现最终的样式,同时为了方便查看,依照顺序显示,后续操作需要兼顾该顺序。当然因为csv格式与Excel的关系,直接用Excel打开会出现乱码,如果必须要使用Excel查看,需要将csv用文本文件打开,拷贝到Excel中,使用数据->分隔的方式重新显示。如操作频繁,可使用Go的Excel库来直接写入到Excel文件,略去转化过程。比如之前在公众号看到的这篇Golang 读写 Excel,可以参考其思路。

2. 表格标题项的处理

// 结算相关标题
var settleColumns = []string{
    "供应商",
    "结算单Id",
    "有效付款订单金额",
    "红包抵扣金额合计",
    "手机端满减返款项的金额合计",
    "钻石会员包邮运费返款",
    "已确认退货金额",
    "退货快递运费",
    "违约订单金额",
    "罚款金额",
    "暂不结款订单金额",
    "返点订单总金额",
    "返点金额",
    "费用调整",
    "红包总金额",
    "退款金额扣点",
    "现金券使用的金额",
    "虚假发货罚款金额",
    "应付金额",
    "结算时间",
    "结算状态",
}

比较简单,直接定义为一个变量即可,也可以直接在写csv文件时在定义。

3. 需要导出的11种数据类型

一开始联想到PHP的变量函数,但是静态语言不支持该种思路,必须在编译阶段确定其类型,不能直接调用一个字符串,更无法操作一个字符串会像动态语言一样,解释器将其解释为一个函数,因此要按函数类型来定义,比如我的处理方式是:

// 使用计数器记录启动的goroutine个数
var count sync.WaitGroup

// 声明函数类型字典
names := map[string]func(group *sync.WaitGroup, string string){
    "1、partner系统已结算单":  partnerList,
    "2、Partner未结算数据":   partnerNoList,
    "3、FSP已结算数据":       fspList,
    "4、FSP未结算数据":       fspNoList,
    "5、Finance已结算数据":   financeList,
    "6、未发货并且无退货的订单":    noDeliveryNoRmaList,
    "7、未结算订单":          noSettleList,
    "8、结算过暂不结算":        onHoldList,
    "9、结算过退货":          settleRmaList,
    "10、在结算单创建审核中产生退货": settlingRmaList,
}
for dist, name := range names {
    count.Add(1)
    go name(&count, dist)
}

// 等待所有的goroutine执行完毕
count.Wait()

之所以要用Go来写,就是因为该处的异步调用优势。每一个字符串描述的后面,都是一个个的真实函数。第11种比较特殊,需要依赖前面的几种返回值,因此无法将其加入到异步执行中,只能同步阻塞运行。

// 11、没有符合任何条件的订单
// 该数据依赖前面数据,不支持并发操作,该方法最后执行
noList()

PHP按照我们已有的方式,只能借助Pcntl多进程模型来完成,但是也需要将执行结果写入到一个文本文件中,同样要记录字典的键,然后等待所有进程都执行完毕后,在将该文本文件按展示顺序排列后在重新写入到Excel文件中,一样可以实现。但是考虑到多进程控制问题,如果不需要返回值,确实比较方便。当然也可以临时建一张表,没跑完一个进程就将数据按格式写入库中,待执行完毕后,在查询数据库得到结果,效果也不会差。

// PHP多进程执行方式
foreach ($dateSpanList as $dateList) {
    $pid = pcntl_fork();
    if ($pid == -1) {
        error_log('Could not launch new job, exiting');
        exit(3);
    } else if ($pid) {
        ++$processCnt;
    } else {
        // 每一个独立的业务都在该进程中执行,不需要返回值,执行完毕后直接退出
        exit(0);
    }
}

for ($i = 0; $i < $processCnt; ++$i) {
    printf("\t\t*****************return %d\n", pcntl_wait($status));
}

// 此时所有进程均已执行完毕,就可以得到所有的结果

但是进程的资源要昂贵得多,而Go的go关键字启动的消耗说特别的小,系统里完全可以有成千上万个协程在运行。Pcntl如果要接收子进程的结束信号,就要用到pcntl_signal()信号注册函数,而且编写和管理起来就要复杂的多。

4. 每一个方法的编写,其中以调试最多的方法结算过暂不结算为例

var soIdList settledSoIds

// 8、结算过暂不结算
func onHoldList(n *sync.WaitGroup, dist string) {
    defer n.Done()

    // 对字典进行锁
    soIdList.lock.Lock()
    defer soIdList.lock.Unlock()

    // 是否已经获取过已结算的so_id集合
    if len(soIdList.soIds) <= 0 {
        var soId int32
        var settleCnt int
        rows, err := common.GetPopDb().Query(`
            SELECT
                so_id,
                MAX(settlement_cnt) settlement_cnt
            FROM t_pop_trans_list FORCE INDEX(idx_pid_oid)
            WHERE partner_id = ?
            AND settlement_note_id > 0
            AND order_id > 1
            AND type <> 'pre_settle'
            GROUP BY so_id
        `, partnerId)
        common.Error(err)
        defer rows.Close()

        for rows.Next() {
            if err := rows.Scan(&soId, &settleCnt); err != nil {
                if _, file, line, ok := runtime.Caller(0); ok {
                    common.Info(err, file, line)
                }
            }

            // 保存已结算的so_id到全局字典中
            soIdList.soIds[settleCnt] = soId
        }
        common.RowsError(rows)
    }

    var p order
    var records [][]string
    records = append(records, []string{dist})
    for cnt, id := range soIdList.soIds {
        err := common.GetPopDb().QueryRow(`
            SELECT
                order_id,
                sku_no,
                valid_order_total_price,
                red_envelope_user_delivery_fee,
                mobile_user_delivery_fee,
                diamond_user_delivery_fee,
                rma_order_total_price,
                rma_delivery_fee,
                delay_order_total_price,
                fine_fee,
                onhold_order_total_price,
                media_rebate_order_total_price,
                media_rebate_fee,
                adjustment_fee,
                red_envelope_total_price,
                rma_rebate_fee,
                promo_card_used_order_total_price,
                0 fake_shipping_fee,
                need_paid_total,
                UNIX_TIMESTAMP(date_line) check_time,
                '' status
            FROM t_pop_trans_list FORCE INDEX(idx_soid_cnt)
            WHERE so_id = ?
            AND settlement_cnt = ?
            AND settlement_note_id = 0
            AND re_type <> 'so'
            AND type <> 'pre_settle'
        `, id, cnt).Scan(
            &p.orderId, &p.skuNo,
            &p.common.validOrderTotalPrice, &p.common.redEnvelopeUserDeliveryFee, &p.common.mobileUserDeliveryFee,
            &p.common.diamondUserDeliveryFee, &p.common.rmaOrderTotalPrice, &p.common.rmaDeliveryFee,
            &p.common.delayOrderTotalPrice, &p.common.fineFee, &p.common.onHoldOrderTotalPrice,
            &p.common.mediaRebateOrderTotalPrice, &p.common.mediaRebateFee, &p.common.adjustmentFee,
            &p.common.redEnvelopeTotalPrice, &p.common.rmaRebateFee, &p.common.promoCardUsedOrderTotalPrice,
            &p.common.fakeShippingFee, &p.common.needPaidTotal, &p.common.checkTime, &p.common.status,
        )
        switch {
        case err == sql.ErrNoRows:
            continue
        case err != nil:
            if _, file, line, ok := runtime.Caller(0); ok {
                common.Info(err, file, line)
            }
        }

        specSoIdList.lock.Lock()
        specSoIdList.soIds[id] = id
        specSoIdList.lock.Unlock()

        // FSP新退保业务,记录日志
        sur := surrender{
            checkLogId:    checkLogId,
            partnerId:     partnerId,
            name:          partnerName,
            classify:      6,
            orderId:       p.orderId,
            skuNo:         p.skuNo,
            needPaidTotal: p.common.needPaidTotal,
            reason:        fmt.Sprintf("结算过暂不结算的订单orderId: %d, skuNo: %d", p.orderId, p.skuNo),
        }
        surrenderLod(sur)

        records = addOrderRecord(records, p)
    }

    target.lock.Lock()
    target.goal[8] = records
    target.lock.Unlock()

    fmt.Println(dist, time.Now().Format("2006-01-02 15:04:05"))
}

其中包含了基本常用的错误处理方式,对于Go来说,查询结果集为空的错误需要特殊处理,

switch {
case err == sql.ErrNoRows:
    continue
case err != nil:
    if _, file, line, ok := runtime.Caller(0); ok {
        common.Info(err, file, line)
    }
}

sql.ErrNoRows的值是

// ErrNoRows is returned by Scan when QueryRow doesn't return a
// row. In such a case, QueryRow returns a placeholder *Row value that
// defers this error until a Scan.
var ErrNoRows = errors.New("sql: no rows in result set")

是一种特殊的情形,需要单独判断,其实是一种正常的返回,但是在Go中被定义为一种错误消息。

遍历也需要检查是否有错误产生。

// 结果集错误,该错误可能由db.query()遍历产生
func RowsError(rows *sql.Rows) {
    if err := rows.Err(); err != nil {
        log.Fatalln(err)
    }
}

看到一些评论说,Go写什么都好,就是真正管用的代码没几行,但是跟错误相关的代码却是有用代码的好几倍,而且错误几乎都无法避开。

在这里遇到最大的一个问题还是,因为Go的数据库包 database/sql 的原因,如果返回的数据较多,会产生MySQL数据库太多连接的错误,这个没什么好办法,只能将sql语句调节到最优,每次处理完数据就直接关闭连接,而不需要等待到defer来延迟关闭结果集。

目前使用go1.8.3版本,并发写字典是需要加锁的,但是据说go1.9之后的新版本,内部已经加锁处理,可以直接调用了。

// 定义一个字典保存所有结算相关信息,字典的键为每一项结算信息的标识,即main中11种内容类型,字典的值为一个二维字符串数组
type desc struct {
    goal map[int][][]string
    lock sync.Mutex
}

该脚本使用的目标数据结构,其中就专门定义了锁机制。

Go查询的记录,即结果集,都需要有明确的变量去绑定接收,这是动态语言天然的优势,但静态语言无法做到。还有就是需要注意,数据库查询返回值中一定不能有NULL,这在Go中是不可接受的。如果无法改变,需要借助NULL类型来处理。但我觉得任何一个合格的DBA或者PHP开发者,只要不是第一天学习PHP,都应该知道数据库对NULL的控制,这种东西应该从建表的第一行SQL开始,就被扼杀在摇篮之中。如果不行你使用的旧的数据库中有这种类型存在,那么狠遗憾,你只能在SQL语句中大量使用 IS NULL

IF (valid_order_total_price IS NULL, 0, valid_order_total_price) valid_order_total_price,

这样的判断,你也看到了,我就是这个不幸的人。还有更多奇怪的问题,只能遇到了再挨个解决。以前一个小伙伴对我说,她们老大给她一个同事说,做编程是一门有意思的工作,你每天都是不停的发现问题,遇见新问题,然后解决问题,每一天都是新的。一定程度上的确如此,但要是遇见磕破脑袋都解决不了的问题,谁都会有发狂的时候,遇见了就忍忍吧,歇一会就想到解决办法了。

5. 数据处理完后,其它类型转为字符串类型

因为使用csv来读写数据,csv只接收字符串类型,故需要将其它非字符串类型转化为字符串类型。

// 对结算值进行绑定
func addSettleRecord(records [][]string, p settle) [][]string {
    return append(records, []string{
        partnerName,
        fmt.Sprintf("%d", p.settlementNoteId),
        fmt.Sprintf("%f", p.common.validOrderTotalPrice),
        fmt.Sprintf("%f", p.common.redEnvelopeUserDeliveryFee),
        fmt.Sprintf("%f", p.common.mobileUserDeliveryFee),
        fmt.Sprintf("%f", p.common.diamondUserDeliveryFee),
        fmt.Sprintf("%f", p.common.rmaOrderTotalPrice),
        fmt.Sprintf("%f", p.common.rmaDeliveryFee),
        fmt.Sprintf("%f", p.common.delayOrderTotalPrice),
        fmt.Sprintf("%f", p.common.fineFee),
        fmt.Sprintf("%f", p.common.onHoldOrderTotalPrice),
        fmt.Sprintf("%f", p.common.mediaRebateOrderTotalPrice),
        fmt.Sprintf("%f", p.common.mediaRebateFee),
        fmt.Sprintf("%f", p.common.adjustmentFee),
        fmt.Sprintf("%f", p.common.redEnvelopeTotalPrice),
        fmt.Sprintf("%f", p.common.rmaRebateFee),
        fmt.Sprintf("%f", p.common.promoCardUsedOrderTotalPrice),
        fmt.Sprintf("%f", p.common.fakeShippingFee),
        fmt.Sprintf("%f", p.common.needPaidTotal),
        time.Unix(p.common.checkTime, 0).Format("2006-01-02 15:04:05"),
        statusToDes(p.common.status),
    })
}

这也是和动态脚本语言差异很大的地方,数据类型很多场景下是不能自动传化为所需要的格式的,必须明确将其转化为目标类型。也没有三元运算符,只能用类似字典断言的方式来判断具体的值。

// 结算单状态描述
func statusToDes(status string) string {
    var statusList = map[string]string{
        "confirmed":             "初审通过",
        "confirmNote":           "待确认",
        "new":                   "新建",
        "invalid":               "不可结算",
        "merged":                "合并",
        "paid":                  "结款完成",
        "rejected":              "驳回",
        "to_approve":            "待审批",
        "verified":              "待结款",
        "submitted":             "待审核",
        "approved":              "审核通过",
        "finished":              "完成",
        "supplier_approved":     "商家审核通过",
        "supplier_rejected":     "商家驳回",
        "wait_supplier_confirm": "待商家确认",
        "received":              "已收单",
        "wait_finance_check":    "等待finance检查",
        "finance_check_ok":      "finance检查ok",
    }
    if desc, ok := statusList[status]; ok {
        return desc
    }
    return status
}

这要是在PHP中,下列方式

return isset($list[$skuCategory]) ? $list[$skuCategory] : '未知';

很简单的一句话就能做到,高版本的PHP,甚至还能用合并运算符,更精简

return $list[$skuCategory] ?? '未知';

直接省去了isset()的判断就能得到要表达的结果。Go没有这么多的语法糖,这根它的哲学有关,保持语言本身的简单。

6. 写csv文件

// 生成csv格式的附件
func attachment(name string) {
    f, err := os.OpenFile(name, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0777)
    defer f.Close()
    common.Error(err)

    var records [][]string
    records = append(records, settleColumns)
    for i := 1; i <= 11; i++ {
        // 当属于第6个时,需要标识为订单相关项
        if i == 6 {
            records = append(records, orderColumns)
        }
        for _, v := range target.goal[i] {
            records = append(records, v)
        }
    }
    err = csv.NewWriter(f).WriteAll(records)
    common.Error(err)
}

写csv文件非常简单,直接保存即可,默认均为utf-8编码,写完后用LibreOffice这些软件打开是没问题的,但是Windows系统就不是很方便了,需要做一些额外的工作。跨平台就是这样,只能迁就它。谁让他们家的办公软件这么流行和好用。苹果的Numbers打开也没问题,反正你知道就行,就是微软的Excel打开是一堆乱码,即便是PHP,也要主动对中文进行转码,将utf-8转化为gbk,你生气也没用,事就是要这样才能成的

return mb_convert_encoding($str, 'gbk', 'utf-8');

对,就是要这样处理之后,使用Windows的同学才能正常查看,但是这样处理后至少我的Mac是没法查看的,这没关系,他们能看就行,咱们自己都可以忍。

7. 发送邮件

// 发送附件
func email(name string, emails string) {
    // smtp.qq.com,使用SSL,端口号465或587
    // 公司邮件本次测试,使用mail.xxx.com:25可以发送邮件,但是正式线无法发送邮件
    // x509: cannot validate certificate for 10.2.12.9 because it doesn't contain any IP SANs
    // x509: certificate is valid for Barracuda/emailAddress=sales@barracuda.com, not mail.jumei.com
    // 似乎是证书的问题
    // 我的网易邮件zhgxun1989@163.com也存在一样的问题,但是腾讯QQ邮箱服务却可以正常使用
    // 定位问题为公司服务器原因,不处理,属于个性问题
    config := `{"username":"978771018@qq.com","password":"xxxxxx","host":"smtp.qq.com","port":587}`
    mail := common.NewEMail(config)
    mail.To = strings.Split(emails, ",")
    mail.From = "978771018@qq.com"
    mail.Subject = partnerName + " 商家结余附件导出"
    //mail.Text = "Text Body is, of course, supported!"

    // 尝试读取错误日志中的文件内容
    f, err := os.Open(logFile)
    common.Error(err)
    defer f.Close()
    con, err := ioutil.ReadAll(f)
    common.Error(err)

    mail.HTML = "<h3 style='color:red'>商家重复结算或结算后退货日志: </h3>" +
        string(con) +
        "<h3 style='color:blue'>以下为商家结余附件: </h3>"
    mail.AttachFile(name)
    err = mail.Send()
    common.Error(err)
}

正如注释中所说,x509问题属于服务问题,就不处理。该邮件发送使用了Beego框架的邮件发送封装包。错误日志部分是另一个Go脚本,这里就不描述了,都差不多。代码只是工具,重要的是要跟业务结合起来,才有用处,才能转化为生产力。

8. 整个入口文件

// 商家结余表导出
func Export(pId int, fix int, emails string, cId int, n *sync.WaitGroup) {
    defer n.Done()

    partnerId = pId
    checkLogId = cId
    logFile = fmt.Sprintf("/home/www/finance/apps/finance/protected/runtime/fix_rma_after_settle_%d", partnerId)

    if len(settleColumns)-len(orderColumns) != 0 {
        log.Fatal("导出csv文件标题项目数不对应")
    }

    // 商家名称
    partnerName = getPartnerName()

    // 结算后退货或重复结算处理
    if fix > 0 {
        Fix()
    }

    // 时间格式化必须是该日期2006-01-02 15:04:05
    fmt.Println("统计开始", time.Now().Format("2006-01-02 15:04:05"))

    // 初始化字典
    target.goal = make(map[int][][]string)
    specSoIdList.soIds = make(map[int32]int32)
    soIdList.soIds = make(map[int]int32)

    // 使用计数器记录启动的goroutine个数
    var count sync.WaitGroup

    // 声明函数类型字典
    names := map[string]func(group *sync.WaitGroup, string string){
        "1、partner系统已结算单":  partnerList,
        "2、Partner未结算数据":   partnerNoList,
        "3、FSP已结算数据":       fspList,
        "4、FSP未结算数据":       fspNoList,
        "5、Finance已结算数据":   financeList,
        "6、未发货并且无退货的订单":    noDeliveryNoRmaList,
        "7、未结算订单":          noSettleList,
        "8、结算过暂不结算":        onHoldList,
        "9、结算过退货":          settleRmaList,
        "10、在结算单创建审核中产生退货": settlingRmaList,
    }
    for dist, name := range names {
        count.Add(1)
        go name(&count, dist)
    }

    // 等待所有的goroutine执行完毕
    count.Wait()

    // 11、没有符合任何条件的订单
    // 该数据依赖前面数据,不支持并发操作,该方法最后执行
    noList()

    // 保存为csv格式的附件
    name := fmt.Sprintf("/home/www/flogs/POP_PartnerId_%d.csv", partnerId)
    attachment(name)

    // 发送邮件
    email(name, emails)

    fmt.Println("统计结束", time.Now().Format("2006-01-02 15:04:05"))
}

使用Go确实比PHP写起来要严格一些,但是对数据库操作没有PHP那么方便和自由。PHP WEB框架目前非常的成熟,数据库等操作都是开箱即用,手到擒来,唯一的就是因为语言的关系,异步编程不方便,需要借助扩展来实现。

偶尔还看到说Go的时间格式化非常鸡肋,其实这东西,熟能生巧,就是一个字符串而已,没有必要在这些东西上浪费时间,这完全没有必要去争论。我也看到网上确实有很多类似的库,看来大家都还是有目共睹,像PHP一样处理时间最简单。不过我也没有使用,觉得这些多写几个字符影响不了工作效率,等到真的需要改善时,再来处理吧。

我喜欢用Go还有一个更大的好处是,Go本身的SDK就是用Go来写的,每个源代码都能看到,每个Go文件都有对应的测试和样例文件,很多功能直接将样例文件拷贝过来,稍加研究和理解就可以写出符合自己业务需要的代码,比如csv包:

func ExampleWriter_WriteAll() {
    records := [][]string{
        {"first_name", "last_name", "username"},
        {"Rob", "Pike", "rob"},
        {"Ken", "Thompson", "ken"},
        {"Robert", "Griesemer", "gri"},
    }

    w := csv.NewWriter(os.Stdout)
    w.WriteAll(records) // calls Flush internally

    if err := w.Error(); err != nil {
        log.Fatalln("error writing csv:", err)
    }
    // Output:
    // first_name,last_name,username
    // Rob,Pike,rob
    // Ken,Thompson,ken
    // Robert,Griesemer,gri
}

官方源码库中文件example_test.go就给出了这个例子,非常直观,容易,而PHP就没有这么便利,首先PHP源代码是C代码,至少我看不懂C代码,只能看手册。但是Go不一样,本身编写的就是Go代码,Go的源代码本身也是Go代码,我觉得这是这门语言非常让人喜欢的一个原因。只要学有余力,完全可以去读Go源代码,做更多的事情。

9. 编译代码

sudo CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install finance/main

我是在自己的Mac上编译成CentOS系统上的可执行文件,编译完成后直接拷贝到服务器上,通过PHP代码调用运行。还有一些说法是Go编译后的二进制文件有点大,确实我这个小脚本编译后有5M多,但是好像基本维持在这个容量。

10. PHP如何使用

/**
 * go脚本导数
 * @param $r
 * @param $partnerIds
 * @param int $fix
 * @param string $emails
 * @param int $checkLogId
 */
public function actionReturnExt($r, $partnerIds, $fix = 1, $emails = 'guangxunz@jumei.com', $checkLogId = 0)
{
    $path = __DIR__ . "/main {$r} {$partnerIds} {$fix} {$emails} {$checkLogId}";
    exec($path, $output);
    print_r($output);
}

PHP中使用只能按照命令执行方式来使用,exec(),system(),shell_exec()等函数都可以实现。该处因为我简单封装了路由和传参方式,所以参数传递有自己的规则,入口文件变得就相对简单一些:

// 编译Linux系统amd64下可执行文件
// sudo CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install finance/main
package main

import (
    "os"

    _ "github.com/go-sql-driver/mysql"

    "finance"
)

func main() {
    finance.Handle(os.Args[1:])
}

这就是整个go脚本的入口文件,直接接收参数即可执行,使用频率不高,需要时直接调用,故不需要守护进程。路由解析就很简单,其实就是用一种传参方式,让代码知道何时,何处调用正确的函数而已。

11. 执行效果

66329 : ./protected/yiic2014 Balance ReturnExt --r=settlementExport --partnerIds=3244
Array
(
    [0] => 统计开始 2017-08-16 11:40:33
    [1] => 2、Partner未结算数据 2017-08-16 11:40:33
    [2] => 10、在结算单创建审核中产生退货 2017-08-16 11:40:33
    [3] => 6、未发货并且无退货的订单 2017-08-16 11:40:33
    [4] => 1、partner系统已结算单 2017-08-16 11:40:33
    [5] => 7、未结算订单 2017-08-16 11:40:33
    [6] => 9、结算过退货 2017-08-16 11:40:33
    [7] => 8、结算过暂不结算 2017-08-16 11:40:33
    [8] => 4、FSP未结算数据 2017-08-16 11:40:33
    [9] => 5、Finance已结算数据 2017-08-16 11:40:33
    [10] => 3、FSP已结算数据 2017-08-16 11:40:33
    [11] => 11、没有符合任何条件的订单 2017-08-16 11:40:33
    [12] => 统计结束 2017-08-16 11:40:35
)

exitStatus:0

66329 : 结束 "./protected/yiic2014 Balance ReturnExt --r=settlementExport --partnerIds=3244"
        [begin:2017-08-16 11:40:33 end:2017-08-16 11:40:35]                      历时:     02秒

这是使用我们自己的跑数平台执行后的输出结果,而且以为异步的关系,能正确执行的条件下,肯定会比同步的PHP脚本快得多。而我们原来的PHP脚本代码是这样一行一行阻塞下去的

/**
 * 导出商家结余信息
 *
 * @param $partnerId
 * @param int $other
 * @param int $fix 是否需要检查结算后退货,重复结算等情况
 * @param string $email 邮箱账号 多个邮件之间用逗号分隔
 * @param int $checkLogId fsp系统新退保业务,可能会有该任务主键ID,来源于脚本 Balance->clean
 */
public function actionExport($partnerId, $other = 1, $fix = 1, $email = '', $checkLogId = 0)
{
    $this->partnerId = $partnerId;
    $length = count($this->showSettleColumns);
    if ($length != count($this->showOrderColumns)) {
        exit("导出excel文件头项目数不对应\n");
    }

    // 启动结算后退货检测
    if ($fix) {
        $runner = FinanceConsoleCommandRunner::createCommandRunner($this);
        $runner->addRun('TPopTransListFixData', 'FixRmaAfterSettle', ['partnerId' => $partnerId, 'checkLogId' => $checkLogId]);
        // 因为要邮件周知结算后退货等日志记录,故只能同步等待
        $runner->wait();
        unset($runner);
    }

    $logFile = $this->getLogFile();

    echo sprintf("统计开始%s\n", date('Y-m-d H:i:s'));

    $partnerName = Common::getPartnerName($partnerId, 'PARTNER');

    $list[] = $this->showSettleColumns;

    // 1、partner系统已结算单
    $list[] = array_pad(['Partner已结算数据'], $length, '');
    $partnerList = CommonPopDb::getPopDb()->createCommand($this->getPartnerSettleSql())->queryAll();
    foreach ($partnerList as $row) {
        $row['partner_id'] = $partnerName;
        $row['status'] = self::statusToDes($row['status']);
        $list[] = array_values($row);
    }
    unset($partnerList);
    echo sprintf("Partner已结算数据%s\n", date('Y-m-d H:i:s'));

    // 2、Partner未结算数据
    $list[] = array_pad(['Partner未结算数据'], $length, '');
    $partnerUnList = CommonPopDb::getPartnerDb()->createCommand($this->getUnPartnerSettleSql())->queryAll();
    foreach ($partnerUnList as $row) {
        $row['partner_id'] = $partnerName;
        $row['status'] = self::statusToDes($row['status']);
        $list[] = array_values($row);
    }
    unset($partnerUnList);
    echo sprintf("Partner未结算数据%s\n", date('Y-m-d H:i:s'));

    // 3、FSP已结算数据
    $list[] = array_pad(['FSP已结算数据'], $length, '');
    $fspList = CommonPopDb::getPopDb()->createCommand($this->getFspSettleSql())->queryAll();
    // fsp改结算项为4项,需要重新计算部分结算数值
    $fspList = $this->handleFsp($fspList);
    foreach ($fspList as $row) {
        $row['partner_id'] = $partnerName;
        $row['status'] = self::statusToDes($row['status']);
        $list[] = array_values($row);
    }
    unset($fspList);
    echo sprintf("FSP已结算数据%s\n", date('Y-m-d H:i:s'));

    // 4、FSP未结算数据
    $list[] = array_pad(['FSP未结算数据'], $length, '');
    $fspUnList = CommonPopDb::getFspDb()->createCommand($this->getUnFspSettleSql($this->partnerId))->queryAll();
    foreach ($fspUnList as $row) {
        $row['partner_id'] = $partnerName;
        $row['status'] = self::statusToDes($row['status']);
        $list[] = array_values($row);
    }
    unset($fspUnList);
    echo sprintf("FSP未结算数据%s\n", date('Y-m-d H:i:s'));

    // 5、Finance已结算数据
    $list[] = array_pad(['Finance已结算数据'], $length, '');
    $financeList = CommonPopDb::getPopDb()->createCommand($this->getFinanceSettleSql($this->partnerId))->queryAll();
    $this->handleFinance($financeList);
    foreach ($financeList as $row) {
        $row['partner_id'] = $partnerName;
        $list[] = array_values($row);
    }
    unset($financeList);
    echo sprintf("Finance已结算数据%s\n", date('Y-m-d H:i:s'));

    if ($other == 1) {
        // 6、 未发货并且无退货的订单
        $list[] = $this->showOrderColumns;
        $list[] = array_pad(['未发货并且无退货的订单'], $length, '');
        $noDeliveryNoRma = $this->noDeliveryNoRma();
        foreach ($noDeliveryNoRma as $row) {
            $this->soIdList[] = $row['id'];
            unset($row['id']);
            $list[] = array_values($row);
        }
        unset($noDeliveryNoRma);
        echo sprintf("未发货并且无退货的订单%s\n", date('Y-m-d H:i:s'));

        // 7、未结算订单
        $list[] = array_pad(['未结算订单'], $length, '');
        $unSettleList = $this->unsettle();
        foreach ($unSettleList as $row) {
            // 部分预售订单不结算
            $isSale = false;
            if ($this->isPreSale($row['order_id'])) {
                $isSale = true;
                $reason = "预售订单[{$row['order_id']}] sku_no[{$row['sku_no']}]不需结算";
            } else {
                $reason = "订单[{$row['order_id']}] sku_no[{$row['sku_no']}]未结算";
            }
            $this->surrenderLod([
                'check_log_id' => $checkLogId,
                'partner_id' => $partnerId,
                'name' => $partnerName,
                'type' => 5,
                'order_id' => $row['order_id'],
                'sku_no' => $row['sku_no'],
                'need_paid_total' => $row['need_paid_total'],
                'reason' => $reason,
                'ctime' => date('Y-m-d H:i:s')
            ]);
            if ($isSale) {
                continue;
            }
            $list[] = array_values($row);
        }
        unset($unSettleList);
        echo sprintf("未结算订单%s\n", date('Y-m-d H:i:s'));

        // 8、结算过暂不结算
        $list[] = array_pad(['结算过暂不结算'], $length, '');
        $onHold = $this->onHold();
        foreach ($onHold as $row) {
            $list[] = array_values($row);

            $this->surrenderLod([
                'check_log_id' => $checkLogId,
                'partner_id' => $partnerId,
                'name' => $partnerName,
                'type' => 6,
                'order_id' => $row['order_id'],
                'sku_no' => $row['sku_no'],
                'need_paid_total' => $row['need_paid_total'],
                'reason' => "订单[{$row['order_id']}] sku_no[{$row['sku_no']}]结算过暂不结算",
                'ctime' => date('Y-m-d H:i:s')
            ]);
        }
        unset($onHold);
        echo sprintf("结算过暂不结算%s\n", date('Y-m-d H:i:s'));

        // 9、结算过退货
        $list[] = array_pad(['结算过退货'], $length, '');
        $afterSettle = $this->afterSettle();
        foreach ($afterSettle as $row) {
            $list[] = array_values($row);
        }
        unset($afterSettle);
        echo sprintf("结算过退货%s\n", date('Y-m-d H:i:s'));

        // 10、在结算单创建审核中产生退货
        $list[] = array_pad(['在结算单创建审核中产生退货'], $length, '');
        $whilesSettle = $this->whilesSettle();
        foreach ($whilesSettle as $row) {
            $list[] = array_values($row);
        }
        unset($whilesSettle);
        echo sprintf("在结算单创建审核中产生退货%s\n", date('Y-m-d H:i:s'));

        // 11、没有符合任何条件的订单
        $list[] = array_pad(['没有符合任何条件的订单'], $length, '');
        $no = $this->CheckDiff();
        foreach ($no as $row) {
            $list[] = array_values($row);
        }
        unset($no);
        echo sprintf("没有符合任何条件的订单%s\n", date('Y-m-d H:i:s'));
    }

    $partnerName = str_replace(array(' ', '', '\t'), '_', trim($partnerName));
    $content = "<p style='color: red;'><b>$partnerName"."({$partnerId})</b>结算后退货、重复结算或其他差异记录日志:</p>\n\n";
    $title = "POP_PartnerId_{$partnerId}";
    $mailTo = $this->getMailTo();
    if ($email) {
        $emails = explode(',', $email);
        $mailTo = array_merge($mailTo, $emails);
    }

    // 查看该商家是否有结算后退货的信息
    if (file_exists($logFile)) {
        $content .= file_get_contents($logFile) . "\n";
        unlink($logFile);
    }

    $content .= "\n<p style='color: blue; '>以下为商家结余附件:</p>";
    FMailer::sendPOPExport(
        $content,
        [Common::exportExcel($list, ['title' => $title, 'biz_item' => $title])],
        $mailTo
    );
    echo sprintf("统计结束%s\n", date('Y-m-d H:i:s'));
}

哈哈,这还是我后来优化过后的代码,之前的代码好几个SQL效率都很慢,当然第一个编写这个脚本的人已经离职不再公司了,那时候还没有这么多的业务逻辑,随便写的脚本都能够很开心的跑出让大家满意的结果来。有时候在公司的时间也很重要,像我就写新功能的时候很少,但是维护现有功能的多。很多东西,如果咱们自己不想改,装作不知道,完全可以一句话把责任抛给别人,网络慢啊,服务器有点卡啊各种理由似乎都很对,毕竟优化是件让人头疼的事情,而且还不一定有结果。做好了,对自己未必有好处,但对公司,对后来的团队都有好处;做不好,只能是劳民伤财,自作自受,所以,优化其实不是真的必须面对时,一般很少有人会轻易去碰它。