豆子域名管家是一个使用Wails3开发的域名和证书检测客户端工具。该工具存储域名的结构体经过几次优化,现在已经进化到如下形式:
type DomainModel struct {
Domain string `json:"domain"` // 域名信息
Port int `json:"port"` // 端口信息,默认443
UpdatedAt int64 `json:"updatedAt"` // 更新时间戳
NextCheckAt int64 `json:"nextCheckAt"` // 下次检测时间
Whois WhoisModel `json:"whois"` // 域名状态
SSL SSLModel `json:"ssl"` // 证书状态
}
type WhoisModel struct {
Expiry int64 `json:"expiry"` // 域名过期时间
RegisteredAt int64 `json:"registeredAt"` // 域名注册时间
LastCheckAt int64 `json:"lastCheckAt"` // 上次扫描时间
Status string `json:"status"` // active, expired, error
LastError string `json:"lastError"` // 最近的错误
}
type SSLModel struct {
Expiry int64 `json:"expiry"` // 证书过期时间
LastCheckAt int64 `json:"lastCheckAt"` // 上次扫描时间
Status string `json:"status"` // valid, warning, expired, error
LastError string `json:"lastError"` // 最近的错误
Issuer string `json:"issuer"` // 签发者信息,方便排查
}
这个结构体已经涵盖了前端显示的所有信息。为了管理这些域名记录,我又定义了一个结构体Store,它和文件结构也一一对应。如下:
type Store struct {
Domains []DomainModel `json:"domains"` // 域名列表
// 其它一些信息
}
type DataStore struct {
mu sync.RWMutex // 读写锁
filePath string // 文件路径
Data Store // 数据记录
isDirty bool // 是否保存标记
saveChan chan struct{} // 保存标记,已弃用
}
当把这个结构JSON序列化后,就可以存入文件中,客户端启动时,再从文件中加载记录信息,这样可以保证客户端关闭和启动不造成信息丢失。
为了方便域名和证书扫描管理,定义了结构体DomainService,它用来管理域名证书扫描以及调度通知,并提供域名证书信息给wails前端进行展示。
// 域名服务
type DomainService struct {
ctx context.Context
store *DataStore // 域名记录
stopScheduler chan struct{} // 用于停止旧的调度协程
lastStats *StatsSnapshot // 状态快照
domainMap map[string]*DomainModel // 内存索引
scanChan chan ScanTask // 扫描任务队列
activeTasks atomic.Int32
}
今天重点介绍两项内容的优化改进,分别是数据存盘与内存索引。
一、数据存盘
最初的设计,磁盘端(JSON/文件),使用 []DomainModel(切片)。 这样在持久化为 JSON 或二进制文件时,切片有自然的顺序,加载和解析速度最快,且文件体积最小。实际存储我是添加了AES加密,然后将密文存入文件。这里为了方便介绍,没有引入加密这块内容。当确定了数据结构之后,如何存储是一个大的问题。最开始的时候是使用信号通知机制(saveChan)
saveChan chan struct{} // 保存标记,已弃用
最初设计中,为了保证数据不丢失,每当一个域名探测完成,就往 saveChan 塞入一个信号触发写盘。在实际使用过程中,发现有一个很大的弊端,每当客户端初次启动时,会进行一次“全量扫描”,短时间内会产生大量探测结果,导致磁盘 IO 瞬间爆表,系统产生严重阻塞。即使目前限制的域名是100条,大量的域名扫描会频繁要求写盘。它还有一个副作用就是当磁盘IO忙碌时,saveChan通道会进行阻塞。
为了改进这个缺陷,采用了脏位标记和即时保存。具体实现方法就是添加了isDirty字段。
isDirty bool // 是否保存标记
在客户端启动的时候,就在后台开启一个goroutine,每隔5秒检查一次isDirty的值,当为true时则执行存盘操作。通过这种方法我们就可以将高频的写盘合并为低频的写盘。比如客户端启动时,如果域名数量少,可能仅需要一次写盘。这种脏位标记 + 定时轮询(isDirty)会很明显的IO 削峰填谷,平滑 IO 曲线,极大提升了极端并发下的稳定性。当然,在后期,我们还添加了即时保存SaveNow方法,因为在用户导入域名以及删除域名时,我们需要立即进行存盘。5秒的时间太长,可能用户在导入后,马上关闭客户端。那么数据就会丢失。域名扫描之所以异步存盘,就在于这个结果可以容纳丢失,第二次扫描时会进行完善,以及当用户发现有的域名没有扫描结果时,会主动进行再次手动扫描。这种设计根据业务场景兼顾了性能与安全性。
二、内存索引
在最初的设计中,每当域名扫描完成时,就会更新DomainModel,我定义的方法为:
// UpdateDomainModel 定义一个通用的模型更新函数
func (s *DomainService) updateDomainModel(domainName string, updater func(*DomainModel)) {
s.store.mu.Lock()
defer s.store.mu.Unlock()
for i := range s.store.Data.Domains {
if s.store.Data.Domains[i].Domain == domainName {
// 在闭包中执行具体的修改逻辑
updater(&s.store.Data.Domains[i])
s.store.isDirty = true
break
}
}
}
这种方法在域名记录少时,还凸显不了性能问题。当域名记录多时,又在客户端启动的时候快速进行扫描,此时的性能瓶颈就显示出来了。比如当线程探测完 example.com 后,必须遍历整个 Slice 找到对应的结构体进行赋值。它的时间复杂度每次更新都是 O(N)。如果有 1 万个域名,并发更新时的 CPU 损耗极高。为了快速找到域名记录,我引入了 map[string]*DomainModel。
domainMap map[string]*DomainModel // 内存索引
Map 指针索引能实现 O(1) 的定位速度。它的底层结构是: Slice 负责底层的顺序存储,Map 的 Value 存储指向 Slice 中对应元素的指针。这样可以瞬间定位,当收到探测结果后,通过域名直接从 Map 拿到指针,原地修改结构体。因为Map 只存储指针,不会产生大量的数据拷贝,仅需占用少量内存。
无论是在 Slice 中还是在其他地方引用这个指针,看到的数据都是同步更新的。但是它有潜藏的风险:Slice 扩容导致的“悬空指针”。在 Go 中,如果你向 []DomainModel 执行 append 操作且触发了扩容,切片底层会申请一块新内存并拷贝数据。这直接的后果就是原本 Map 里存的指针还会指向旧的内存地址,导致你更新了半天,新切片里的数据还是旧的。
所以,我在新增域名或者删除域名时,都需要重建索引。
func (s *DomainService) RebuildIndex() {
s.store.mu.Lock()
defer s.store.mu.Unlock()
// 1. 重新初始化 Map
s.domainMap = make(map[string]*DomainModel)
// 2. 遍历 store 里的原始切片
for i := range s.store.Data.Domains {
// 关键:取的是 Data.Domains 里的真实元素地址
dm := &s.store.Data.Domains[i]
// 使用 域名+端口 作为唯一标识
key := s.getMapKey(dm.Domain, dm.Port)
s.domainMap[key] = dm
}
}
这样可以规避 Go 切片扩容导致的底层内存地址漂移问题,确保 Map 中的指针永远有效。
豆子域名管家是使用Go Wails开发的一款本地域名证书检测工具,它是本地化域名与 SSL 证书哨兵,支持钉钉、企微、飞书通知,保护您的资产不掉线。