大家好,如果您还对徒手用 Go 写个 Redis 服务器-go操作redis不太了解,没有关系,今天就由本站为大家分享徒手用 Go 写个 Redis 服务器-go操作redis的知识,包括的问题都会给大家分析到,还望可以解决大家的问题,下面我们就开始吧!
今天给大家带来的开源项目是Godis:一个用Go语言实现的Redis服务器。支持:
5种数据结构(string、list、hash、set、sortedset)自动过期(TTL)发布订阅、地理位置、持久化等功能
您可能不需要自己实现Redis服务,但是您是否厌倦了每天编写增删改查的业务代码?想提高你的编程水平吗?尝试从头开始编写一个项目并打开IDE却发现无从下手?
手工造轮子绝对是提高编程技能的好方法。让我们使用Go 从头开始编写一个Redis 服务器(Godis)。从中你将学到:
如何编写Go语言TCP服务器,设计和实现安全可靠的通信协议(redis协议),如何使用Go语言开发高并发程序,设计和实现分布式集群和分布式事务,熟悉常用链表、哈希表、跳转表、时间轮等,不用担心数据结构太难!虽然示例代码是Go,但即使你不懂Go语言,也不影响你理解Redis的原理和底层协议以及高性能的秘密。另外,为了照顾广大读者,作者对技术讲解进行了优化。示例代码在原项目的基础上进行了简化,并逐行注释。如果您是高级玩家,请直接访问项目阅读源码:
https://github.com/HDT3213/godis
让我们一起拨开Redis的迷雾吧。
一、写个 TCP 服务器
众所周知,Redis是C/S模型,采用TCP协议进行通信。接下来,我们开始实现TCP 服务器。 Golang作为服务器端广泛使用的编程语言,提供了非常简单的TCP接口,因此实现起来非常方便。示例代码:
funcListenAndServe(addressstring){//绑定监听地址listener,err:=net.Listen('tcp',address)iferr!=nil{log.Fatal(fmt.Sprintf('listenerr:%v',err))}deferlistener. Close()log.Println(fmt.Sprintf('bind:%s,startlistening.',address))for{//Accept会阻塞,直到有新连接建立或者监听中断才返回conn, err:=listener 。 Accept()iferr!=nil{//这通常是监听器被关闭,无法继续监听导致的错误。 log.Fatal(fmt.Sprintf('accepterr:%v',err))}//打开一个新的goroutine来处理连接goHandle(conn)}}funcHandle(connnet.Conn){reader:=bufio.NewReader(conn)for {//ReadString会阻塞,直到遇到分隔符'\n' //遇到分隔符后,ReadString会返回上次遇到分隔符之前收到的所有数据//如果遇到分隔符之前发生异常,ReadString会返回接收到的数据和错误信息msg, err:=reader.ReadString('\n')iferr!=nil{//通常遇到的错误是连接中断或关闭,用io.EOF表示ifr==io .EOF{log.Println('connectionclose')}else{log.Println(err)}return}b:=[]byte(msg)//将接收到的信息发送给客户端conn.Write(b)}}funcmain( ){ListenAndServe(':8000')} 到目前为止,只需要40行代码就可以获取服务器了!启动上面的TCP服务后,在终端中输入telnet 127.0.0.1 8000,连接到刚才写的服务器。它会原样返回你发送给你的消息(所以请不要骂它):
这个TCP服务器非常简单。主协程调用accept函数监听端口。接受新连接后,它会打开一个Goroutine 来处理它。这种简单的阻塞IO模型有点类似于早期的Tomcat/Apache服务器。
阻塞IO模型使用线程来处理连接。当没有接收到新数据时,监听线程会被阻塞,直到数据准备好并唤醒线程进行处理。由于阻塞IO模型需要开启大量线程并进行频繁的上下文切换,因此效率较低。 Redis使用的epoll技术(IO多路复用)使用一个线程来处理大量连接,大大提高了吞吐量。那么我们的TCP服务器会比Redis慢很多吗?
当然不是。 Golang利用Goroutine调度开销远小于线程调度开销的优势,封装了一个goroutine-per-connection风格的极简接口,而net/tcp库将epoll封装成阻塞IO,在享受epoll高性能的同时避免了复杂性本机epoll 接口所需的异步代码。
在笔者的电脑上,Redis每秒可以响应10.6k PING命令,而Godis(完整代码)的吞吐量为9.2 kqps,相差不大。如果想了解更多Golang 高性能的秘密,可以搜索go netpoller 或go language network poller 关键词
另外,一个合格的TCP服务器在关闭时不应该停止,而是需要完成必要的清理工作,例如响应收到的请求、释放TCP连接等。该功能一般称为优雅关闭或优雅关闭。优雅关机步骤:
首先,关闭监听器以停止接受新连接
然后,迭代所有幸存的连接并一一关闭它们
优雅关闭的代码有很多,这里就不完整贴出来了。
二、透视 Redis 协议
解决了通信问题之后,下一步就是搞清楚Redis协议。它实际上是一组像JSON 和Protocol Buffers 这样的序列化协议。可以看到底层其实就是一些基础知识。
从Redis 2.0开始通信已经统一为RESP协议(REdis Serialization Protocol)。该协议易于实现,不仅可以被程序高效解析,而且可以被人类读取和调试。
RESP 是一种基于TCP 协议的二进制安全文本协议。 RESP以行为单位,客户端和服务器发送的命令或数据总是使用\r\n(CRLF)作为换行符。
二进制安全意味着协议中允许使用任意字符而不会导致失败。例如,C 语言字符串以\0 结尾,不允许\0 出现在字符串中间,而Go 语言字符串则允许\0 出现。我们说Go语言字符串是二进制安全的,但是C语言字符串不是二进制安全的。的。
RESP 的二进制安全性允许我们在键或值中包含特殊字符,例如\r 或\n。使用Redis存储protobuf、msgpack等二进制数据时,二进制安全性尤为重要。
RESP定义了5种格式:
简单字符串:服务器用于返回简单结果,例如“OK”。它不是二进制安全的,并且不允许换行。错误消息(Error):服务器用于返回简单的错误消息,例如'ERR Invalid Synatx'。二进制安全,不允许换行。整数(Integer):llen、scard等命令的返回值,64位有符号整数字符串(Bulk String):二进制安全字符串,如返回值数组(Array、Called Multi Bulk Strings):Bulk String数组,客户端发送指令以及lrange等命令的响应格式。 RESP 使用第一个字符来指示格式:
简单字符串:以'+' 开头,如:'+OK\r\n' 错误:以'-' 开头,如:'-ERR Invalid Synatx\r\n' 整数:以':' 开头,如as: ':1\r\n' 字符串:以$ 开头数组:以* 开头让我们通过一些实际例子来理解该协议。
2.1 字符串String(Bulk String)有两行,第一行是$+文本长度,第二行是实际内容。喜欢:
$3\r\nSET\r\nString (Bulk String) 是二进制安全的,这意味着Bulk String 中可以包含'\r\n' 字符(行尾的CRLF 被隐藏):
$4a\r\nb2.2 空$-1 表示零。例如,使用get命令查询不存在的key时,返回的结果是$-1。
2.3 数组第一行数组(Array)格式为'*'+数组长度,后面跟相应数量的字符串(Bulk String)。例如['foo', 'bar']消息(传输时的内容):
*2$3foo$3bar 客户端也使用数组(Array)格式向服务器发送指令。命令本身将用作第一个参数,例如SET 键值命令的RESP 消息:
*3$3SET$3key$5value 打印出换行符:
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n2.4 解析预备知道了常用RESP消息的内容后,就可以开始解析了他们。但需要注意的是,RESP 是一种二进制安全协议,它允许在正文中使用\r\n 字符。例如,Redis可以正确接收并执行SET 'a\r\nb' hellogithub命令。该命令的正确消息如下:
*3$3SET$4a\r\nb$11hellogithub 当ReadBytes 读取第五行'a\r\nb\r\n' 时,会误认为是两行:
*3$3SET$4a//错误分支b//错误分支$11hellogithub 因此,读完第四行$4后,不应该继续使用ReadBytes('\n')来读下一行。你应该使用io. ReadFull(reader, msg) 方法读取指定长度的内容。
msg=make([]byte,4+2)//文本长度4+换行长度2_,err=io.ReadFull(reader,msg)2.5 编写 RESP 协议解析器解决上面内容包含'\r\n的问题后',我们可以开始编写Redis协议解析器了!
typePayloadstruct{Dataredis.ReplyErrerror}//ParseStream通过io.Reader读取数据,并通过channel将结果返回给调用者//流处理接口适合客户端/服务器使用funcParseStream(readerio.Reader)-chan *Payload{ch:=make(chan*Payload)goparse0(reader,ch)returnch} 由于解析器代码较多,这里只简单介绍一下核心流程。
funcparse0(readerio.Reader,chchan-*Payload){//初始化读取状态readMultiLine:=falseexpectedArgsCount:=0varargs[][]bytevarbulkLenint64for{//上面我们提到RESP是单行Bit //因为行被分为简单的字符串和二进制安全的BulkStrings,我们需要封装一个readLine函数来兼容line,err=readLine(reader,bulkLen)iferr!=nil{//处理错误返回}//接下来我们解析刚刚读取的行//我们简单地将Reply分为两类: //单行333 60StatusReply,IntReply,ErrorReply//多行:BulkReply,MultiBulkReplyif!readingMultiLine{ifisMulitBulkHeader(line){//我们收到了MulitBulkReply的第一行//在MulitBulkReply中获取B的数量ulkString预期ArgsCount=parseMulitBulkHeader(line)//等待MulitBulkReply的后续行readMultiLine=true}elseifisBulkHeader(line){//我们收到BulkReply的第一行//获取BulkReply第二行的长度,告诉readLine函数通过bulkLen获取下一行BulkString的长度bulkLen=parseBulkHeader()//本次Reply中总共有1个BulkStringexpectedArgsCount=1//等待BulkReply的后续行readMultiLine=true}else{//处理单行这样如StatusReply、IntReply、ErrorReply等。 Replyreply:=parseSingleLineReply(line)//通过ch emitReply(ch)返回结果}}else{//进入这个分支表明我们正在等待MulitBulkReply或BulkReply的后续行//Mul itBulkReply 的后续行有两种类型,BulkHeader 或BulkStringifisBulkHeader(line){bulkLen=parseBulkHeader()}else{//我们读取的是BulkString,可能是MulitBulkReply 或BulkReplyargs=append(arg s,line)}iflen (args)==expectedArgsCount{//我们已经读取了所有后续行//通过ch返回结果emitReply(ch)//重置状态,准备解析下一个ReplyreadingMultiLine=falseexpectedArgsCount=0args=nilbulkLen=0}}}}
三、实现内存数据库
至此我们就完成了数据接收和解析部分。剩下的问题是我们应该将数据存储在哪里?
抛开持久化部分不谈,作为一个基于内存的KV数据库,Redis的所有数据都需要存储在内存中的一个哈希表中,而这个哈希表就是我们今天需要编写的最后一个组件。
与单线程Redis不同,我们实现的Redis(godis)是并行工作的,所以必须考虑各种并发安全问题。常见的并发安全哈希表设计有以下几种:
sync.map:Golang官方提供的并发哈希表,适合读多写少的场景。然而,在m.dirty 提升后,m.read 将被复制到新的m.dirty 中。当数据量较大时,复制操作会阻塞所有协程,存在重大隐患。 juc.ConcurrentHashMap:Java的并发哈希表是使用分段锁实现的。扩容时,所有访问哈希表的线程都会协助rehash操作,在rehash完成之前所有读写操作都会被阻塞。由于缓存数据库中的键值对数量庞大,读写操作的响应时间较高,因此juc策略并不合适。 memcached hashtable:当后台线程进行rehash操作时,主线程会判断要访问的hash槽是否已经被rehashed,并决定是操作old_hashtable还是new_hashtable。这种设计称为渐进式重新哈希。它的优点是rehash操作基本不会阻塞主线程的读写,是最理想的解决方案。但渐进式rehash的实现非常复杂,因此godis采用了Golang社区广泛使用的分段锁策略(不是上面三种),即将key分散到固定数量的分片中,以避免整体的rehash操作。 Shard是一个带有锁保护的map。当分片rehash时,会阻塞该分片内的读写,但不会影响其他分片。
代码如下:
typeConcurrentDictstruct{table[]*Shardcountint32}typeShardstruct{mmap[string]接口{}mutexsync.RWMutex}func(dict*ConcurrentDict)spread(hashCodeuint32)uint32{tableSize:=uint32(len(dict.table))return(tableSize-1) uint32(hashCode)}func(dict*ConcurrentDict)getShard(indexuint32)*Shard{returndict.table[index]}func(dict*ConcurrentDict)Get(keystring)(valinterface{},existsbool){hashCode:=fnv32(key)index:=dict.spread(hashCode)shard:=dict.getShard(index)shard.mutex.RLock()defershard.mutex.RUnlock()val,exists=shard.m[key]return}func(dict*ConcurrentDict)Put(keystring ,valinterface{})(resultint){ifdict==nil{panic('dictisnil')}hashCode:=fnv32(key)index:=dict.spread(hashCode)shard:=dict.getShard(index)shard.mutex.Lock() defershard.mutex.Unlock()if_,ok:=shard.m[key];ok{shard.m[key]=valreturn0}else{shard.m[key]=valdict.addCount()return1}}ConcurrentDict可以保证并发保证了单键操作的安全性,但仍然无法满足并发安全性的要求,例如:
Incr命令需要完成:读-做加法-写三步操作,读写两步操作不是原子的。当且仅当所有给定键都不存在时,MSETNX 命令才会设置所有给定键的值。我们需要保证“检查多个key是否存在”和“写入多个key”这两个操作的原子性,所以我们需要实现db.Locker来锁定一个或一组key,直到完成所有操作然后释放它们。
实现db.Locker 最直接的想法是使用map[string]*sync.RWMutex
加锁过程分为两步:初始化互斥体——加锁和解锁过程也分为两步: 解锁——释放互斥体那么就存在一个无法解决的并发问题:
时间协程A 协程B1locker['a'].Unlock()2locker['a']=sync.RWMutex{}3delete(locker['a'])4locker['a'].Lock() 由于t3 协程B释放锁,协程A 在t4 尝试加锁将会失败。如果协程B在解锁时不执行delete(locker['a']),则可以避免该异常,但这会导致严重的内存泄漏。
我们注意到哈希槽的数量远小于键的数量。反过来,多个键可以共享一个哈希槽。因此,我们不再直接锁定密钥,而是锁定密钥所在的哈希槽以确保安全。另一方面,哈希槽数量较少,即使不释放也不会消耗太多内存。
类型锁结构{
table []*sync.RWMutex } func Make(tableSize int) *Locks { table := make([]*sync.RWMutex, tableSize) for i := 0; i < tableSize; i++ { table[i] = &sync.RWMutex{} } return &Locks{ table: table, } } func (locks *Locks)Lock(key string) { index := locks.spread(fnv32(key)) mu := locks.table[index] mu.Lock() } func (locks *Locks)UnLock(key string) { index := locks.spread(fnv32(key)) mu := locks.table[index] mu.Unlock() } 在锁定多个 key 时需要注意,若 协程A 持有 键a 的锁试图获得 键b 的锁,此时 协程B 持有 键b 的锁试图获得 键a 的锁则会形成死锁。 解决方法是所有协程都按照相同顺序加锁,若两个协程都想获得 键a 和 键b 的锁,那么必须先获取 键a 的锁后获取 键b 的锁,这样就可以避免循环等待。 到目前为止构建 Redis 服务器所需的基本组件已经备齐,只需要将 TCP 服务器、协议解析器与哈希表组装起来我们的 Redis 服务器就可以开始工作啦。 最后,以上代码均简化自我写的 Godis:一个开源仅用 Go 语言实现的 Redis 服务器。期待您的关注和 Star: 项目地址:https://github.com/HDT3213/godis四、结束
很多朋友的日常工作主要是编写业务代码,对于框架、数据库、中间件这些“架构”、“底层代码” 有一些恐惧感。 但本文我们只写了 3 个组件,共计几百行代码就实现了一个基本的 Redis 服务器。所以底层的技术并不难,只要你对技术感兴趣由浅入深、从简到繁,“底层代码”也并不神秘。好了,文章到这里就结束啦,如果本次分享的徒手用 Go 写个 Redis 服务器-go操作redis和问题对您有所帮助,还望关注下本站哦!
本文采摘于网络,不代表本站立场,转载联系作者并注明出处:https://www.iotsj.com//kuaixun/8011.html
用户评论
太酷了! 用 Go 写 Redis 服务器,这得多厉害啊!
有18位网友表示赞同!
我感觉这是一个非常有趣的挑战,想看看结果如何。
有16位网友表示赞同!
项目分享这种原创的代码总是很让人惊喜。
有9位网友表示赞同!
Go 的并发能力在这方面肯定很有优势,运行起来效率高吗?
有18位网友表示赞同!
作者会不会也提供一些测试用例和示例用法呢?
有15位网友表示赞同!
我之前用过 Redis,知道它有多实用,自己写一个太厉害了!
有10位网友表示赞同!
学习 Go 的大好机会,直接看项目代码理解原理吧!
有7位网友表示赞同!
希望能看到一些性能测试数据,对比一下和现有的 Redis 方案。
有9位网友表示赞同!
这种徒手实现是不是很考验对数据结构和算法的掌握?
有20位网友表示赞同!
要是能在网络上部署起来,那岂不是很有意思?
有14位网友表示赞同!
会不会太难了?不过想尝试看看!
有10位网友表示赞同!
我有点不太懂 Redis 的机制,能不能简单解释一下呢?
有20位网友表示赞同!
期待看到最终的成果,看一看这种 Go 实现的 Redis 能达到什么效果!
有18位网友表示赞同!
太酷了! 用 Go 写 Redis 服务器,这得多厉害啊!
有6位网友表示赞同!
我感觉这是一个非常有趣的挑战,想看看结果如何。
有13位网友表示赞同!
项目分享这种原创的代码总是很让人惊喜。
有15位网友表示赞同!
Go 的并发能力在这方面肯定很有优势,运行起来效率高吗?
有9位网友表示赞同!
作者会不会也提供一些测试用例和示例用法呢?
有14位网友表示赞同!
我之前用过 Redis,知道它有多实用,自己写一个太厉害了!
有10位网友表示赞同!
学习 Go 的大好机会,直接看项目代码理解原理吧!
有13位网友表示赞同!
希望能看到一些性能测试数据,对比一下和现有的 Redis 方案。
有20位网友表示赞同!
这种徒手实现是不是很考验对数据结构和算法的掌握?
有12位网友表示赞同!
要是能在网络上部署起来,那岂不是很有意思?
有13位网友表示赞同!
会不会太难了?不过想尝试看看!
有6位网友表示赞同!
我有点不太懂 Redis 的机制,能不能简单解释一下呢?
有12位网友表示赞同!
期待看到最终的成果,看一看这种 Go 实现的 Redis 能达到什么效果!
有18位网友表示赞同!