mini-redis复刻Redis的INCR指令

写在文章开头

因为近期比较忙碌,所以对于mini-redis的复刻基本处于一些指令向的完善,而本文将针对字符串操作中介绍笔者近期所复刻的键值自增指令的落地思路,以帮助读者更好的理解和学习mini-redis

Hi,我是 sharkChili ,是个不断在硬核技术上作死的技术人,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

同时也非常欢迎你star我的开源项目mini-redis:github.com/shark-ctrl/...

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。

详解incr和decr指令的复刻思路

对象类型前置校验

因为指令是基于字符串操作的,所以在执行INCR或者DECR之前我们都必须针对入参的键值对进行校验,所以对于以下情况,我们都必须采用fail-fast的方式提前将失败暴露,将键值对已存在,对应的值非字符串类型(例如:字典类型),直接响应错误:

基于上述的基本概念,我们给出落地的代码,即位于command.goincrDecrCommand方法,可以看到我们会优先到redis内存中查看是否存在对应的key,如果存在则进行必要的类型判断,如果非字符串类型即REDIS_STRING则直接响应错误出去,并直接返回:

go 复制代码
func incrDecrCommand(c *redisClient, incr int64) {
 var value int64
 var oldValue int64
 var newObj *robj
 //查看键值对是否存在
 o := lookupKeyWrite(c.db, c.argv[1])
 //如果键值对存在且类型非字符串类型,直接响应错误并返回
 if o != nil && checkType(c, o, REDIS_STRING) {
  return
 }
 
 
 //......

}

对此我们也给出checkType的内部逻辑,可以看到当比对类型不一致时会直接输出错误并返回true,读者可以参考注释了解:

go 复制代码
func checkType(c *redisClient, o *robj, rType int) bool {
 //如果类型不一致,则输出-WRONGTYPE Operation against a key holding the wrong kind of value
 if o.robjType != rType {
  addReply(c, shared.wrongtypeerr)
  return true
 }
 return false
}

其实笔者这里也想吐槽一句redis对于函数设计的语义的不恰当性,理论性合理的函数进行校验时正确的做法应该是:

  1. 逻辑校验失败,输出错误返回false。
  2. 逻辑校验正确,返回true。

也只能说因为某些历史原因,或者设计者有着自己的主观编码习惯吧,本着一比一的复刻理念,笔者也沿袭了这样的编码思路。

基于数值池高效完成字符串转换

针对字符串类型(可以转数值的情况下,它也会转数值类型),我们都是通过robj类型创建和维护,因为我们本次所复刻的incr和decr所操作的类型是字符串中可转为数值的对象,所以本着数值类型有迹可循的规律以及空间换时间的思想,我们提出池化思想,即将0-9999数值缓存一份数值池,后续的增减操作后处于该范围的数值都可以直接使用数值池里对应的robj对象,以节约robj对象创建的开销和非必要的内存资源占用:

所以笔者在main.go中声明sharedObjectsStruct 这个结构体中声明了一个integers维护常量池的robj对象:

go 复制代码
type sharedObjectsStruct struct {
 //......
 integers       [REDIS_SHARED_INTEGERS]*robj //通用0~9999常量数值池
 //......
}

然后在createSharedObjects方法中完成初始化,后续就可以直接使用了:

less 复制代码
func createSharedObjects() {
 //......

 var i int64
 //初始化常量池对象
 for i = 0; i < REDIS_SHARED_INTEGERS; i++ {
  //基于接口封装数值
  num := interface{}(i)
  //生成string对象
  shared.integers[i] = createObject(REDIS_STRING, &num)
  //声明编码类型为int
  shared.integers[i].encoding = REDIS_ENCODING_INT
 }

 //......
}

于是我们就得出了后续的编码逻辑:

  1. 将value强转为数值判断是否超出范围,如果超了则抛出异常。反之进入步骤2。
  2. 查看取值范围是否大于10000,如果是则自己生成robj对象,反之采用池化数值池的robj。
  3. 基于1、2生成的数值对象将键值对更新或者覆盖到内存数据库中。
scss 复制代码
/**
 针对字符串类型的值进行如下判断的和转换:
 1. 如果为空,说明本次的key不存在,直接初始化一个空字符串,后续会直接初始化一个0值使用
 2. 如果是字符串类型,则转为字符串类型
 3. 如果是数值类型,则先转为字符串类型进行后续的通用数值转换操作保证一致性
 */
 var s string
 if o == nil {
  s = ""
 } else if isString(*o.ptr) {
  s = (*o.ptr).(string)
 } else {
  s = strconv.FormatInt((*o.ptr).(int64), 10)
 }
 //进行类型强转为数值,如果失败,直接输出错误并返回
 if getLongLongFromObjectOrReply(c, s, &value, nil) != REDIS_OK {
  return
 }

 oldValue = value
 //如果累加超范围则报错
 if (incr < 0 && oldValue < 0 && incr < (math.MinInt64-oldValue)) ||
  (incr > 0 && oldValue > 0 && incr > (math.MaxInt64-oldValue)) {
  errReply := "increment or decrement would overflow"
  addReplyError(c, &errReply)
  return
 }
 //基于incr累加的值生成value
 value += incr
 //如果超常量池范围则封装一个对象使用 
 if o != nil &&
  (value < 0 || value >= REDIS_SHARED_INTEGERS) &&
  (value > math.MinInt64 || value < math.MaxInt64) {
  newObj = o

  i := interface{}(value)
  o.ptr = &i
 } else if o != nil {//如果对象存在,且累加结果没超范围则调用createStringObjectFromLongLong获取常量对象
  newObj = createStringObjectFromLongLong(value)
  //将写入结果覆盖
  dbOverwrite(c.db, c.argv[1], newObj)
 } else {//从常量池获取数值,然后添加键值对到数据库中
  newObj = createStringObjectFromLongLong(value)
  dbAdd(c.db, c.argv[1], newObj)
 }

通用结果响应

完成上述操作后就是将结果按照RESP协议规范将结果响应给客户端,按照协议要求数值类型必须用:号开头,所以假设我们累加结果为10,那么响应给客户端的结果就是10\r\n

对应我们的给出最后的代码段:

scss 复制代码
//将累加后的结果返回给客户端,按照RESP格式即 :数值\r\n,例如返回10 那么格式就是:10\r\n
 reply := *shared.colon + strconv.FormatInt(value, 10) + *shared.crlf
 addReply(c, &reply)

完整的代码实现

我们来小结一下上述的实现思路:

  1. 键值对查询与校验。
  2. 数值类型转换与越界判断。
  3. 字符串类型强转并基于取值范围查看是否通过数值池获取。
  4. 更新或覆盖键值对。
  5. 将操作结果返回客户端。

完整代码如下:

go 复制代码
func incrDecrCommand(c *redisClient, incr int64) {
 var value int64
 var oldValue int64
 var newObj *robj
 //查看键值对是否存在
 o := lookupKeyWrite(c.db, c.argv[1])
 //如果键值对存在且类型非字符串类型,直接响应错误并返回
 if o != nil && checkType(c, o, REDIS_STRING) {
  return
 }
 /**
 针对字符串类型的值进行如下判断的和转换:
 1. 如果为空,说明本次的key不存在,直接初始化一个空字符串,后续会直接初始化一个0值使用
 2. 如果是字符串类型,则转为字符串类型
 3. 如果是数值类型,则先转为字符串类型进行后续的通用数值转换操作保证一致性
 */
 var s string
 if o == nil {
  s = ""
 } else if isString(*o.ptr) {
  s = (*o.ptr).(string)
 } else {
  s = strconv.FormatInt((*o.ptr).(int64), 10)
 }
 //进行类型强转为数值,如果失败,直接输出错误并返回
 if getLongLongFromObjectOrReply(c, s, &value, nil) != REDIS_OK {
  return
 }

 oldValue = value

 if (incr < 0 && oldValue < 0 && incr < (math.MinInt64-oldValue)) ||
  (incr > 0 && oldValue > 0 && incr > (math.MaxInt64-oldValue)) {
  errReply := "increment or decrement would overflow"
  addReplyError(c, &errReply)
  return
 }
 //基于incr累加的值生成value
 value += incr
 //如果超常量池范围则封装一个对象使用
 if o != nil &&
  (value < 0 || value >= REDIS_SHARED_INTEGERS) &&
  (value > math.MinInt64 || value < math.MaxInt64) {
  newObj = o

  i := interface{}(value)
  o.ptr = &i
 } else if o != nil { //如果对象存在,且累加结果没超范围则调用createStringObjectFromLongLong获取常量对象
  newObj = createStringObjectFromLongLong(value)
  //将写入结果覆盖
  dbOverwrite(c.db, c.argv[1], newObj)
 } else { //从常量池获取数值,然后添加键值对到数据库中
  newObj = createStringObjectFromLongLong(value)
  dbAdd(c.db, c.argv[1], newObj)
 }
 //将累加后的结果返回给客户端,按照RESP格式即 :数值\r\n,例如返回10 那么格式就是:10\r\n
 reply := *shared.colon + strconv.FormatInt(value, 10) + *shared.crlf
 addReply(c, &reply)

}

递增递减的复用

基于上述函数对应的递增指令INCR就使用incrCommand,入参传1代表加1,而decrCommand则传-1扣减即可:

scss 复制代码
func incrCommand(c *redisClient) {
 //累加1
 incrDecrCommand(c, 1)
}

func decrCommand(c *redisClient) {
 //递减1
 incrDecrCommand(c, -1)
}

最终效果演示

最后,我们将服务启动进行测试,可以看到指令正常执行:

makefile 复制代码
127.0.0.1:6379> incr k1
(integer) 1
(4.50s)
127.0.0.1:6379> incr k1
(integer) 2
127.0.0.1:6379> incr k1
(integer) 3
127.0.0.1:6379> incr k1
(integer) 4
127.0.0.1:6379> incr k1
(integer) 5
127.0.0.1:6379> incr k1
(integer) 6
127.0.0.1:6379> decr k1
(integer) 5
127.0.0.1:6379> decr k1
(integer) 4
127.0.0.1:6379> decr k1
(integer) 3
127.0.0.1:6379> decr k1
(integer) 2
127.0.0.1:6379> decr k1
(integer) 1
127.0.0.1:6379> decr k1
(integer) 0
127.0.0.1:6379> decr k1
(integer) -1
127.0.0.1:6379>

小结

我是 sharkchiliCSDN Java 领域博客专家mini-redis 的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili

同时也非常欢迎你star我的开源项目mini-redis:github.com/shark-ctrl/...

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。

参考

《redis设计与实现》

本文使用 markdown.com.cn 排版

相关推荐
IsPrisoner2 小时前
Go语言安装proto并且使用gRPC服务(2025最新WINDOWS系统)
开发语言·后端·golang
tan180°3 小时前
Linux进程信号处理(26)
linux·c++·vscode·后端·信号处理
有梦想的攻城狮4 小时前
spring中的@MapperScan注解详解
java·后端·spring·mapperscan
柚个朵朵5 小时前
Spring的Validation,这是一套基于注解的权限校验框架
java·后端·spring
Asus.Blogs6 小时前
为什么go语言中返回的指针类型,不需要用*取值(解引用),就可以直接赋值呢?
开发语言·后端·golang
C_V_Better6 小时前
Java Spring Boot 控制器中处理用户数据详解
java·开发语言·spring boot·后端·spring
胡子洲6 小时前
Spring Boot 应用中实现基本的 SSE 功能
java·spring boot·后端
贰拾wan6 小时前
【Java-EE进阶】SpringBoot针对某个IP限流问题
java·spring boot·后端·idea
Paran-ia7 小时前
【2025版】Spring Boot面试题
java·spring boot·后端