豆子域名管家是一个使用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 证书哨兵,支持钉钉、企微、飞书通知,保护您的资产不掉线。

下载地址:https://91demo.top/tools/