Go语言实现key-value存储系统 | 青训营

一、理论和实战介绍

1.key-value存储系统:

属于单机存储系统,使用键值对的方式进行数据持久化存储,类似于map数据结构,每一个key对应一个value,并且key总是唯一的,可以类比于关系型数据库的主码,借此通过key可以快速查询到value值;由于很多语言自带可以实现键值对的数据结构,链表,字典,哈希表,树等,因此key-value存储系统设计实现比较简单,基于本地文件系统就能对数据进行持久化保存;同时支持分布式架构,可以实现负载均衡,优化大规模数据处理;key-value也没有预定义的数据类型,可以直接存储各种类型的数据,甚至是复杂对象数据;常用于缓存,日志存储,分布式配置等;

2.LSM-Tree:

key-value存储系统实现常用的数据结构,RocksDB实现的主要存储结构,也广泛应用于很多分布式数据库系统,能够解决高吞吐量的写入操作,实现持久化存储,其设计使其在写入上非常高效,但牺牲了读取效率,会有很多读取的附加开销;其基本原理是结合写入和合并操作,实现高写入性能和持久化存储;其每一层次都是一个有序的键值存储结构,最底层是磁盘内的持久化存储结构,上层则是内存中的数据结构;在写入操作时直接写入内存表,写入速度很快,当内存表达到一定大小阈值时再转换为磁盘内的表(如:SSTables)并根据键的顺序在磁盘内排好序,并且会在一定时间将磁盘表进行合并,去重排序,减少磁盘占用;

3.实战介绍:

使用Go语言基于本地文件系统实现的简单key-value存储系统,使用本地文件作为持久化存储位置,实现了基本的put(key,value)get(key)scan_by_prefix(prefix)三个接口,分别用于插入键值对数据,根据键查找值和按照指定前缀检索键值对;需要支持存储系统server独立进程,选择使用Windows自带的Telnet客户端工具连接并使用指令测试。

二、实战内容

1.基于本地文件的key-value存储实现:

首先我们需要先解决如何基于本地文件系统对键值对进行持久化存储,最简单的当然是直接用一个本地文件当作磁盘存储键值对数据,直接将键值对按照一定的格式写入到文件中持久化存储,查询时利用key唯一的特性遍历文件也能直接取到value,这样就能基于本地文件实现一个简易的key-value存储系统;

  • put(key,value)方法实现: 首先打开本地文件,对文件写入方式,不存在处理,权限等进行设置,使用fmt.Fprintf将键值对以冒号进行分隔再写入文件,在写入之前,为了确保key值的唯一性,需要检查当前文件中是否已存在相同key,存在时直接返回并提示该key已经存在,不存在则将键值对写入文件,写入成功后关闭文件;
go 复制代码
func put(key, value string) error {
//os.O_APPEND 表示写入时在文件末尾追加,.O_CREATE 表示如果文件不存在则创建,0644 是文件权限位
	file, err := os.OpenFile(FilePath, os.O_APPEND|os.O_CREATE, 0644)
	if err != nil {
		return err
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		parts := strings.Split(scanner.Text(), ":")
		if len(parts) >= 2 && parts[0] == key {
			fmt.Println("Key already exists", key)
			return nil
		}
	}
	_, err = fmt.Fprintf(file, "%s:%s\n", key, value)
	if err == nil {
		fmt.Println("Put successfully")
	}
	return err
}
  • get(key)方法实现:首先打开文件并获取到文件内容,然后遍历每一行获取到键值对,根据冒号将键值分开,键等于参数key时对应的value即为需要查询的值,没有找到key则证明没有该key对应的键值对,则返回空值并提示未找到key;
go 复制代码
func get(key string) (string, error) {
	file, err := os.Open(FilePath)
	if err != nil {
		return "", err
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		parts := strings.Split(scanner.Text(), ":")
		if len(parts) == 2 && parts[0] == key {
			return parts[1], nil
		}
	}

	if err := scanner.Err(); err != nil {
		return "", err
	}

	return "", fmt.Errorf("key not found: %s", key)
}
  • scan_by_prefix(prefix)方法实现:与上述两个方法不同,该方法需要获取到指定前缀的所有键值对,返回值是string类型的切片,同样首先打开文件并逐行获取内容,获取到每行内容后按冒号分隔键值对后进行判断,直接使用字符串函数func HasPrefix(s, prefix string) bool判断key是否前缀为prefix,如果是则将该键值对放入results字符串切片中,直到文件内容全部遍历;
go 复制代码
func scanByPrefix(prefix string) ([]string, error) {
	file, err := os.Open(FilePath)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	var results []string
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		parts := strings.Split(scanner.Text(), ":")
		if len(parts) == 2 && strings.HasPrefix(parts[0], prefix) {
			results = append(results, scanner.Text())
		}
	}

	if err := scanner.Err(); err != nil {
		return nil, err
	}

	return results, nil
}
  • 功能测试:指定操作文件const FilePath = "data.db",在main函数中编写示例测试上述三个函数是否正确:

    put(key,value)测试及其结果:

go 复制代码
	err := put("name", "Alice")
	if err != nil {
		fmt.Println("Error putting data:", err)
	}

	err2 := put("name", "Alic")
	if err2 != nil {
		fmt.Println("Error putting data:", err2)
	}

	err3 := put("age", "18")
	if err3 != nil {
		fmt.Println("Error putting data:", err3)
	}

	err4 := put("name_small", "lili")
	if err4 != nil {
		fmt.Println("Error putting data:", err4)
	}

自动创建data.db文件并逐行写入内容:

get(key)测试及结果:

go 复制代码
	value, err := get("name")
	if err != nil {
		fmt.Println("Error getting data:", err)
	} else {
		fmt.Println("Value for key 'name':", value)
	}

	value2, err2 := get("age")
	if err2 != nil {
		fmt.Println("Error getting data:", err2)
	} else {
		fmt.Println("Value for key 'age':", value2)
	}

scan_by_prefix(prefix)测试及结果:

2.基于B+树的key-value存储实现:

上述方法虽然实现了简单的键值对存储,但是直接对文件进行遍历读取,性能和代码质量都是很低的,因此我们要在内存上选取合适的数据结构用于优化数据的读写,由于LSM-Tree本身实现就已经是一个完备的key-value存储系统,并且在实现上也有很多技术难点,由于能力有限,这里我选择了更熟悉的数据库中常用的B+树来进行优化实现,同样能很大程度上提高读写性能;同时Go语言可以直接引入"github.com/google/btree"库,这个库提供了B树和B+树数据结构的实现,给出了很多内置的key-value操作函数,直接利用它可以高效的进行键值对的存储和查询;

  • 全局变量和数据结构:用于指定B+树阶数(B+树的阶数关系到系统的查询和磁盘IO的性能效率,需要根据系统的使用场景和设备情况进行综合判断选择,这里由于测试数据量小综合选定4)和文件路径;三个结构体分别对应键值对、btree.Item接口、键值存储;
go 复制代码
const order = 4 // B+树的阶数,每个节点最多可以容纳的子节点数为4
const FilePath = "data.db"

type KeyValue struct {
	Key   string
	Value string
}

// KeyValueItem 实现了btree.Item接口的结构体,用于在B+树中存储键值对
type KeyValueItem struct {
	KeyValue KeyValue
}

// KeyValueStore 定义了键值存储的结构体
type KeyValueStore struct {
	tree *btree.BTree //存储 B+ 树的实例,用于管理键值对
	file *os.File     //存储数据的文件句柄,用于读写数据
}
  • NewKeyValueStore函数用于读取文件并将文件中的已有数据填充到B+树中,将数据初始化到B+树结构中用于后续操作;Less方法,它用于比较两个KeyValueItem实例的大小关系,以便在B+树中进行排序和搜索操作,是使用B+树必须要实现的方法;Close方法用于关闭文件;
go 复制代码
func (k KeyValueItem) Less(than btree.Item) bool {
	return k.KeyValue.Key < than.(KeyValueItem).KeyValue.Key
}

func NewKeyValueStore() (*KeyValueStore, error) {
	// 使用适当的标志和权限打开或创建数据文件
	file, err := os.OpenFile(FilePath, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0644)
	if err != nil {
		return nil, err
	}

	// 创建一个新的KeyValueStore实例,包含一个空的B+树和打开的文件
	kvStore := &KeyValueStore{
		tree: btree.New(order),
		file: file,
	}

	// 从文件中读取现有数据并填充到B+树中
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		parts := strings.Split(scanner.Text(), ":")
		if len(parts) == 2 {
			// 创建一个KeyValueItem并将其插入到B+树中
			kvStore.tree.ReplaceOrInsert(KeyValueItem{KeyValue: KeyValue{Key: parts[0], Value: parts[1]}})
		}
	}
	if err := scanner.Err(); err != nil {
		return nil, err
	}

	return kvStore, nil
}

func (kv *KeyValueStore) Close() {
	kv.file.Close()
}
  • put(key,value)方法优化:首先在树中查找是否已存在相同的key,比起第一种直接遍历文件查询,可以大大降低IO损耗,如果存在则提示key已存在,如果不存在,则先将键值对写入文件,最后写入B+树并进行自平衡;
go 复制代码
func (kv *KeyValueStore) Put(key, value string) error {
	// 查找树中是否存在具有相同键的项目
	item := kv.tree.Get(KeyValueItem{KeyValue: KeyValue{Key: key}})
	// 如果键已存在,进行覆盖操作
	if item != nil {
		fmt.Println("The key already exists, Please change the key.")
		return nil
	}
	// 将新键值对写入文件
	_, err := fmt.Fprintf(kv.file, "%s:%s\n", key, value)
	if err != nil {
		return err
	}
	// 将新键值对插入到树中
	kv.tree.ReplaceOrInsert(KeyValueItem{KeyValue: KeyValue{Key: key, Value: value}})
	return nil
}
  • get(key)方法优化:既然已经使用了B+树,查询就可以直接利用btree包中的Get函数实现,直接在B+树上查找进而避免了直接遍历文件:
go 复制代码
func (kv *KeyValueStore) Get(key string) (string, bool) {
	item := kv.tree.Get(KeyValueItem{KeyValue: KeyValue{Key: key}})
	if item != nil {
		return item.(KeyValueItem).KeyValue.Value, true
	}
	return "", false
}
  • scan_by_prefix(prefix)方法优化:使用B+树的AscendGreaterOrEqual方法,从指定的前缀键开始向后(按键的递增顺序)扫描树中的键值对,对于每个找到的项,检查键是否具有指定的前缀,如果是的话,就将对应的值添加到结果列表中,从而也避免了直接对文件进行遍历操作,利用B+树的特性快速查询:
go 复制代码
func (kv *KeyValueStore) ScanByPrefix(prefix string) []string {
	var results []string
	kv.tree.AscendGreaterOrEqual(KeyValueItem{KeyValue: KeyValue{Key: prefix}}, func(item btree.Item) bool {
		if kvi, ok := item.(KeyValueItem); ok && strings.HasPrefix(kvi.KeyValue.Key, prefix) {
			results = append(results, kvi.KeyValue.Key+":"+kvi.KeyValue.Value)
		} else {
			return false
		}
		return true
	})
	return results
}
  • 功能测试:仍然直接在main函数中测试:

    B+树初始化:

go 复制代码
	kvStore, err := NewKeyValueStore()
	if err != nil {
		fmt.Println("Error initializing KeyValueStore:", err)
		//return
	}
	defer kvStore.Close()

put(key,value)测试及其结果:

go 复制代码
kvStore.Put("name", "John")
kvStore.Put("age", "25")
kvStore.Put("telephone", "123456789")
kvStore.Put("name_small", "Johnny")
kvStore.Put("name", "Alic")

get(key)测试及其结果:

go 复制代码
	value, found := kvStore.Get("name")
	if found {
		fmt.Println("Value for key 'name':", value)
	} else {
		fmt.Println("Key 'name' not found")
	}
	
	value2, found2 := kvStore.Get("telephone")
	if found2 {
		fmt.Println("Value for key 'telephone':", value2)
	} else {
		fmt.Println("Key 'telephone' not found")
	}

scan_by_prefix(prefix)测试及结果:

go 复制代码
	value := kvStore.ScanByPrefix("na")
	if value != nil {
		fmt.Println("Value for prefix 'na':", value)
	} else {
		fmt.Println("Prefix 'na' not found")
	}

3.实现存储server独立部署:

经过B+树数据结构的优化,key-value存储系统已经初步优化了读写效率,能够实现所需的三个功能,接下来需要将存储服务进行独立部署,使用户可以通过其他客户端连接该服务使用类似SQL的指令对存储系统进行操作,上述三种指令的实现不需要更改,只需要设置客户端指令格式,给出指令处理的逻辑并且创建服务等待客户端连接;

  • 增加handleConnection(conn net.Conn, kvStore *KeyValueStore)方法用于处理客户端连接,读取客户端指令,同时定义命令 PUTGETSCAN,分别对应实现的三种方法,增加 QUIT用于关闭连接,退出服务,函数会读取客户端传入的指令及其参数,根据指令类型调用相应的函数,执行相应的操作,并且将执行命令后的响应写回给客户端;
go 复制代码
// handleConnection函数用于处理传入的连接
func handleConnection(conn net.Conn, kvStore *KeyValueStore) {
	defer conn.Close()  // 在函数结束时关闭连接

	reader := bufio.NewReader(conn)    // 创建连接读取器
	writer := bufio.NewWriter(conn)    // 创建连接写入器

	for {
		request, err := reader.ReadString('\n')  // 读取请求字符串直到遇到换行符
		if err != nil {
			fmt.Println("Error reading request:", err)
			return
		}

		parts := strings.Fields(request)  // 将请求字符串分割成字段
		if len(parts) == 0 {
			continue
		}

		command := parts[0]  // 提取命令部分
		switch command {

		case "PUT":
			if len(parts) >= 3 {
				key := parts[1]
				value := parts[2]
				err := kvStore.Put(key, value)  // 调用键值存储的PUT方法存储数据
				if err != nil {
					writer.WriteString("\nError putting data: " + err.Error() + "\n")
				} else {
					writer.WriteString("\nPut successfully\n")
				}
			} else {
				writer.WriteString("\nInvalid PUT command format\n")
			}

		case "GET":
			if len(parts) >= 2 {
				key := parts[1]
				value, found := kvStore.Get(key)  // 调用键值存储的GET方法获取数据
				if found {
					writer.WriteString("\n" + value + "\n")
				} else {
					writer.WriteString("\nKey not found\n")
				}
			} else {
				writer.WriteString("\nInvalid GET command format\n")
			}

		case "SCAN":
			if len(parts) >= 2 {
				prefix := parts[1]
				values := kvStore.ScanByPrefix(prefix)  // 调用键值存储的ScanByPrefix方法按前缀扫描数据
				if values != nil {
					for _, value := range values {
						writer.WriteString("\n" + value + "\n")
					}
				} else {
					writer.WriteString("Prefix not found\n")
				}
			} else {
				writer.WriteString("\nInvalid SCAN command format\n")
			}

		case "QUIT":
			return  // 结束连接处理
		default:
			writer.WriteString("\nUnknown command\n")
		}

		writer.Flush()  // 刷新写入缓冲区,将数据发送给客户端
	}
}
  • 主函数初始化B+树后设置监听IP和端口,建立TCP连接,当一个连接被接受时,会启动一个新的goroutine来使用handleConnection()函数处理连接和指令,服务器会持续运行,直到手动终止。
go 复制代码
func main() {
	kvStore, err := NewKeyValueStore()
	if err != nil {
		fmt.Println("Error initializing KeyValueStore:", err)
		return
	}
	defer kvStore.Close()

	listener, err := net.Listen("tcp", "127.0.0.1:8080")
	if err != nil {
		fmt.Println("Error starting server:", err)
		return
	}
	defer listener.Close()

	fmt.Println("Server is listening on 127.0.0.1:8080")

	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("Error accepting connection:", err)
			continue
		}
		go handleConnection(conn, kvStore)
	}
}

4.存储服务测试:

测试利用Windows自带的客户端工具Telnet进行,使用Telnet连接服务,在终端输入指令测试存储系统的正确性;

Telnet客户端是一种命令行工具,用于与远程服务器或设备建立Telnet连接并进行文本交互,通过Telnet客户端,用户可以通过命令行界面与远程服务器通信,执行命令和查看输出;它使用的是Telnet协议,Telnet协议是一种用于远程终端访问的网络协议,允许用户通过网络连接到远程计算机并在其上执行操作,就像本地终端一样,同时也可以用于连接本地服务,支持TCP、HTTP等协议的连接,但是不支持UDP,因为其底层基于TCP实现,利用这些特性,可以使用Telnet工具做很多服务器的调试测试工作。

PUT指令测试:

GET指令测试:

SCAN指令测试:

错误指令:

QUIT指令:

三、总结

这个实战是存储和数据库课程的课后作业,我按着自己的理解来简单实现了一下,由于时间能力的限制,功能并不完善,只实现了三个命令,整体处理也很简单,除了使用B+树优化读写也没有再增加其他的优化方案,也没有进一步探索分布式架构的实现,还有很大的扩展空间,B+树的实现也直接偷懒使用了Golang的外部库,表示功能非常强大!

虽然实现的很简陋,但是也学到了很多,对btree库的使用,key-value存储系统的特点,LSM-Tree的特性,包括Telnet客户端工具的使用,都是以前接触的较少或者没有深入思考的知识,同时也知道了一个优秀的存储系统要怎样去优化读写性能,减少IO时延,同样更加认识到数据结构的重要性,一个好的存储系统永远离不开一个好的数据结构,一个好的数据结构对一个存储系统会有非常大的优化作用,无论是存储系统还是数据库,无论是数据的写入还是查询,都离不开数据结构,所以掌握好这些基础知识才是开发优秀数据存储服务的关键。

相关推荐
Find24 天前
MaxKB 集成langchain + Vue + PostgreSQL 的 本地大模型+本地知识库 构建私有大模型 | MarsCode AI刷题
青训营笔记
理tan王子24 天前
伴学笔记 AI刷题 14.数组元素之和最小化 | 豆包MarsCode AI刷题
青训营笔记
理tan王子24 天前
伴学笔记 AI刷题 25.DNA序列编辑距离 | 豆包MarsCode AI刷题
青训营笔记
理tan王子24 天前
伴学笔记 AI刷题 9.超市里的货物架调整 | 豆包MarsCode AI刷题
青训营笔记
夭要7夜宵1 个月前
分而治之,主题分片Partition | 豆包MarsCode AI刷题
青训营笔记
三六1 个月前
刷题漫漫路(二)| 豆包MarsCode AI刷题
青训营笔记
tabzzz1 个月前
突破Zustand的局限性:与React ContentAPI搭配使用
前端·青训营笔记
Serendipity5651 个月前
Go 语言入门指南——单元测试 | 豆包MarsCode AI刷题;
青训营笔记
wml1 个月前
前端实践-使用React实现简单代办事项列表 | 豆包MarsCode AI刷题
青训营笔记
用户44710308932421 个月前
详解前端框架中的设计模式 | 豆包MarsCode AI刷题
青训营笔记