GolangHttpSession-1 数据结构

1. 版权

本文为原创, 遵循 CC 4.0 BY-SA 版权协议, 转载需注明出处: https://blog.csdn.net/big_cheng/article/details/120415401.
文中代码属于 public domain (无版权).

2. 概述

关于Golang里实现http session, 以前写过一篇博客, 数据结构是:

type sesmgr struct {
	lock *sync.Mutex
	m    map[string]*list.Element
	l    *list.List
}

举例: key1 => e1 (ses1, 15:25过期), key2 => e2 (ses2, 15:20过期). l 按过期时间倒序: e1(15:25) -> e2(15:20).
获取ses2 时, 它的过期时间加上固定的有效期(例如30min), 它变成最后才过期的, 所以移到链头: e2(15:50) -> e1(15:25).
即这个设计限制过期时间总是固定值, 这样无论获取到哪个session 后都会移到链头 - 链尾就逐渐堆积着所有(将要)过期的session, 清理起来很方便.

此设计主要不足是固定有效期. 另外个人认为它的引用关系较多: Element 被m 引用、前/后Element 会互相引用、Element 引用l、l 又引用链头Element. 在数据量增大时gc(内存垃圾回收) 的开销可能相对较大.

关键是在m 之余 维护一个所有session 过期的顺序, 使得在各session 过期值随时变化的情况下, 能快速找到当前最早要过期的session. 按此方向了解了平衡二叉树、红黑树, 最后发现堆 是符合需求的, 因为前2 种都追求所有节点的排序, 而堆(参考) 只保证找出最小的那个节点 - 这就够用了.

通常堆 是用数组实现, 重排时只需交换元素的位置很快. 设计这个数据结构时要考虑:

  • 由于m session值 引用-> 堆节点, 堆节点改变位置时要考虑对应session值 里的引用是否也要修改. 如果直接是对象引用, 不需修改, 但是如前所述认为这对于gc 来说并非最好
  • 堆 使用slice 的话, 一个问题是扩容: 例如当容纳了10万个节点后要扩容, 需要拷贝这10万节点到新slice - 这或多或少会耗时, 对某些类型的应用可能不能接受
  • (堆节点删除后的空位回收不是问题, 因为堆删除的元素会先移到slice 尾, 只要把slice 长度-- 即可)

想要一个引用少、不需拷贝的结构. 在试验中发现Go map的性能很好, 于是想到不如用map 来模拟数组, map key就是数组下标:

  • 堆节点下标本来就是从0 开始连续. 例如堆原有3个节点下标分别是0、1、2, 删除1节点后剩余2个节点的下标会调整成0、1
  • 没有slice 的扩容问题, 新增节点就是map.set

为减少引用, 可以m session值 存放-> 堆节点的下标. 当堆节点改变下标时, 同步修改对应session值里存放的下标. 一切的前提是Go map的get/set性能.

3. p_map.go

测试3种map key: Longstr、Shortstr、int, 数据量可调:

func main() {
	rand.Seed(time.Now().Unix())

	n := 100_000
	keys := []string{}
	for i := 0; i < n; i++ {
		keys = append(keys, genKeyLongstr())
		// keys = append(keys, genKeyShortstr())
		// keys = append(keys, rand.Int())
	}

	m := map[string][2]int{}
	t0 := time.Now()
	for _, key := range keys {
		m[key] = [2]int{rand.Int(), rand.Int()}
	}
	println("init map\n\t", len(m), time.Now().Sub(t0).String())

	...
}

func genKeyLongstr() string {
	b := make([]byte, 16)
	rand.Read(b)
	return fmt.Sprintf("%X", b)
}

func genKeyShortstr() string {
	return strconv.FormatInt(int64(rand.Int()), 36)
}

首先单线程遍历一遍的时间:

	println("单线程遍历一遍")
	t0 = time.Now()
	for _, key := range keys {
		if _, ok := m[key]; ok {
		}
	}
	println("\t", time.Now().Sub(t0).String())

其次用4根线程并发做 读+改:

	println("4线程查改20s")
	chX := make(chan int)
	chOut := make(chan int)
	mu := new(sync.Mutex)
	for i := 0; i < 4; i++ {
		go func(i0 int) {
			cnt := 0
			for {
				select {
				case <-chX: // 退出
					chOut <- cnt
					return
				default:
					mu.Lock()
					key := keys[(i0+cnt*4)%n]
					if v, ok := m[key]; ok {
						v[1] = rand.Int()
						m[key] = v
					}
					cnt++
					mu.Unlock()
				}
			}
		}(i)
	}

每根线程处理不同的数据集, 处理的代码sync.
打印结果:

	time.Sleep(20 * time.Second)
	close(chX)
	cnts, total := [4]int{}, 0
	for i := 0; i < 4; i++ {
		cnts[i] = <-chOut
		total += cnts[i]
	}
	fmt.Printf("\t %v total %d(%d)\n", cnts, total, len(strconv.Itoa(total)))

手测(Intel Core i5 2.20GHz 2.19GHz; go1.16 windows/amd64) 结果如下:

// key       数据量   init   单线程遍历一遍   4线程查改20s   内存
// Longstr   10w     30ms    10ms            4kw           21M
// Longstr   100w    500ms   90ms            4kw           220M
// Shortstr  10w     30ms    10ms            4kw           19M
// Shortstr  100w    400ms   100ms           4kw           210M
// int       10w     20ms    5ms             4kw           10M
// int       100w    200ms   80ms            4kw           120M

可见int key 更快, 尤其省内存. 另, 4kw/20s = 2000次/毫秒.

4. t_ses.go

session的概念性实现, 验证新增、修改过期时间、删除操作, 及对比性能.

ses

package main

import (
	"container/heap"
	"errors"
	"fmt"
	"math/rand"
	"strconv"
	"sync"
	"time"
)

const default_duration = 30 * time.Minute // 默认有效期, 30min

type ses struct {
	key2  int                    // 在m2 的下标
	attrs map[string]interface{} // session 属性
}

func (s ses) String() string {
	return fmt.Sprintf("ses/key2=%d", s.key2)
}

sesmgr

type sesmgr struct {
	// m2 是map 模拟的堆, key2 是下标, 总是从0 开始连续. 节点0 过期时间最小.
	//
	// 堆节点swap 时, 由节点.key1 同步更新m1 ses.key2. 例如:
	//   123 => ses1(key2=0) => 堆节点0 {123, t1}, 456 => ses2(key2=1) => 堆节点1 {456, t2}.
	//   堆节点0,1 swap时, 由123,456 找到ses1,ses2 后更新ses.key2:
	//   123 => ses1(key2=1) => 堆节点1 {123, t1}, 456 => ses2(key2=0) => 堆节点0 {456, t2}.

	mu *sync.Mutex
	m1 map[uint32]*ses   // session map: key1 => ses.
	m2 map[int][2]uint32 // session过期时间map: key2 => {key1, 过期时间 (unix seconds)}.
}

func (psm *sesmgr) Len() int {
	sm := *psm
	return len(sm.m2)
}
func (psm *sesmgr) Less(i, j int) bool {
	sm := *psm
	return sm.m2[i][1] < sm.m2[j][1]
}
func (psm *sesmgr) Swap(i, j int) {
	sm := *psm
	sm.m1[sm.m2[i][0]].key2 = j
	sm.m1[sm.m2[j][0]].key2 = i
	sm.m2[i], sm.m2[j] = sm.m2[j], sm.m2[i]
}
func (psm *sesmgr) Push(x interface{}) {
	sm := *psm
	sm.m2[len(sm.m2)] = x.([2]uint32)
}
func (psm *sesmgr) Pop() interface{} {
	sm := *psm
	x := sm.m2[len(sm.m2)-1]
	delete(sm.m2, len(sm.m2)-1)
	return x
}

func newSesmgr() *sesmgr {
	return &sesmgr{
		mu: new(sync.Mutex),
		m1: map[uint32]*ses{},
		m2: map[int][2]uint32{},
	}
}

Go 堆 只要求实现heap.Interface 接口, 我们让sesmgr 本身实现它. 其中Swap 互换堆节点位置时, 也同步修改m1 ses.key2.

新建session

// 新建session.
func (psm *sesmgr) newSes() (*ses, error) {
	sm := *psm
	sm.mu.Lock()
	defer sm.mu.Unlock()
	var key1 uint32
	for i := 0; i < 11; i++ {
		if i == 10 { // 尝试10次
			return nil, errors.New("sesmgr: new key fail")
		} else {
			j := rand.Uint32()
			if _, ok := sm.m1[j]; !ok {
				key1 = j
				break
			}
		}
	}

	key2 := len(sm.m2)
	v1 := &ses{
		key2:  key2,
		attrs: map[string]interface{}{},
	}
	sm.m1[key1] = v1
	v2 := [2]uint32{key1, rand.Uint32()} // 过期时间用随机值代替
	heap.Push(psm, v2)
	return v1, nil
}

由于key1 是uint32, 所以这里最多允许40多亿个session.

查找session

// 查找session. 如果找到, 过期时间增加默认有效期.
func (psm *sesmgr) getSes(key1 uint32) *ses {
	sm := *psm
	sm.mu.Lock()
	defer sm.mu.Unlock()
	ses, ok := sm.m1[key1]
	if !ok {
		return nil
	}

	v2 := sm.m2[ses.key2]
	v2[1] = v2[1] + uint32(default_duration/time.Second)
	sm.m2[ses.key2] = v2
	heap.Fix(psm, ses.key2)
	return ses
}

留意m2 的值 是值类型([2]uint32), 所以修改时要重新set 一次, 否则改的只是一个copy.
修改过期时间后, heap.Fix 内部会调用sesmgr.Less/Swap 方法, 将该节点重排到合适位置, 使得堆头总是最小. Swap 内部会保持m1 与m2 同步.

删除session

// 删除session.
func (psm *sesmgr) delSes(key1 uint32) {
	sm := *psm
	sm.mu.Lock()
	defer sm.mu.Unlock()
	ses, ok := sm.m1[key1]
	if !ok {
		return
	}

	heap.Remove(psm, ses.key2)
	delete(sm.m1, key1)
}

heap.Remove 内部会先把待删节点Swap 到堆尾, 然后调用sesmgr.Pop 方法删掉堆尾节点, 具体参考container/heap 源码. 之后我们同步删除m1 里的值.

测试增删改

func main() {
	rand.Seed(time.Now().Unix())

	sm := newSesmgr()
	ses1, _ := sm.newSes()
	ses2, _ := sm.newSes()
	debug(sm, ses1, ses2)

	fmt.Println("\nses2 过期时间减到0")
	ses2V2 := sm.m2[ses2.key2]
	ses2V2[1] = 0
	sm.m2[ses2.key2] = ses2V2
	heap.Fix(sm, ses2.key2)
	debug(sm, ses1, ses2)

	fmt.Println("\n删除ses2, 新增ses3(显示为ses2)")
	ses2Key1 := ses2V2[0]
	sm.delSes(ses2Key1)
	ses2, _ = sm.newSes()
	debug(sm, ses1, ses2)

	...
}

func debug(sm *sesmgr, ses1, ses2 *ses) {
	ses1V2 := sm.m2[ses1.key2]
	ses2V2 := sm.m2[ses2.key2]
	fmt.Printf("ses1\n\tkey1=>ses\t\t%d => %v\n\tkey2=>{key1 expire}\t%d => %v\n",
		ses1V2[0], ses1, ses1.key2, ses1V2)
	fmt.Printf("ses2\n\tkey1=>ses\t\t%d => %v\n\tkey2=>{key1 expire}\t%d => %v\n",
		ses2V2[0], ses2, ses2.key2, ses2V2)
}

实测结果一例:

ses1
        key1=>ses               2130801059 => ses/key2=0
        key2=>{key1 expire}     0 => [2130801059 2488002794]
ses2
        key1=>ses               2825755196 => ses/key2=1
        key2=>{key1 expire}     1 => [2825755196 3040392722]

ses2 过期时间减到0
ses1
        key1=>ses               2130801059 => ses/key2=1
        key2=>{key1 expire}     1 => [2130801059 2488002794]
ses2
        key1=>ses               2825755196 => ses/key2=0
        key2=>{key1 expire}     0 => [2825755196 0]

删除ses2, 新增ses3(显示为ses2)
ses1
        key1=>ses               2130801059 => ses/key2=0
        key2=>{key1 expire}     0 => [2130801059 2488002794]
ses2
        key1=>ses               944377615 => ses/key2=1
        key2=>{key1 expire}     1 => [944377615 4200946011]

ses1 过期时间(2488002794) 小于ses2 的(3040392722), 所以堆顺序是ses1 -> ses2.
将ses2 过期时间改为0 后, 堆顺序变为ses2 -> ses1.
删掉ses2 后由于ses3 的过期时间(4200946011) 大于ses1 的, 所以堆顺序变成ses1 -> ses2(ses3).

测试性能

	fmt.Println("\ninit map")
	n := 1_000_000
	t0 := time.Now()
	keys1 := make([]uint32, n)
	for i := 0; i < n; i++ {
		if ses, err := sm.newSes(); err != nil {
			panic(err)
		} else {
			keys1[i] = sm.m2[ses.key2][0]
		}
	}
	fmt.Println("\t", n, time.Now().Sub(t0).String())

单线程遍历一遍的时间:

	fmt.Println("单线程遍历一遍")
	t0 = time.Now()
	for _, key1 := range keys1 {
		sm.getSes(key1)
	}
	fmt.Println("\t", time.Now().Sub(t0).String())

4根线程并发遍历:

	fmt.Println("4线程遍历20s")
	chX := make(chan int)
	chOut := make(chan int)
	for i := 0; i < 4; i++ {
		go func(i0 int) {
			cnt := 0
			for {
				select {
				case <-chX: // 退出
					chOut <- cnt
					return
				default:
					key1 := keys1[(i0+cnt*4)%n]
					sm.getSes(key1)
					cnt++
				}
			}
		}(i)
	}

打印结果:

	time.Sleep(20 * time.Second)
	close(chX)
	cnts, total := [4]int{}, 0
	for i := 0; i < 4; i++ {
		cnts[i] = <-chOut
		total += cnts[i]
	}
	fmt.Printf("\t %v total %d(%d)\n", cnts, total, len(strconv.Itoa(total)))

手测结果:

	// 数据量   init   单线程遍历一遍   4线程遍历20s   内存
	// 10w     120ms   40ms           2kw            16M
	// 100w    1s      500ms          1.9kw          190M

内存稍大于p_map int 的, 4线程并发数接近p_map 的一半, 由于getSes 内部实际有多次查改, 所以似乎可以.

5. 补充

拷贝的性能

// 10w 1ms 100w 10ms
func oneDim() {
	n := 100_000
	src := make([][2]int, n)
	for i := 0; i < n; i++ {
		src[i][0] = rand.Int()
		src[i][1] = rand.Int()
	}
	// println(src[0][0], src[0][1], src[99][0], src[99][1])

	dst := make([][2]int, 2*n)
	t0 := time.Now()
	cn := copy(dst, src)
	println(cn, time.Now().Sub(t0).String())
	// println(dst[0][0], dst[0][1], dst[99][0], dst[99][1])
}

// 10w 4ms 100w 30ms - 这里拷贝指针比拷贝值 更慢.
func oneDimP() {
	n := 100_000
	src := make([]*[2]int, n)
	for i := 0; i < n; i++ {
		src[i] = &[2]int{rand.Int(), rand.Int()}
	}
	// println(src[0][0], src[0][1], src[99][0], src[99][1])

	dst := make([]*[2]int, 2*n)
	t0 := time.Now()
	cn := copy(dst, src)
	println(cn, time.Now().Sub(t0).String())
	// println(dst[0][0], dst[0][1], dst[99][0], dst[99][1])
}

手测结果: 10w 1ms, 100w 10ms. 由于sesmgr 性能是1000次/毫秒, 可见拷贝还是会有影响的.

sesmgr 查删性能比较

	fmt.Println("单线程删除1/10")
	t0 = time.Now()
	for i := n / 10; i >= 0; i-- {
		sm.delSes(sm.m2[0][0])
	}
	fmt.Println("\t", time.Now().Sub(t0).String(), len(sm.m1))

手测10w和100w时, 单线程遍历一遍 : 单线程删除1/10 ≈ 2 : 3, 即删1次的耗时约等于读15次.

6. 参考

GolangHttpSession-2 数据结构l2

Golang三叉堆

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值