重构即时IM项目12:优化消息通路(上)

5 阅读19分钟

消息唯一Uuid生成

消息在客户端会生成一个唯一的Uuid,这个Uuid是使用Uuidv4算法生成的128位随机,服务端收到上行之后就会根据消息的Uuid进行去重处理,不是重复请求的话就把消息落库然后回复ACK。但是之前消息落库时是这样的:

m := model.Message{
    Uuid:      msgCmd.Uuid,
    SessionId: msgCmd.ReceiverId,
    Seq:       seq,
    Type:      int8(msgCmd.Type),
    Content:   msgCmd.Content,
    SendId:    req.Uid,
    ReceiveId: msgCmd.ReceiverId,
    Status:    1,
}

此时消息表中的

Id         int64        `gorm:"column:id;primaryKey;comment:自增id"`

是单机自增的主键ID,这是重构之前的单机架构中实现的,但是我们如果有多台StateServer服务的话,那么多个服务都像单机Mysql请求自增主键ID并且插入,在高并发场景下,数据库的行锁和IO会成为严重的性能瓶颈,而且每生成一次ID都需要经过数据库,通信复杂度上升,如果对应数据库宕机的话,整个系统的写入操作将全部瘫痪。在分布式系统中,更严重的问题是数据库自增主键ID无法实现水平拓展,如果你有两个独立的数据库节点 A 和 B。如果没有全局协调,它们都会从 1 开始自增,导致主键冲突,无法进行数据合并或关联查询。虽然可以通过“设置步长”(如 A 节点生成奇数,B 节点生成偶数)来规避,但这种方案非常僵化,一旦需要增加第三个节点,扩容极其困难。

所以我们不应该使用单机自增主键ID作为消息的ID,应该使用分布式ID生成算法来给消息生成唯一ID,这样的话分配消息ID就可以不经过数据库了,可以先落到Kafka中,再异步落库。我们考虑是否可以使用客户端一样的Uuidv4算法生成,答案是否定的,因为客户端使用Uuidv4生成只是为了给消息一个唯一标识,方便接收ACK,维护发送队列状态。但是服务端生成的消息唯一ID是需要落库的,如果使用128位的完全随机ID的话会造成页分裂和随机IO的问题。所以我们考虑使用递增的分布式ID生成算法,就是在雪花算法和号段模式中进行抉择。

对于雪花算法和号段模式的取舍本质上还是在对时钟依赖和数据库依赖之中做权衡。雪花算法可以直接在本地生成,不需要网络调用,而且由于ID的高位是时间,所以消息天然具备了有序性,在数据库中按 ID 排序基本等同于按时间排序,而且使用雪花算法不依赖数据库。但是雪花算法有时钟回拨问题,如果服务器时间因为 NTP 同步或其他原因倒退,可能会生成重复 ID,导致消息系统崩溃。

号段模式的优点就是不依赖时钟,完全不用担心时钟回拨,而且使用号段模式分配的ID更加密集,对数据库索引的填充率更高。缺点就是依赖外部存储,如果数据库不可用且本地号段耗尽,服务将不可用。由于ID用完之后就需要去外部存储拿新的号段,所以还需要通信开销,这个可以通过异步预取优化。

在此我们使用雪花算法作为消息唯一标识,因为在 IM 中,我们经常需要查询“某条消息之后的消息”或“最新的历史记录”。雪花算法生成的ID高位就是时间戳,可以直接体现消息顺序。但是号段模式ID 大小与时间不严格对应,刚刚启动的新机器领到了较大的号段,发出的消息 ID 很大,而老机器还在使用比较小的号段,结果就是新消息的 ID 可能比旧消息小 。这会导致按 ID 排序拉取历史消息时,顺序是乱的。

而且IM的写压力具备爆发性,比如说节日祝福,热点事件群聊这种。雪花算法是纯内存运算,纳秒级生成,单机生成ID的QPS上限高,但是号段模式虽然有缓存,但是仍然需要定期服务数据库,就算做了异步预取优化还是有通信开销。而且使用雪花算法架构简单,无状态,不需要额外部署分发ID的服务器。

下面是修改后的代码:

// 生成分布式 ID (Snowflake)
id := s.snowflake.Generate().Int64()
m := model.Message{
    Id:        id,
    SessionId: msgCmd.ReceiverId,
    Seq:       seq,
    Type:      int8(msgCmd.Type),
    Content:   msgCmd.Content,
    SendId:    req.Uid,
    ReceiveId: msgCmd.ReceiverId,
    Status:    1,
}
if err = s.store.Save(ctx, m); err != nil {
    log.Printf("[StateServer] Persist failed uuid=%s: %v", msgCmd.Uuid, err)
    status = protocol.MessageAckStatus_ACK_RETRY
}

注意此时uuid不需要存储,因为消息的Uuid主要作用是防止客户端重试导致的消息重复,这在Redis层就已经通过SetNx被拦截了,落库是幂等校验 通过后 才发生的动作,所以数据库不需要再存 UUID 来做去重,而且后续的消息查询基本都是基于时间戳排序的,而Uuid是客户端随机生成的,对业务无作用。

ID分配器优化

经过之前的讨论,我们知道了服务端需要维护会话内消息的时序一致性,所以需要为每条消息分配会话内单调递增的ID,在上一节的暴力实现中,ID分配器就是单纯的对ConvSeq:%s", conversationID这个进行自增,然后分配给对应消息。

这种纯内存自增的方法有个缺点,就是Redis节点宕机之后可能会有数据丢失,因为AOF或者RDB持久化是异步的过程,Redis重启之后Seq可能会出现回跳,此时就会导致SeqID重复,引发客户端逻辑错误。而且在Redis主从部署时,如果Redis主节点宕机了,集群提升从节点为新的主节点,由于 Redis 主从复制是异步的,Slave 可能还没同步到最新的 Seq。Slave 上位后,会发放旧的 Seq,同样导致重复。

那我们怎么解决Redis中Seq回退的问题呢,我们可以检测Redis是否宕机和发生主从切换。具体来说,就是每个Redis在启动的时候都生成一个随机的RunningID,客户端使用Lua脚本的方式请求Redis,Lua脚本内部逻辑是检查runningID是否等于当前Redis的RunningID,如果相等的话就可以直接++,如果不相等的话就加一个大数,强制跳跃,比如加1000,此时就可以避免Seq重复了。

但是上面方法还是有问题,每次调用时使用Lua开销大,首先就是网络传输数据的增加,再者就是Lua脚本在执行时会阻塞其他请求,好在这个Lua脚本的逻辑不是很复杂,但是如果并发量大的话还是会显著降低Redis的吞吐量。发现RunningID不一致之后的++步长设置也有考究,如果Redis每秒刷盘一次,那么步长可以设置为高峰期QPS比如说5000,如果设置为1000的话还是会造成SeqID重复。而且主节点宕机之后,如果所有服务端都请求新的主节点分配SeqID,那么此时这100个请求会同时发起 EVAL 脚本,每个脚本都执行 INCRBY 1000 ,造成ID的大幅跳变。

其实问题的根本原因就是Redis为了追求性能,做的是单主异步复制,而且宕机内刷盘也是异步刷盘,所以宕机时没有什么可靠性而言,因此,我们需要使用另一种方式:基于Mysql的号段模式。数据库可以配置半同步日志来保证主从切换时数据库的强一致性,绝对不会丢失已经提交的事务。我们在Mysql中建立一张Session_Seq表,用于记录每个会话当前分配到的最大SeqID,Go 服务端不再每次发消息都去访问数据库,而是维护一个本地内存缓冲区。当服务端内存里的 ID 用完时,就去数据库取一批ID过来,比如1000-2000,服务端拿到之后就知道自己有了这1000个ID的专属权,所以可以直接在内存里面分配即可。

如果此时服务端宕机的话,内存里没用完的 ID就直接丢弃,重启之后再去请求数据库,虽然有空洞但是保证了ID的单调递增;如果数据库宕机的话,因为有本地内存缓冲,即使 MySQL 挂了,服务还能利用剩下的库存继续发消息,而且只要配置了半同步复制,表内的max_seq依旧是准确的。

但是引入了号段模式之后,网关层必须配合实现会话粘滞策略,这是因为在分布式环境下,如果有多个StateServer实例同时运行,且不做粘滞路由,会出现致命的消息乱序问题:假设 Server A 申请到了号段 [1001, 2000] ,Server B 申请到了号段 [2001, 3000] 。如果同一个会话的消息没有被固定路由到同一台机器,而是轮询分发。用户发送的第一条消息 Msg1 被路由到 Server B,分配了 Seq 2001 ,用户毫秒级内发送的第二条消息 Msg2 被路由到 Server A,分配了 Seq 1001 。这就导致了虽然 ID 是唯一的,但在客户端按 Seq 排序后,后发的 Msg2 会排在先发的 Msg1 前面,导致对话逻辑彻底错乱。

解决方法也很简单,就是网关层不把消息分配到不同的StateServer就可以了,也就是基于会话ID进行一致性hash路由。确保同一个会话内的所有请求,在Server A存活期间永远只打到ServerA,这样 Server A 就可以利用本地内存中的计数器,严格按照接收顺序分配,从而保证了会话内消息的 严格时序一致性。同时,这种粘滞设计还能最大化利用本地号段缓存,避免多台机器同时申请号段造成的 ID 浪费和稀疏空洞。

但是这种使用一致性哈希去做粘滞的方法也有缺点,首先就是负载不均衡的问题,一致性hash是基于会话ID分布的,但是此时业务流量通常不是均匀分布,因为某个大群可能分配给了Server A,其他小群分配给了Server B。导致 Server A 的 CPU 和带宽瞬间被打满,甚至宕机,而 Server B 却负载极低,这种流量倾斜是哈希算法本身无法感知的。其次是故障和扩容相关问题,当 Server A 因过载宕机时,根据一致性 Hash 的规则,原本由它承担的所有会话流量会瞬间全部转移到 Hash 环上的下一个节点(例如 Server B)。这会导致 Server B 突然承接双倍流量,极大概率也扛不住而随之宕机,从而引发雪崩效应,导致整个集群像多米诺骨牌一样逐个倒下。

尽管一致性哈希有上述缺点,我们依然选择使用,因为保证IM场景中会话时序的一致性优先级是最高的,关乎于IM项目的正确性。而且上面的问题也有解决办法,解决负载不均衡的问题可以引入虚拟节点,不再将一台物理机器映射为 Hash 环上的一个点,而是映射为 100~200 个虚拟点,这样的话可以打散热点,数据在物理机之间的分布更加均匀,标准差显著降低。

下面是ID分配器修改后的代码实现:

type MySQLAllocator struct {
	db      *gorm.DB
	buffers sync.Map // key: sessionId, value: *SeqBuffer
	step    int      // 默认步长
}

type SeqBuffer struct {
	mu      sync.Mutex
	current int64 // 当前已分配到的值
	max     int64 // 当前号段的最大值 (上限)
}

分配器中使用Map存储是因为每个会话的Seq不一样,SeqBuffer是用户请求Mysql之后分配到本地的序列号缓冲区,在max之前的序列号都是当前Server私有的。

func (a *MySQLAllocator) NextSeq(ctx context.Context, sessionID string) (int64, error) {
	// 获取或创建本地 buffer
	val, _ := a.buffers.LoadOrStore(sessionID, &SeqBuffer{})
	buffer := val.(*SeqBuffer)

	buffer.mu.Lock()
	defer buffer.mu.Unlock()

	// 检查 buffer 是否还有剩余
	if buffer.current < buffer.max {
		buffer.current++
		return buffer.current, nil
	}

	// 内存用尽,去 MySQL 申请新号段
	nextMax, err := a.renewSegment(ctx, sessionID)
	if err != nil {
		return 0, fmt.Errorf("renew segment failed: %w", err)
	}

	// 更新 buffer
	buffer.max = nextMax
	buffer.current = nextMax - int64(a.step) + 1
	return buffer.current, nil
}

这个方法就是先检查本地缓冲区分配的序列号用完了没有,如果用完了的话就去Mysql申请新号段,然后更新本地ID缓冲区。

func (a *MySQLAllocator) renewSegment(ctx context.Context, sessionID string) (int64, error) {
	var seq model.SessionSeq
	err := a.db.Transaction(func(tx *gorm.DB) error {
		// 尝试查找记录
		err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
			Where("session_id = ?", sessionID).
			First(&seq).Error

		if err == gorm.ErrRecordNotFound {
			// 如果不存在,插入初始记录
			seq = model.SessionSeq{
				SessionId: sessionID,
				MaxSeq:    int64(a.step), // 第一次分配 [1, step]
				Step:      a.step,
			}
			if createErr := tx.Create(&seq).Error; createErr != nil {
				return createErr
			}
			return nil
		} else if err != nil {
			return err
		}

		// 3. 如果存在,更新 MaxSeq
		seq.MaxSeq += int64(a.step)
		seq.Step = a.step // 确保数据库记录与当前配置一致
		if updateErr := tx.Save(&seq).Error; updateErr != nil {
			return updateErr
		}
		return nil
	})

	if err != nil {
		return 0, err
	}
	return seq.MaxSeq, nil
}

这个方法就是去数据库按照步长获取一批序列号,然后更新数据库关于这个会话相关的行,返回Server获取到的最大序列号,不需要返回这一批序列号中最小的,因为Mysql是按照Server请求的步长分配的。

下面是Mysql中分配会话递增ID的表结构:

type SessionSeq struct {
    SessionId string    `gorm:"column:session_id;primaryKey;type:varchar(64);not null;comment:会话ID"`
    MaxSeq    int64     `gorm:"column:max_seq;not null;default:0;comment:当前最大序列号"`
    Step      int       `gorm:"column:step;not null;default:1000;comment:步长"`
    UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime;comment:更新时间"`
}

一致性哈希会话粘滞

要实现根据会话ID粘滞StateServer,首先需要实现StateServer在Etcd内进行服务注册,逻辑也非常简单,和之前网关层的服务注册差不多:

func (r *StateRegister) Register(ctx context.Context) error {
    resp, err := r.client.Grant(ctx, r.ttl)
    if err != nil {
        return err
    }
    r.leaseID = resp.ID

    key := StatePrefix + r.addr
    _, err = r.client.Put(ctx, key, r.addr, clientv3.WithLease(r.leaseID))
    if err != nil {
        return err
    }

    log.Printf("Registered state node %s with leaseID %x", key, r.leaseID)

    ch, err := r.client.KeepAlive(ctx, r.leaseID)
    if err != nil {
        return err
    }

    go func() {
        for {
            select {
                case <-ctx.Done():
                return
                case _, ok := <-ch:
                if !ok {
                    log.Printf("KeepAlive channel closed for state node %s", key)
                    return
                }
            }
        }
    }()

    return nil
}

这段代码首先申请一个租约,再将kv对绑定在这个租约上,然后再开启一个协程定期续约。

实现了服务注册之后就可以实现一致性哈希了,核心思路就是获取注册到Etcd中所有StateServer的路由地址,然后把这些地址映射到一个虚拟hash环上。具体来说,对于每一个发现的真实StateServer的地址,我们通过追加序号的方式生成100个虚拟节点,(例如 127.0.0.1:9000#0 , ...#1 , ...#99 )。然后对每个虚拟节点名称计算CRC哈希值,将这 100 个 Hash 值追加到一个 uint32 类型的数组 ring 中,对 ring 数组进行升序排序,使其成为一个逻辑上的环,同时维护一个 map[uint32]string ,记录每个虚拟节点的 Hash 值对应哪个真实的 State Server 地址。

当网关需要转发一个SessionID时,先使用同样的CRC算法计算SessionID的哈希值,在有序的 ring 数组中,使用 二分查找 找到第一个 大于等于该 Hash 值的虚拟节点位置,拿到虚拟节点的 Hash 值后,去 Map 里查出它背后的真实 State Server 地址,建立连接并转发请求。这样就可以做到会话粘滞在特定的StateServer上面了。

下面是构造哈希环的代码:

func (sd *StateDiscovery) Start(ctx context.Context) error {
	resp, err := sd.client.Get(ctx, StatePrefix, clientv3.WithPrefix())
	if err != nil {
		return err
	}

	for _, ev := range resp.Kvs {
		sd.addNode(string(ev.Value))
	}

	// Watch
	go sd.watch(ctx)
	return nil
}

func (sd *StateDiscovery) watch(ctx context.Context) {
	rch := sd.client.Watch(ctx, StatePrefix, clientv3.WithPrefix())
	for {
		select {
		case <-ctx.Done():
			return
		case wresp := <-rch:
			for _, ev := range wresp.Events {
				switch ev.Type {
				case clientv3.EventTypePut:
					sd.addNode(string(ev.Kv.Value))
				case clientv3.EventTypeDelete:
					key := string(ev.Kv.Key)
					addr := key[len(StatePrefix):]
					sd.removeNode(addr)
				}
			}
		}
	}
}

首先就是获取所有真实的StateServer地址,然后注册到上面说的哈希环中,这里还需要开启一个监听协程,因为需要及时感知StateServer节点的变化,对哈希环做出相应更改。

之后这三个方法分别是将地址注册到哈希环上,将地址从哈希环上移除,根据SessionID进行一致性哈希获取对应StateServer的地址:

func (sd *StateDiscovery) addNode(addr string) {
    sd.mu.Lock()
    defer sd.mu.Unlock()

    for i := 0; i < VirtualNodes; i++ {
        vKey := fmt.Sprintf("%s#%d", addr, i)
        hash := crc32.ChecksumIEEE([]byte(vKey))
        sd.nodeMap[hash] = addr
        sd.ring = append(sd.ring, hash)
    }
    sort.Slice(sd.ring, func(i, j int) bool {
        return sd.ring[i] < sd.ring[j]
    })
    log.Printf("Added state node: %s", addr)
}

func (sd *StateDiscovery) removeNode(addr string) {
    sd.mu.Lock()
    defer sd.mu.Unlock()

    newRing := make([]uint32, 0)
    for _, hash := range sd.ring {
        if sd.nodeMap[hash] != addr {
            newRing = append(newRing, hash)
        } else {
            delete(sd.nodeMap, hash)
        }
    }
    sd.ring = newRing
    log.Printf("Removed state node: %s", addr)
}

func (sd *StateDiscovery) GetNode(sessionID string) (string, error) {
    sd.mu.RLock()
    defer sd.mu.RUnlock()

    if len(sd.ring) == 0 {
        return "", fmt.Errorf("no available state nodes")
    }

    hash := crc32.ChecksumIEEE([]byte(sessionID))
    idx := sort.Search(len(sd.ring), func(i int) bool {
        return sd.ring[i] >= hash
    })

    if idx == len(sd.ring) {
        idx = 0
    }

    return sd.nodeMap[sd.ring[idx]], nil
}

下面就是网关层的变动了,首先需要维护一个stateClients map[*string*]pb.StateServiceClient,因为现在StateServer会有多个,而且要根据会话ID做一致性路由。

func (s *Server) getStateClient(key string) (pb.StateServiceClient, error) {
    addr, err := s.stateDiscovery.GetNode(key)
    if err != nil {
        return nil, err
    }

    s.clientMu.RLock()
    client, ok := s.stateClients[addr]
    s.clientMu.RUnlock()
    if ok {
        return client, nil
    }

    s.clientMu.Lock()
    defer s.clientMu.Unlock()

    if client, ok := s.stateClients[addr]; ok {
        return client, nil
    }

    // Use insecure for now
    conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        return nil, err
    }
    client = pb.NewStateServiceClient(conn)
    s.stateClients[addr] = client
    return client, nil
}

这段代码就是网关根据会话ID做一致性哈希,找到对应StateServer的客户端句柄,返回出去供后续调用。由于网关层现在需要知道消息的会话ID了,所以需要先根据proto文件解析出信令结构体,获取对应会话ID,然后再调用上面这个函数获取StateServer的地址,再进行调用。

var routingKey string
var cmd protocol.Command
if err := proto.Unmarshal(payload, &cmd); err == nil {
    switch cmd.Type {
        case protocol.CommandType_MESSAGEUP:
        var msg protocol.MessageUp
        if err := proto.Unmarshal(cmd.Data, &msg); err == nil {
            routingKey = msg.ReceiverId
        }
    }
}

if routingKey == "" {
    routingKey = c.Uid
}

client, err := s.getStateClient(routingKey)

注意,这部分代码无论是什么类型的消息,最终都会得到一个非空的routingKey,拿到这个routingKey之后,代码就会调用之前的一致性哈希方法获取StateServer的客户端句柄,进行调用。所以,所有经过这里的请求,都会被纳入一致性 Hash 的路由体系中,哪怕它不需要严格的会话粘滞,也会被 Hash 到某台固定的机器上。这样做的好处是逻辑统一,简化了代码路径简单,不需要维护两套路由逻辑。后面肯定是需要细分的,比如说上行消息走一致性哈希,其他消息信令可以通过负载均衡进行转发,现在主要是为了实现简单。

Redis和Mysql处理原子性

之前的这段代码有BUG:

if s.rdb != nil {
    if s.rdb.Ping(ctx).Err() == nil {
        key := fmt.Sprintf("MsgUUID:%s", msgCmd.Uuid)
        ok, err := s.rdb.SetNX(ctx, key, "1", ttl).Result()
        if err != nil {
            log.Printf("[StateServer] Idempotency error uuid=%s: %v", msgCmd.Uuid, err)
            status = protocol.MessageAckStatus_ACK_RETRY
        } else {
            if ok {
                status = protocol.MessageAckStatus_ACK_OK
                firstSeen = true
            } else {
                status = protocol.MessageAckStatus_ACK_OK
                firstSeen = false
            }
        }
    }
}

// 分配会话内序列号并持久化消息(使用独立包)
var seq int64
if status == protocol.MessageAckStatus_ACK_OK && firstSeen {
    var err error
    seq, err = s.alloc.NextSeq(ctx, msgCmd.ReceiverId)
    if err != nil {
        log.Printf("[StateServer] Seq alloc failed conv=%s uuid=%s: %v", msgCmd.ReceiverId, msgCmd.Uuid, err)
        status = protocol.MessageAckStatus_ACK_RETRY
    } else {
        // 生成分布式 ID (Snowflake)
        id := s.snowflake.Generate().Int64()
        m := model.Message{
            Id:        id,
            SessionId: msgCmd.ReceiverId,
            Seq:       seq,
            Type:      int8(msgCmd.Type),
            Content:   msgCmd.Content,
            SendId:    req.Uid,
            ReceiveId: msgCmd.ReceiverId,
            Status:    1,
        }
        if err = s.store.Save(ctx, m); err != nil {
            log.Printf("[StateServer] Persist failed uuid=%s: %v", msgCmd.Uuid, err)
            status = protocol.MessageAckStatus_ACK_RETRY
        }
    }
}

这段代码假设Redis判断去重和Mysql落库是原子的,但实际不是,考虑如下情况。假如消息A是一条新消息,此时Redis中SetNx会返回true,设置firstSeen为true,此时就会走到后面Mysql落库,但是如果此时Mysql落库失败,就会返回ACK_Retry信令给客户端,客户端就会重试。此时Redis中已经有对应消息的Uuid了,所以会走到firstSeen = false的分支,此时就不会走到落库逻辑了,就会产生用户收到了ACK_OK,但是消息并没有落库。

这个问题的本质在于幂等性检查的状态与业务操作状态的不一致,在当前逻辑中,只要 Redis SetNX成功,系统就单方面认为这条消息已经处理过了,而不管后续Mysql业务操作是否成功。为了解决这个问题,必须确保Redis中的已处理标记与Mysql中的落库成功保持一致,这里可以使用TCC分布式事务的思想解决。

首先是Try,在执行业务逻辑前,先通过Redis的SetNx获取锁,如果扩区成功,说明是第一次请求,进入Try阶段,如果获取失败,说明是重复请求,直接返回成功。之后就执行业务逻辑,SeqID分配和Mysql落库,如果Mysql写入成功,则认为事务提交成功,此时Redis中的Key保留,作为已经处理的标记。如果Mysql写入失败,就进入Cancel阶段,必须立即删除Redis中的Key,这相当于撤销了 Try 阶段的锁。这样,当客户端发起重试时, SetNX 将再次成功,允许业务逻辑重新执行,直到成功为止。

解决方法也很简单,只需要在后面业务逻辑处理失败的时候回滚对应Key就好,在有Err的地方调用这段代码删除幂等Key。

if s.rdb != nil {
    s.rdb.Del(ctx, fmt.Sprintf("MsgUUID:%s", msgCmd.Uuid))
}

但是这个方法也有一个问题,就是业务逻辑失败之后,但在执行 Del Key 之前,服务器突然断电宕机了,此时Redis里面就一直留着客户端的那个消息Uuid,客户端重试时会就会一直被挡住,无法落库。解决方法很简单,就是SetNx设置一个过期时间,即使发生了上述极端情况,锁也会在TTL后自动释放,系统最终会恢复正常。

如果需要更强的保证的话,可以使用在消息表中为Uuid字段建立唯一索引,Redis 依然做初步拦截,实际插入时使用索引插入,即使 Redis 锁失效或被误删,MySQL 的唯一索引也能保证绝对不会插入重复数据。