Go业务系统开发总结

2,172 阅读26分钟

近期在研发一套物联网设备管理系统,其主要用途是将公司旗下所负责智能园区中的硬件设备通过物联网云平台来进行综合管控。
整个系统在架构上设计分为 4 层。自底向上分别是设备硬件、设备接入网关、物联网平台、设备管理系统。除去设备硬件,其它 3 层都属于软件范畴。
这篇文章主要记录一下我在开发设备管理系统的后端过程中的一些总结。

系统架构分层的演进

在目前的业务系统架构中,分层是这样的。
image.png
API:接口路由层,作用是为请求匹配相应的控制器函数。
Controller:对每个请求作出响应控制器,在某些系统中被称为 Resource。
BLL:Business Logic Layout 的缩写,业务逻辑层,在某些系统中被称为 Service。
Model:数据持久层/ORM 层,通常负责持久化数据的读写操作,在某些系统中被称为 Mapper 或者 Repository。
这是中规中矩、传统且典型的三层架构。但是它存在一些弊端。
最常见的两种情况是层次边界混乱和服务循环依赖

层次边界混乱

假设我有一个登陆服务,最初只提供给 Web 端调用,验证账号密码并返回 Token。
image.png
现在需要支持 H5 调用。如果通过 H5 调用,则会判断该手机号是否注册过,如果尚未注册,需要进行注册,并返回 Token,如果已注册,则直接返回 Token。
这里面就涉及到两个服务,Login 和 Register。
image.png
一种很不友好的方式是让客户端自行调用两次接口,显然这不太可取。因为这种做法是将业务逻辑前倾到了客户端。
如果在现有架构不变的基础上实现上述需求,有两种做法可以选。
第一种做法是将 Web 和 H5 的服务彻底分离开,走两套逻辑。
image.png
这种做法的优点是隔离性更好,对测试、维护和重构都更加友好,缺点是相同的逻辑要写多套。
第二种做法是服务不分离,在 Login 服务中写判断逻辑,由 Login 服务去调用 Register 服务。这是一种冗余的做法。
image.png
这种做法的优点是逻辑复用、对开发友好,缺点是隔离性很差,增加测试、维护和重构的难度。

服务循环依赖

假设我有一个用户服务和一个评论服务。其中用户服务依赖了评论服务,而评论服务耦又依赖了用户服务。这种情况就是典型的循环依赖,会引发宕机。
image.png
如果在现有架构不变的基础上实现上述需求,那么就一定要分清楚,哪个服务是上游服务、哪个服务是下游服务,上游只能依赖下游,而下游不能反向依赖上游。
image.png
但是一旦这么做了,客户端在调用下游服务时仍然想得到原本预期的数据结构,就需要发起至少两次调用,最终由客户端自己将数据在内存中做组合。整个流程相当麻烦。
而且最大的问题是,在架构的同一层级中不应该再继续分上游与下游。

解决之道

我们先来参考一下阿里巴巴 Java 开发手册 1.4.0 中给出的分层示例:
image.png
虽然语言不同,但是架构是可以彼此参考与借鉴的。阿里的 Java 架构是目前所有语言、所有行业、所有公司中做的最好的,所以非常有参考价值。
与我们的架构不同的是,阿里多了一个 Manager 的概念,它的作用是将原来的业务逻辑层一些通用的能力下沉到这一层,并且将所有外部接口和第三方服务都在 Manager 层处理,比如系统所依赖的 RPC 服务、HTTP 服务等。
除了阿里的架构外,我还参考了目前主流的微服务架构,它的宏观架构是这样的:
image.png
其中 MicroService 的意义是提供原子服务(Atomic Service)。
BFF 层的意义和阿里系统中 Service 的意义有些类似。
总而言之,应该有一层只提供职责单一的原子服务,还应该有另一层对原子服务进行编排的服务。有些系统设计中将这一层称为 API 聚合器(API Composer),我称为组合服务(Composite Service)。无论叫什么,它们的主要职责都是通过对原子服务的聚合、编排,对外提供一种友好、格式统一、数据完整的服务。
所以我的方案是将服务层拆分为两层,一层为原子服务,一层为组合服务。
原子服务仍然可以叫 bll,组合服务就叫做 bff,当然叫 service 也可以。
原则上控制器层不会直接调用原子服务,而是调用组合服务。在项目体量不大时,如果碰到单表的增删改查,直接调用原子服务也不会有太大问题。但仍然建议尽可能的不要直接调用原子服务,因为这是一种跨层的操作,当项目体量变大时,会让引用变得混乱。

BFF 的另一个关键优势

在很多缺乏 BFF 层的系统架构设计中,可能会**将这三个原子服务暴露给客户端,由客户端分别调用三次接口,并由客户端去维护数据间的关系,这是非常不可取的,因为这会让业务逻辑前置。**最严重的问题是,后端服务重构面临各种挑战,因为后端一旦重构意味着客户端端必须配合修改,从而需要发版。在这种情况下,Web 端还好解决,只需要清除缓存。但 App 端就必须要求用户升级版本。很显然,我们没有办法决定用户是否升级版本,而强制升级是一种非常不友好的做法。所以很多系统的接口会有版本的概念,比如 v1/v2/v3...主要还是为了兼容一些老旧版本的客户端。
一旦使用了 BFF 层设计,数据的聚合以及格式化就会从客户端下沉到 BFF 层,客户端耦合的服务将是 BFF 层而不是原子服务。
这样在重构原子服务时,只需要变更 BFF 层,由 BFF 层始终与客户端保持适配,小型改动则无需客户端发版。前后端耦合严重这个问题也将迎刃而解。

BFF 层落地的困境

单纯的设计技术架构只是开始,如何让其在现有项目的人员架构上落地将会是另一个问题。
理想状态下,应该是有专人负责 BFF 层的接口工作,而且这部分人应该是和大前端进行捆绑的,算是偏前的后端工程师。
如果项目的人员比较少,单独有人负责这个工作就有点得不偿失,理论上应该是前端工程师来写 BFF 层,但问题是前端工程师可能并不会使用后端的语言和框架。所以在 BFF 层概念出现的早期,基本上都是由 Node.js 来写的。
既然如此,那么由后端工程师来写怎么样呢?同时面临一个问题。BFF 层往往是跨模块的,往往一个后端工程师负责单个或几个模块,如果有一个 BFF 接口需要对接他负责范围以外的模块,就需要去了解对应的业务,成本也不会低。如果项目成员很少,那么采用协商数据格式的方式可能效率更高。
具体如何实施,需要看具体场景。

BFF 层性能优化:并行

在架构演进出来的 bff 层中,充斥着各种服务调用与数据格式化的工作。
假设我要提供一个用户主页接口。
该接口需要提供用户基础信息、用户粉丝数/关注数、用户近期发表的 10 篇文章。
这需要三个原子服务,分别是 UserInfo、Relationship、Article。
我们需要做的是由 BFF 层提供一个 UserHomePage 接口,这个接口的粒度很粗,可以直接满足客户端的数据需求。
主要的逻辑是客户端调用接口时附带用户 ID 参数,我们通过用户 ID 作为查询参数分别调用三个服务,得到三分数据,最终按照与客户端约定好的数据结构组合起来,返回给客户端。
我们可以简单的将这个步骤分为两个环节:请求数据(Request)与处理数据(Process)。
而这两步通常来说都无需串行。
如果使用串行模式,会让接口整体响应时间变得非常久。
image.png
多个服务的请求数据如果没有业务约束,统一使用协程去调用,然后通过 chan 与处理数据函数做数据交互。
数据处理流程同样可以采取并行模式,这将会大大提高性能。
image.png

高并发下的并行需要注意什么?

业务系统中有一个 receive 接口,主要就是提供一个 WebHook,用于接收从规则引擎中流出的设备数据。
由于设备的数量可能会非常多,同时每个设备的点位可能也非常多。所以这个接口的并发会非常高。
并发量到底有多大呢?有一个计算公式:设备数量设备数据点/心跳时间。
假设有 500 台设备,每台设备有 40 个数据点,心跳时间是 2 秒。那么并发量就是 500
40/2 = 10000 请求/秒。但后来我发现这个公式是有些问题的,因为它忽略了高峰期。因为设备几乎是同时上线的,所以心跳的并发可能是在同一时刻,也就是说上面的公式更像是在计算一个平均值。真实的并发量可能是 500*40 = 20000 请求/秒。
这个接口主要做两件事:

  1. 存入 InfluxDB。
  2. 经过数据权限校验后推送给 WebSocket 客户端列表。

这两个任务彼此之间没有任何联系,为了提高整体的吞吐量,我在接收到数据后启动两个 GoRoutine,接着就把请求返回了,所以这个接口是一个异步接口。
伪代码:

func Receive(r *ghttp.Request) {
    go saveToDB()
    go sendToWS()
    gplus.ResSuccess(r, nil)
}

在 sendToWS 中又会启动 N 个 GoRoutine 为 WebSocket 的每个客户端推送数据。
也就是说每有一个请求进来,都会创建 N 个 GoRoutine。计算公式为:请求数*(2 + WebSocket 客户端数量)。
假设有 10 个 WebSocket 客户端,继续按照上面的公式计算,20000 * (2 + 10) = 400000。
所以我很快就意识到这是有问题的,不能收到一个请求就创建一堆 GoRoutine。
这么做有两点坏处:

  1. 目前的 GoRoutine 创建至少消耗 2k 内存空间,按照以上的数值粗略估算,可以看到几乎每 2 秒内就会有 8G 的内存读写,非常消耗性能。而这些协程如果可以复用将大大提高性能。
  2. GoRoutine 的数量无限制,如果设备数量继续增加,或者 WebSocket 的客户端数量继续增加,都可能让 GoRoutine 的数量无限制增长下去,导致内存爆掉。

解决方案是引入协程池。协程池的实现原理非常简单,可以自己实现一个。或者直接使用开源的 ants
使用协程池需要注意两个问题。

  1. 按照当前的业务量级合理设置协程池的数量。
  2. 协程池尽量提前预热。

性能瓶颈分析与优化

性能的分析三个步骤:

  1. 设置目标。
  2. 定位性能瓶颈点。
  3. 优化性能瓶颈点。

下面将对这三点进行展开分析。

设定目标

首先是设置性能目标,设置目标的通常是吞吐量和分位值的响应时间。如果你不清楚如何设置,我给出一个最简单的目标:在 QPS 符合预期的情况下,常规的面向客户端的接口平均响应时间不应该超过 200ms,最坏的情况是接口响应时间不应该超过 1s。你可以对你的接口进行测试,看看是否达到了这个目标。

性能测试、性能采集工具

定位性能瓶颈点除了经验以外,工具也是必不可少的。常见的工具有 benchmark、pprof 和 fortio 等。这些工具可以在不同的维度和粒度对程序进行精密的检查和监控,帮助我们全面的理解我们的程序。

benchmark

benchmark 是 Go 语言内置的一种性能测试框架,可以很容易分析出某个函数在各种调用情况下的性能表现。

pprof

pprof 是 Go 语言基础库提供的包,用于性能采集,支持 CLI 和 HTTP 两种方式。
如果是使用 goframe,可以使用 EnablePProf 开启。它可以监控 CPU、内存和 GoRoutine 等信息的实时情况。
通过 /debug/pprof 来访问它。
image.png

fortio

fortio 是一个接口负载测试框架,原来是著名的微服务管理项目 istio 的内置接口负载测试模块,后独立出来。它提供了一个简洁的 Web 界面,可以将测试结果保存到 JSON 文件中,非常简单易用。

如果你对上述工具感兴趣,可以去它们的官方网站进行学习。
除了它们,还有非常多的测试工具帮助我们完成性能测试、性能采集和性能监控等工作。

优化性能瓶颈点

我把性能瓶颈分为两个层面,宏观层面和围观层面。
宏观层面又分为基础框架和业务逻辑。
而微观层面主要是指程序设计语言的应用和数据库的应用。

基础框架

GF 和 GORM

项目的 Web 基础框架是 GF(GoFrame)、ORM 框架是 GORM。
这两个框架都非常优秀和流行,但凡事都有两面,它们都存在性能的瓶颈点。
在 Go 语言中,反射是最容易碰到的性能瓶颈。而解决反射的最佳途径往往是代码生成。
那么,在目前的基础框架中,最容易且最频繁发生的反射是在哪里呢?
两个位置。

  1. HTTP Request 的参数 JSON 解析。
  2. GORM 的参数序列化。

优化前者可以使用 easyjson 这个库来取代,后者可以使用 SQLX 和 SQLC 这两个库来取代。
GF 和 GORM 这两个基础框架都是为了易用性、可扩展性和稳定性而牺牲了部分性能。
在面对大部分场景下,它们所提供的工程化能力相对于性能都是利大于弊。
由于一般的业务不会需要很高的 QPS,所以通常情况下是不需要解决这两个问题的。
如果真的对性能有着极致的追求,自研基础框架是一个不错的选择,但是需要一定的研发能力。

业务逻辑

基础框架的性能固然重要,但是抛开业务谈框架性能有点耍流氓。
随着业务逻辑的比重增加,往往最影响系统性能的是业务逻辑而不是基础框架。

高性能接口设计:提供 batch 接口

当一个页面中存在大量接口,并且多个接口之间存在依存关系,前端工程师开始抱怨时。那么接口设计八成是真的有问题。
我们得承认一个问题:前端对接口的感知要强于后端。
将多个接口的数据组成和一个接口对外暴露的行为,可以减少请求调用次数。所以提供粗力度的 batch 接口是非常有必要的。这和 BFF 层的设计不谋而合。
如果不及时制止,很有可能会将逻辑前倾,最终导致前端逻辑很重,后端服务受限。
目前还有一种更优的方案可以选择:GraphQL。
GraphQL 是一种对前端极其友好地技术方案,但同时也极其依赖后端的基础设施。对于中小型团队而言,引入 GraphQL 对后端的挑战还是非常大的,所以在不具备足够研发能力的情况下不推荐使用 GraphQL。

SQL 查询:使用 in

一定不要在 for 循环中执行查询单条的 SQL 语句。
这种场景将查询参数收集到一个 slice 中,最终在循环外使用 in 语句执行一次 SQL 来完成。
然后再自行将查询结果逐一填充到原来的列表数据中。
假设我要查询一个用户信息列表。在 UserInfo 中需要嵌套 Address,这两个表通过 UserInfo.AddressID 和 Address 进行关联。
下面是伪代码。
数据结构:

type UserInfo struct {
    RecordID  string
    Name 	  string
    AddressID string
    
	Address   Address
}

type Address struct {
    RecordID string
	Province string
    City     string
    District string
}

查询逻辑的反例:

userInfos := &userInfoModel.Query()
for _, userInfo := range userInfos {
    userInfo.Address = addressModel.Get(&addressQueryParam{
        userInfo: userInfo.RecordID,
    })
}

假设这里查询出的 userInfos 的数量是 100 条,那么 for 循环中就会执行 100 次 SQL 查询。
正确的做法是:

userInfos := userInfoModel.Query()
userInfoIDs := [len(userInfos)]string
for _, userInfo := range userInfos {
    userInfoIDs = append(userInfoIDs, userInfo.RecordID)
}
addresses := addressModel.Query(&addressQueryParam{
    userInfoIDs: userInfoIDs,
})
for _, userInfo := range userInfos {
    for _, address := range addresses {
        if userInfo.AddressID == address.RecordID {
    		userInfo.Address = address
            continue
        }
    }
}

虽然看上去代码量增加了,并且需要写两层嵌套循环,但实际上性能比上面的代码要强得多。原因就是在内存中执行两层嵌套循环加一次 SQL 的时间远小于执行 N 次 SQL 的时间。
可以总结出一个原则:**内存性能远大于 SQL 操作性能。**这个原则同样适用于批量 insert、update 和 delete。

程序设计语言的应用:细节优化

这部分的优化主要是和 Go 语言相关,内容非常多,这里不打算逐一介绍它们的原理,因为解释它们需要花费大量的篇幅。这里只给出一个列表,后续会再写一篇文章详细介绍它们。

  • 参数传递时尽量使用指针,这样可以极大的减少 GC 频率。
  • 尽量在栈上分配对象,可以减少变量逃逸。
  • 作为参数传递时,Array 未必比 Slice 更慢。因为对于短小的对象,复制成本远小于在堆上分配和回收操作。
  • 同理,map、slice 预设容量性能会更好,因为可以避免扩容。
  • 将小对象直接存储到 map 会比存储指针性能更好。
  • map 的空间不会自动回收,即使使用 delete 清除了所有的 key。只有手动将其设为 nil 才能回收。
  • map 的 key 使用 int 类型比 string 性能更好。
  • 字符串拼接时,少量拼接使用 + 操作符,大量拼接使用 strings.Builder。
  • 锁的优化分两块:将锁粒度变小,将锁的临界区缩小。
  • 降低 Goroutine 占用的内存,特别是 Goroutine 特别多的情况。
  • 处理好 Goroutine 的生命周期,避免 Goroutine Leak。
  • 慎用 defer,单纯的 defer 性能会比直接执行要差。特别是被 Goroutine 调用的函数,容易让并发改为串行。
  • 慎用闭包,闭包会携带上下文变量,会增加内存和 CPU 的负载,如果使用不当还容易引发变量逃逸和数据竞争。
  • 慎用反射,能用反射做的事情,代码生成都可以胜任。
  • 频繁创建的对象,应该池化。

数据库的应用

数据库操作对一个接口的影响非常大,在其他几项影响性能的因素中占比最大。
首先是在技术选型上。
持久化存储技术主要是关系型数据库(MySQL)、文档数据库(MongoDB)和时序数据库(InfluxDB)。缓存技术主要是 Redis。
基础业务数据存放在 MySQL 中,具有时间戳并且频繁产生的数据存放在 InfluxDB 中,同时对 InfluxDB 中的数据进行定时清洗,将有价值的数据存储到 MongoDB 中。
前期无需引入 Redis,会增加系统复杂度。可以等请求量和数据量都达到一定量级后再引入。
综上所述,最关键的存储技术是 MySQL。

如何正确使用 MySQL?

设计表其实是不难的,但这里面有非常大的学问。
一张表无非是由表结构、索引和数据三部分组成。
表结构
结构是由业务逻辑来决定的,首先不要在一张表里设计过多字段。每个字段的类型要选对,比如小数使用 decimal 而不要使用 float 和 double,超过 decimal 的范围要将整数和小数拆开存储,以确保精度。短字符串使用定长 char,长字符串使用 text 来节省存储空间。人类年龄使用 tinyint、海龟年龄使用 smallint、太阳年龄使用 int、恒星年龄使用 bigint,也是为了节省存储空间,并提高查询速度。表结构的设计更多依靠个人经验。
除了字段外,为表选择一个合适的存储引擎也非常重要,最常用的两大存储引擎是 MyISAM 和 InnoDB。两者底层都使用变种 B+ 树来存储索引和数据,区别在于 MyISAM 将索引和数据分开存储。单纯的查询性能 MyISAM 更胜一筹,但它不支持事务和外键。目前默认的引擎是 InnoDB。如果不需要事务和外键,并且读多写少,MyISAM 可能是一个更优选择。
索引
索引是最影响 SQL 执行性能的因素之一。
合理的设置索引会大大提高查询性能。
但需要注意,索引自身会占用存储空间,并且影响插入和修改数据的性能。
由于每一个索引会产生一颗索引树,所以应该优先使用联合索引,这时更应该注意索引是否失效。
每个索引都有它的数据结构类型,目前最常见的是 B+ 树和 Hash 表。默认是 B+ 树。两者的区别是 Hash 表不支持范围查询,但可能会比 B+ 树更快。如果某条索引不需要范围查询,可以换成 Hash 表的数据格式。
数据
数据的数量过多时,会影响查询性能,主要原因是表的锁机制。
常见的优化手段有分区、分表、分库和分片。但优化的前提是数据已经达到了海量数据的级别。至于多少数据算是海量数据,有人说 500 万、有人说 1000 万、有人说 2000 万。实际上是没有标准答案的,因为不同的表结构和索引设计都不同,所以每条数据所占用的资源也不相同,很难拿出一个具体的数据来作为依据。
但根据经验来看,设计合理的数据库通常都可以通过上述的 4 种手段来解决问题。如果解决不了,可以考虑更换数据库,比如选择分布式数据库或者文档型数据库。
总结来说,设计好数据库,一定要对底层的数据结构有一定了解。如果对数据结构一窍不同,那么是很难设计好数据结构的。

到底该不该用 join?

答案是不用。
尽管大部分业务系统的初始都是单体架构,对应的数据库大概率也是单库。但我们在一早就应该考虑未来如何进行微服务拆分。
落地微服务的技术有很多,诸如 SpringCloud、Doubb 或者 go-micro 等。但它们都是技术手段。业务的拆分,靠的还是设计,比如 DDD。
按照目前微服务的最佳实践,数据库是和服务绑定的,也就是有多少个服务,就会对应有多少个数据库。如果大量采用 join,在拆分业务时数据库将会是一个大坑。
那么不用 join 会有什么问题呢?
连表问题。
连表需要在内存中手动进行。
如果你是一个具有洁癖的人,对程序具有极高的要求,那么你的数据库大概率会遵循第三范式,尽最大可能避免冗余。
当然我不排斥冗余,还是看场景。如果某些数据创建后极大概率不会变更或者变更频率极低,冗余就有意义了。它可以用空间换时间。
假设你目前的数据库不存在冗余,当你面对复杂的业务场景时,在内存中关联数据很容易就会出现性能问题。
举个例子:你有 A、B、C、D 四个表。A 包含 B、B 包含 C、C 包含 D。
假设 as、bs、cs、ds 四个切片的长度都是 20。
这是模拟的数据:

package main

import "fmt"

type A struct {
	ID  int
	BID int
	b   B
}
type B struct {
	ID  int
	CID int
	c   C
}
type C struct {
	ID  int
	DID int
	d   D
}
type D struct {
	ID int
}

var as = make([]A, 0, 20)
var bs = make([]B, 0, 20)
var cs = make([]C, 0, 20)
var ds = make([]D, 0, 20)

func init() {
	for i := 1; i <= 20; i++ {
		as = append(as, A{ID: i, BID: i})
		bs = append(bs, B{ID: i, CID: i})
		cs = append(cs, C{ID: i, DID: i})
		ds = append(ds, D{ID: i})
	}
}

func main() {
	fmt.Println(as, "\n", bs, "\n", cs, "\n", ds)
}

这种情况下的关联,很容易写出下面这种代码:

func main() {
	count := 0
	for _, a := range as {
		for _, b := range bs {
			for _, c := range cs {
				for _, d := range ds {
					if a.BID == b.ID {
						a.b = b
					}
					if b.CID == c.ID {
						b.c = c
					}
					if c.DID == d.ID {
						c.d = d
					}
					count++
				}
			}
		}
	}
	fmt.Println(count)
}

一共循环 202020*20 = 160000 次。性能损耗相当可怕。
改进方案应该是下面这样,分别填充:

func main() {
	count := 0
	for _, c := range cs {
		for _, d := range ds {
			if c.DID == d.ID {
				c.d = d
			}
			count++
		}
	}

	for _, b := range bs {
		for _, c := range cs {
			if b.CID == c.ID {
				b.c = c
			}

			count++
		}
	}

	for _, a := range as {
		for _, b := range bs {
			if a.BID == b.ID {
				a.b = b
			}
			count++
		}
	}
	fmt.Println(count)
}

这样总循环次数是 2020 + 2020 + 20*20 = 1200 次。性能提升 130 多倍。
但这样仍然不是最优方案。
认真思考一下,造成循环次数如此之多的原因是数据结构问题。扁平的数组结构必须遍历才可以访问每一个元素,那可不可以改变数据结构来避免遍历呢?当然可以。
改造数据结构,给每个切片类型添加一个 toMap 方法。

// You can edit this code!
// Click here and start typing.
package main

import "fmt"

type A struct {
	ID  int
	BID int
	b   B
}
type B struct {
	ID  int
	CID int
	c   C
}
type C struct {
	ID  int
	DID int
	d   D
}
type D struct {
	ID int
}

var as = make([]A, 0, 20)
var bs BS = make([]B, 0, 20)
var cs CS = make([]C, 0, 20)
var ds DS = make([]D, 0, 20)

func init() {
	for i := 1; i <= 20; i++ {
		as = append(as, A{ID: i, BID: i})
		bs = append(bs, B{ID: i, CID: i})
		cs = append(cs, C{ID: i, DID: i})
		ds = append(ds, D{ID: i})
	}
}

var count = 0

type BS []B
type CS []C
type DS []D

func (s BS) toMap() map[int]B {
	retMap := make(map[int]B, len(s))
	for _, item := range s {
		retMap[item.ID] = item
		count++
	}
	return retMap
}
func (s CS) toMap() map[int]C {
	retMap := make(map[int]C, len(s))
	for _, item := range s {
		retMap[item.ID] = item
		count++
	}
	return retMap
}
func (s DS) toMap() map[int]D {
	retMap := make(map[int]D, len(s))
	for _, item := range s {
		retMap[item.ID] = item
		count++
	}
	return retMap
}

func main() {
	bMap := bs.toMap()
	cMap := cs.toMap()
	dMap := ds.toMap()
	for _, a := range as {
		a.b = bMap[a.BID]
		a.b.c = cMap[a.b.CID]
		a.b.c.d = dMap[a.b.c.DID]
		count++
	}
	fmt.Println(count)
}

这样总共循环 4*20 = 80 次就可以完成数据的填充。
经过两次改造,成功将 160000 次循环下降到 80 次,成功减少 2000 倍循环次数。
本质上还是将复杂度从 O(N^2) 下降为 O(N)。这里进一步体现了算法这种基本功的重要性。

总结

在这里和大家分享一下,我做产品和技术的心得体会。
在系统研发过程中,我总是被不停的催。
我比较喜欢一个观点:好的代码都是用时间熬出来的
但我又不得不承认一个现实,从业至今,几乎所有的项目的工期都非常紧张而又苛刻。每延长一天开发周期,资本家都要多支付一笔薪水,这是他们不愿看到的。
其实这也是项目管理不够完善的原因。这种类型的系统,很难精准预估设计周期和开发周期,自然也没有 deadline。没有 deadline 意味着项目很有可能无限期的开发下去。
我们能做的,就是利用自己的技术积累和研发经验,在有限的时间内尽最大可能让程序达到最优状态。
除了技术、研发流程和项目管理,一个更加容易决定我们的微观维度,是心态。
我曾经对程序员的心态做了四种分类,分别是打工者心态、创业者心态、摸鱼心态和佛系心态。
打工者心态:以自身为主,做项目是为了提高自身技术。善于制造各种破轮子,美其名曰享受技术的乐趣,装 B 的同时还可以提高技术,常见于有上进心的工作生涯早期。
创业者心态:以产品为主,本着恶心自己成就客户的原则来做项目。善于利用各类已存在的技术来优化自己的产品。将产品作为自己的明信片。常见于有创业精神的开发者。
摸鱼心态:对技术毫无兴趣,不想对产品负责,领导说改什么就改什么,没有自己的主观判断,一直被推着走。
佛系心态:对技术有兴趣但不强烈,对产品负责但不追求极致。保证用户满意,让程序达到 60 分的状态即可。
心态的不同,决定了每个人的职业生涯的高度。
而我目前最认可的心态应该是创业者心态。因为满足别人的同时会获得认可,进而获得成就感,激发自己做出更好产品的欲望。这是一个良性循环。
话说回来,如何保证代码的高质量?产品的高水准?
第一个问题,我认为需要具备以下几点:

  1. 敢于对现有架构做出质疑。
  2. 勇于研究不理解的代码。
  3. 精于利用各类单元测试工具和自动化构建工具。
  4. 善于调整思考问题的维度,先思考本质,再思考系统性。
  5. 提高代码的迭代速度,多写 TODO、FIXME。

第二个问题比较难回答,因为它没有什么技巧或者标准。我深知做出一个好产品远比把代码写好要难得多。写代码无非是将已知问题解决掉。而做产品,更多的是探索问题、定义问题、纠正问题。思路清晰只能保证代码的质量,和产品好坏无关。设计产品更多的是对人性的理解,对本质的洞察。这是很多人始终都难以做到的,这也是为什么很多厉害的技术大牛做不出好的产品的原因。