日常开发分享 golang字符串切片,字符字节问题踩坑

2023年 8月 5日 26.2k 0

字符串和切片,都是golang常用的两种内置数据类型。最近在日常工作中,遇到了一个字符串切片导致的问题,记录一下排查问题的过程,避免后续在这种场景上踩坑

背景

在项目中,我们使用mysql来存储数据信息,其中label表记录了标签相关的信息。表结构如下:

CREATE TABLE `label` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(190) COLLATE utf8mb4_unicode_ci DEFAULT 'label name',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_n` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=7050965 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='label'

其中name字段为varchar类型,190代表字符长度,并且是唯一索引

为什么name字段设置最大字符长度为190?

name是varchar类型,并且是唯一索引。mysql规定varchar类型为索引时,最大长度为767字节

name字段的编码格式为utf8mb4_unicode_ci,一个字符最多用4个字节来表示,767 / 4 = 191.75,所以只需限制最大字符长度小于191.75即可,项目里取190作为限制

业务代码逻辑很简单,主要有两步:

  • 依据name查询label表,如果查询到了label信息,则直接返回
  • 如果没有查询到label信息,则尝试创建label信息
  • 伪代码:

    const (
       // 最大字符长度
       LabelNameLengthLimit = 190
    )
    
    func GetOrCreateLabel(ctx context.Context, name string) (label dao.Label, err error) {
       var err error
       // 依据name查询label信息
       label, err = db_reader.GetLabel(name)
       // 没有找到对应的label信息,且字符串长度超过190,则切片后再次查询
       if IsRecordNotFoundError(err) && len(name) > LabelNameLengthLimit {
          label, err = db_reader.GetLabel(name[:LabelNameLengthLimit])
       }
       // 还是报错,则尝试创建label信息
       if err != nil {
          label.Name = name
          err = db_writer.CreateLabel(ctx, &label)
          // 报唯一键冲突错误,可能是由于并发创建导致的问题,再次兜底进行查询
          if IsKeyConflict(err) {
             label, err = db_reader.GetLabel(ctx, name)
             if err != nil {
                return label, err
             }
          }
          if err != nil {
             return label, err
          }
       }
       return label, nil
    }
    

    问题

    部分case,首次执行业务代码成功,后续执行业务代码一直报错

    case1:

    labelName := "© 2015 Charanga Sí o Ké - Chema Muñoz, Perfecto Artola, Pablo Guerrero, Antonio Flores, Los Gipsy King, Chambao, Adele, La Pegatina, El Chaval de la Peca, Los Manolos © 2015 Charanga Sí o Ké - Sones de Rumba"
    

    执行:

  • 首次执行业务代码,执行成功
  • 后续执行业务代码,22行稳定复现报错
  • 分析

    后续执行业务代码时

    • 依据name,一直查询不到对应的label信息,err报错RecordNotFoundError
    • 依据name[:LabelNameLengthLimit],一直查询不到对应的label信息,err报错RecordNotFoundError
    var err error
    // 依据name查询label信息
    label, err = db_reader.GetLabel(name)
    // 没有找到对应的label信息,且字符串长度超过190,则切片后再次查询
    if IsRecordNotFoundError(err) && len(name) > LabelNameLengthLimit {
       label, err = db_reader.GetLabel(name[:LabelNameLengthLimit])
    }
    

    err != nil时,尝试创建label信息,此时报唯一键冲突错误

    线上可能是并发创建导致的唯一键冲突,兜底查询一次,此时查询还是报错RecordNotFoundError

    err = db_writer.CreateLabel(ctx, &label)
    // 报唯一键冲突错误,可能是由于并发创建导致的问题,再次兜底进行查询
    if IsKeyConflict(err) {
       label, err = db_reader.GetLabel(ctx, name)
       if err != nil {
          return label, err
       }
    }
    

    由于字符长度超过了190,定位到主要的问题是:依据切片后name[:LabelNameLengthLimit]查询时,没有查询到结果,但是依据name去创建label信息时,报唯一键冲突,说明查询的值和实际存储的值不一致

    golang string底层实现go/src/reflect/value.go

    // StringHeader is the runtime representation of a string.
    // It cannot be used safely or portably and its representation may
    // change in a later release.
    // Moreover, the Data field is not sufficient to guarantee the data
    // it references will not be garbage collected, so programs must keep
    // a separate, correctly typed pointer to the underlying data.
    type StringHeader struct {
       Data uintptr
       Len  int
    }
    

    其中:

    • Data 指向底层的[]byte的首地址,string底层其实是[]byte
    • Len 代表字节切片的长度,避免多次获取字符串长度时,重复计算
    • 内置函数len(string),获取的是字符串字节长度
    • 对字符串进行切片labelName[:LabelNameLengthLimit],是按照字节数进行切片

    所以问题就是:查询和存储时,截取字符串的标准不一样

    • 当字符串字符长度超过190时,查询时是按照字节进行截取
    • 当字符串字符长度超过190时,存储时是按照字符进行截取

    测试代码:

    labelName := "© 2015 Charanga Sí o Ké - Chema Muñoz, Perfecto Artola, Pablo Guerrero, Antonio Flores, Los Gipsy King, Chambao, Adele, La Pegatina, El Chaval de la Peca, Los Manolos © 2015 Charanga Sí o Ké - Sones de Rumba"
    fmt.Println("原字符串内容:", labelName)
    fmt.Println("字节长度:", len(labelName))
    fmt.Println("按照字节截取内容:", labelName[:LabelNameLengthLimit])
    fmt.Println("字符长度:", len([]rune(labelName)))
    fmt.Println("按照字符截取内容:", string([]rune(labelName)[:LabelNameLengthLimit]))
    

    输出:

    原字符串内容: © 2015 Charanga Sí o Ké - Chema Muñoz, Perfecto Artola, Pablo Guerrero, Antonio Flores, Los Gipsy King, Chambao, Adele, La Pegatina, El Chaval de la Peca, Los Manolos © 2015 Charanga Sí o Ké - Sones de Rumba
    字节长度: 214
    按照字节截取内容: © 2015 Charanga Sí o Ké - Chema Muñoz, Perfecto Artola, Pablo Guerrero, Antonio Flores, Los Gipsy King, Chambao, Adele, La Pegatina, El Chaval de la Peca, Los Manolos © 2015 Charanga S�
    字符长度: 207
    按照字符截取内容: © 2015 Charanga Sí o Ké - Chema Muñoz, Perfecto Artola, Pablo Guerrero, Antonio Flores, Los Gipsy King, Chambao, Adele, La Pegatina, El Chaval de la Peca, Los Manolos © 2015 Charanga Sí o Ké
    

    解决

    解决方案:

  • 先把string转rune切片,使用字符切片来表示字符串
  • 判断rune切片长度是否超过限制,超过则依据字符长度进行切片
  • const (
       // 最大字符长度
       LabelNameLengthLimit = 190
    )
    
    func GetOrCreateLabel(ctx context.Context, name string) (label dao.Label, err error) {
       if rs := []rune(name); len(rs) > LabelNameLengthLimit {
          name = string(rs[:LabelNameLengthLimit])
       }
       
       var err error
       // 依据name查询label信息
       label, err = db_reader.GetLabel(name)
       // 还是报错,则尝试创建label信息
       if err != nil {
          label.Name = name
          err = db_writer.CreateLabel(ctx, &label)
          // 报唯一键冲突错误,可能是由于并发创建导致的问题,再次兜底进行查询
          if IsKeyConflict(err) {
             label, err = db_reader.GetLabel(ctx, name)
             if err != nil {
                return label, err
             }
          }
          if err != nil {
             return label, err
          }
       }
       return label, nil
    }
    

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论