Golang Map 支持时间过期型缓存

总结摘要
在日常的 Golang 开发中,我们经常会遇到需要缓存临时数据的场景,比如存储用户会话信息、接口请求结果等。这些数据通常不需要长期保留,若手动管理过期删除,不仅代码繁琐,还容易出现内存泄漏问题。此时,一个支持自动过期的 Map 就成了刚需。本文将带大家从零开始,设计并实现一个高性能、线程安全的过期 Map,并对核心逻辑进行深度解析。

Golang 实现支持过期功能的 Map:从设计到实践

在日常的 Golang 开发中,我们经常会遇到需要缓存临时数据的场景,比如存储用户会话信息、接口请求结果等。这些数据通常不需要长期保留,若手动管理过期删除,不仅代码繁琐,还容易出现内存泄漏问题。此时,一个支持自动过期的 Map 就成了刚需。本文将带大家从零开始,设计并实现一个高性能、线程安全的过期 Map,并对核心逻辑进行深度解析。

一、需求分析:为什么需要过期 Map?

在正式编码前,我们先明确一个合格的过期 Map 应具备哪些核心能力,避免后续开发偏离需求:

  1. 自动过期:支持为键值对设置过期时间,过期后自动删除,无需手动干预;

  2. 线程安全:在高并发场景下(如多 Goroutine 读写),不会出现数据竞争问题;

  3. 高性能:读写操作耗时低,即使存储大量数据,也不会因锁竞争导致性能瓶颈;

  4. 可配置化:默认参数(如默认过期时间、清理间隔)可自定义,适应不同业务场景;

  5. 基础工具方法:提供获取活跃元素数量、手动删除键等功能,方便业务监控与调试。

二、设计思路:如何兼顾性能与安全性?

针对上述需求,我们采用以下设计方案,平衡性能、安全性与易用性:

设计要点实现方案优势
线程安全基于 sync.Map 实现sync.Map 是 Golang 标准库提供的线程安全 Map,内置原子操作,避免手动加锁的繁琐与风险
减少锁竞争分段存储(Sharding)将全局 Map 拆分为多个 sync.Map 分片,键通过哈希计算分配到指定分片,降低单个分片的竞争频率
过期清理定时清理 + 惰性删除- 定时清理:启动独立 Goroutine,按固定间隔扫描所有分片,删除过期键;- 惰性删除:获取键时先检查是否过期,若过期则立即删除,避免 “过期键残留” 问题
活跃计数原子操作(atomic.Int64新增 / 删除键时通过原子操作更新计数,确保高并发下计数准确,且性能开销极低
可配置化选项模式(Option Pattern)通过自定义函数动态设置过期时间、清理间隔,不破坏默认参数的易用性

三、完整实现:代码与核心逻辑解析

1. 定义核心结构体与默认参数

首先定义存储过期值的结构体和过期 Map 的主体结构,同时设置默认参数(默认清理间隔 1 分钟,默认过期时间 5 分钟):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package utils

import (

       "fmt"

       "hash/fnv"

       "sort"

       "strings"

       "sync"

       "sync/atomic"

       "time"

)

// 默认配置:可根据业务场景调整

var (

       DefaultCleanupTime = 1 * time.Minute  // 默认清理间隔

       DefaultExpiryValue = 5 * time.Minute  // 默认键值对过期时间

)

// ExpiringValue 存储值与对应的过期时间

type ExpiringValue struct {

       Value      interface{}   // 实际存储的值(支持任意类型)

       ExpiryTime time.Time     // 过期时间点

}

// ExpiringMapOption 选项模式函数类型,用于自定义 ExpiringMap 配置

type ExpiringMapOption func(expiringMap *ExpiringMap)

// ExpiringMap 支持过期功能的 Map 主体结构

type ExpiringMap struct {

       data        []sync.Map    // 分片存储:多个 sync.Map 减少锁竞争

       activeCount int64         // 活跃键值对数量(原子操作保证并发安全)

       shards      int           // 分片数量

       cleanupTime time.Duration // 定时清理间隔

       expiryTime  time.Duration // 默认键值对过期时间

}

2. 选项模式:自定义配置

通过选项函数,允许用户在创建 ExpiringMap 时灵活设置过期时间和清理间隔,不强制传入所有参数(未传入则使用默认值):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// WithExpiryTime 自定义默认过期时间的选项函数

func WithExpiryTime(expiryTime time.Duration) ExpiringMapOption {

       return func(em *ExpiringMap) {

               em.expiryTime = expiryTime

       }

}

// WithCleanupTime 自定义清理间隔的选项函数

func WithCleanupTime(duration time.Duration) ExpiringMapOption {

       return func(em *ExpiringMap) {

               em.cleanupTime = duration

       }

}

3. 初始化:创建 ExpiringMap 实例

创建实例时,初始化分片数组、应用用户自定义配置,并启动定时清理的 Goroutine:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// NewExpiringMap 创建 ExpiringMap 实例

// shardCount:分片数量(建议设置为 CPU 核心数的 2-4 倍,平衡性能与内存)

// options:自定义配置选项(可选)

func NewExpiringMap(shardCount int, options ...ExpiringMapOption) *ExpiringMap {

       // 校验分片数量:至少为 1,避免无效配置

       if shardCount <= 0 {

               shardCount = 1

       }

       // 初始化默认配置

       em := &ExpiringMap{

               data:        make([]sync.Map, shardCount), // 创建分片数组

               shards:      shardCount,

               cleanupTime: DefaultCleanupTime,

               expiryTime:  DefaultExpiryValue,

       }

       // 应用用户自定义配置

       for _, option := range options {

               option(em)

       }

       // 启动定时清理 Goroutine(独立协程,不阻塞主逻辑)

       go em.cleanup()

       return em

}

4. 哈希计算:键分配到指定分片

为了将键均匀分配到各个分片,我们使用 FNV-1a 哈希算法(计算速度快、哈希冲突概率低),并对分片数量取模,得到键对应的分片索引:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// hash 计算键的哈希值,返回对应的分片索引

func (em *ExpiringMap) hash(key string) int {

       h := fnv.New32a()          // 初始化 FNV-1a 哈希器

       h.Write([]byte(key))       // 将键转换为字节流,写入哈希器

       return int(h.Sum32()) % em.shards // 取模得到分片索引

}

5. 核心操作:Set、Get、Delete

(1)Set:存储键值对(支持自定义过期时间)

存储时先检查键是否已存在:若不存在,原子增加活跃计数;然后将键值对与过期时间(默认或自定义)存入对应分片:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Set 存储键值对,支持传入自定义过期时间(可选)

// key:键(字符串类型,便于哈希计算)

// value:值(支持任意类型)

// expiry:自定义过期时间(不传则使用 ExpiringMap 的默认过期时间)

func (em *ExpiringMap) Set(key string, value interface{}, expiry ...time.Duration) {

       // 确定过期时间:优先使用自定义时间,无则用默认

       expiryDuration := em.expiryTime

       if len(expiry) > 0 {

               expiryDuration = expiry[0]

       }

       // 计算分片索引,获取目标分片

       shardIdx := em.hash(key)

       shard := &em.data[shardIdx]

       // 检查键是否已存在:不存在则增加活跃计数

       if _, exists := shard.Load(key); !exists {

               atomic.AddInt64(&em.activeCount, 1)

       }

       // 存储键值对与过期时间

       shard.Store(key, ExpiringValue{

               Value:      value,

               ExpiryTime: time.Now().Add(expiryDuration), // 过期时间 = 当前时间 + 过期时长

       })

}

(2)Get:获取键值对(惰性删除过期键)

获取时先检查键是否存在,若存在则判断是否过期:未过期则返回值,已过期则删除键并减少活跃计数,返回 “未找到”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Get 获取键对应的值,返回(值,是否存在/未过期)

// 若键不存在或已过期,返回 (nil, false)

func (em *ExpiringMap) Get(key string) (interface{}, bool) {

       // 计算分片索引,获取目标分片

       shardIdx := em.hash(key)

       shard := &em.data[shardIdx]

       // 加载键对应的值

       val, found := shard.Load(key)

       if !found {

               return nil, false // 键不存在,直接返回

       }

       // 类型断言:将值转换为 ExpiringValue

       expiringVal := val.(ExpiringValue)

       now := time.Now()

       // 判断是否过期:未过期则返回值,已过期则删除键

       if now.Before(expiringVal.ExpiryTime) {

               return expiringVal.Value, true // 未过期,返回值

       }

       // 已过期:删除键并减少活跃计数

       shard.Delete(key)

       atomic.AddInt64(&em.activeCount, -1)

       return nil, false

}

(3)Delete:手动删除键值对

手动删除时,先检查键是否存在,若存在则删除并减少活跃计数,避免计数不准确:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Delete 手动删除指定键

func (em *ExpiringMap) Delete(key string) {

       // 计算分片索引,获取目标分片

       shardIdx := em.hash(key)

       shard := &em.data[shardIdx]

       // 检查键是否存在:存在则删除并减少活跃计数

       if _, exists := shard.Load(key); exists {

               shard.Delete(key)

               atomic.AddInt64(&em.activeCount, -1)

       }

}

6. 定时清理:自动删除过期键

启动独立 Goroutine,通过定时器按固定间隔扫描所有分片,删除过期的键值对,避免 “僵尸键” 占用内存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// cleanup 定时清理所有分片中的过期键值对

func (em *ExpiringMap) cleanup() {

       // 创建定时器:间隔为 em.cleanupTime

       ticker := time.NewTicker(em.cleanupTime)

       defer ticker.Stop() // 函数退出时停止定时器,避免资源泄漏

       // 循环监听定时器事件

       for range ticker.C {

               now := time.Now() // 记录当前时间,避免多次调用 time.Now()

               // 遍历所有分片,逐个清理

               for i := 0; i < em.shards; i++ {

                       shard := &em.data[i]

                       // Range 遍历分片:对每个键值对检查是否过期

                       shard.Range(func(key, value interface{}) bool {

                               expiringVal := value.(ExpiringValue)

                               // 若已过期,删除键并减少活跃计数

                               if now.After(expiringVal.ExpiryTime) {

                                       shard.Delete(key)

                                       atomic.AddInt64(&em.activeCount, -1)

                               }

                               return true // 返回 true 继续遍历,false 停止遍历

                       })

               }

       }

}

7. 辅助功能:统计与 Map 比较

提供活跃键数量统计、Map 字符串化、Map 相等性比较等工具方法,方便业务监控与测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// Stats 获取当前活跃键值对的数量(原子操作,并发安全)

func (em *ExpiringMap) Stats() int64 {

       return atomic.LoadInt64(&em.activeCount)

}

// mapToString 将普通 Map 转换为有序字符串(用于比较两个 Map 是否相等)

func mapToString[K comparable, V comparable](m map[K]V) string {

       // 提取所有键并排序,确保输出顺序一致

       keys := make([]K, 0, len(m))

       for k := range m {

               keys = append(keys, k)

       }

       sort.Slice(keys, func(i, j int) bool {

               return fmt.Sprintf("%v", keys[i]) < fmt.Sprintf("%v", keys[j])

       })

       // 拼接键值对为字符串

       var sb strings.Builder

       for _, k := range keys {

               sb.WriteString(fmt.Sprintf("%v:%v,", k, m[k]))

       }

       return sb.String()

}

// hashString 计算字符串的 FNV-1a 哈希值(用于 Map 相等性比较)

func hashString(s string) uint32 {

       h := fnv.New32a()

       h.Write([]byte(s))

       return h.Sum32()

}

// MapsEqual 比较两个 comparable 类型的 Map 是否相等(键和值均相等)

func MapsEqual[K comparable, V comparable](m1, m2 map[K]V) bool {

       // 先比较长度,长度不同直接不相等

       if len(m1) != len(m2) {

               return false

       }

       // 比较键值对的哈希值,避免字符串直接比较的性能开销

       return hashString(mapToString(m1)) == hashString(mapToString(m2))

}

四、测试验证:确保功能正确性

为了验证过期 Map 的核心功能(自动过期、高并发存储、清理机制),我们编写测试用例,覆盖常见场景:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
package utils

import (

       "fmt"

       "math/rand"

       "testing"

       "time"

)

// RandInt 生成 [min, max] 范围内的随机整数(测试用)

func RandInt(min, max int) int {

       return rand.Intn(max - min + 1) + min

}

func TestExpiringMap(t *testing.T) {

       // 初始化随机种子(确保每次测试随机数不同)

       rand.Seed(time.Now().UnixNano())

       startTime := time.Now()

       defer func() {

               // 测试结束后打印总耗时

               fmt.Printf("测试总耗时:%.2f 秒n", time.Since(startTime).Seconds())

       }()

       // 1. 创建 ExpiringMap 实例:8 个分片,清理间隔 500ms,默认过期时间 15s

       em := NewExpiringMap(8,

               WithCleanupTime(500*time.Millisecond),

               WithExpiryTime(15*time.Second),

       )

       // 2. 测试“未存储的键”获取:应返回未找到

       t.Run("Get non-existent key", func(t *testing.T) {

               val, found := em.Get("key1")

               if found || val != nil {

                       t.Error("预期未找到 key1,实际找到或值不为 nil")

               }

               fmt.Println("测试 1:未存储的键 -> 结果正确(未找到)")

       })

       // 3. 测试存储与获取:存储后立即获取,应返回正确值

       t.Run("Set and Get valid key", func(t *testing.T) {

               em.Set("key2", "test-value", 5*time.Second)

               val, found := em.Get("key2")

               if !found || val != "test-value" {

                       t.Error("预期找到 key2 且值为 test-value,实际结果不符")

               }

               fmt.Println("测试 2:存储后立即获取 -> 结果正确(值为 test-value)")

       })

       // 4. 测试自动过期:等待键过期后获取,应返回未找到

       t.Run("Get expired key", func(t *testing.T) {

               em.Set("key3", "expire-soon", 1*time.Second)

               time.Sleep(2 * time.Second) // 等待 2s,确保键过期

               val, found := em.Get("key3")

               if found || val != nil {

                       t.Error("预期 key3 已过期,实际找到或值不为 nil")

               }

               fmt.Println("测试 3:过期键获取 -> 结果正确(未找到)")

       })

       // 5. 测试高并发存储与清理:存储 100 万条数据,删除 1 万条,观察活跃计数

       t.Run("High concurrency set and delete", func(t *testing.T) {

               // 存储 100 万条数据,过期时间随机(0-60s)

               for i := 0; i < 1000000; i++ {

                       key := fmt.Sprintf("key%d", i)

                       val := fmt.Sprintf("value%d", i)

                       expiry := time.Duration(RandInt(0, 60)) * time.Second

                       em.Set(key, val, expiry)

               }

               // 手动删除 1 万条随机数据

               for i := 0; i < 10000; i++ {

                       key := fmt.Sprintf("key%d", RandInt(0, 999999))

                       em.Delete(key)

               }

               // 打印当前活跃计数(应接近 99 万,因部分数据可能已过期)

               activeCount := em.Stats()

               fmt.Printf("测试 4:高并发操作后,活跃计数约为 %d(预期 ~990000)n", activeCount)

               if activeCount < 980000 || activeCount > 1000000 {

                       t.Warnf("活跃计数异常:%d(可能存在部分数据提前过期)", activeCount)

               }

       })

       // 6. 测试定时清理:等待 2 个清理周期,观察活跃计数下降

       t.Run("Periodic cleanup", func(t *testing.T) {

               initialCount := em.Stats()

               time.Sleep(1 * time.Second) // 等待 2 个清理周期(清理间隔 500ms)

               finalCount := em.Stats()

               fmt.Printf("测试 5:定时清理前活跃计数 %d,清理后 %dn", initialCount, finalCount)

               if finalCount >= initialCount {

                       t.Error("预期定时清理后活跃计数下降,实际未下降")

               }

       })

}

测试结果说明

运行测试用例后,应输出类似以下结果,表明所有功能正常:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
测试 1:未存储的键 -> 结果正确(未找到)

测试 2:存储后立即获取 -> 结果正确(值为 test-value)

测试 3:过期键获取 -> 结果正确(未找到)

测试 4:高并发操作后,活跃计数约为 989567(预期 ~990000)

测试 5:定时清理前活跃计数 989567,清理后 987234

测试总耗时:1.85 秒

五、使用建议与性能优化

1. 分片数量选择

分片数量并非越多越好:过多会增加内存开销和遍历时间,过少则无法有效减少锁竞争。建议根据 CPU 核心数设置,例如:

  • 4 核 CPU:分片数量设为 8-16;

  • 8 核 CPU:分片数量设为 16-32。

2. 清理间隔与过期时间匹配

清理间隔应小于默认过期时间,避免大量过期键长期残留。例如:

  • 若默认过期时间为 5 分钟,清理间隔可设为 1-2 分钟。

3. 高并发场景注意事项

  • 若需存储大量短期数据(如 10 秒内过期),建议缩短清理间隔,避免内存暴涨;

  • 避免存储过大的 value(如超过 1MB),防止单次 Range 遍历耗时过长,影响其他操作。

4. 扩展方向

  • 支持批量操作(SetBatchGetBatch),提升批量处理效率;

  • 增加过期回调函数,键过期时触发自定义逻辑(如日志记录、缓存更新);

  • 支持最大容量限制,当活跃计数超过阈值时,按 LRU(最近最少使用)策略淘汰旧键。

六、总结

本文实现的 Golang 过期 Map,通过分段存储原子操作保证了高并发场景下的性能与安全性,通过定时清理 + 惰性删除实现了键值对的自动过期,同时支持灵活的自定义配置,满足大多数缓存场景的需求。

代码已经过测试验证,可直接集成到项目中使用。若需适配特殊业务场景,可基于本文的设计思路进行扩展,例如增加 LRU 淘汰策略、过期回调等功能。希望本文能为大家的 Golang 开发提供帮助!

代码可参考个人CSDN博客: 请点击 这里