redis和EloqKV的事务性和性能测试

前言

Redis是一款高性能的开源内存数据库,广泛用于缓存和简单的KV数据存储,但在事务支持方面有所局限。

Redis事务

Redis 使用 MULTI 和 EXEC 实现事务,MULTI 开启事务,然后将事务中的命令放入一个队列中,EXEC 执行事务队列的命令,这些命令会按顺序执行,且具有以下特点:

  1. 原子性(部分):要么所有命令都执行,要么所有命令都不执行,且 Redis 在发生失败时不会回滚已成功执行的命令。
  2. 顺序性: 事务中的命令严格按提交顺序执行。
  3. 隔离性: 在事务执行期间,其他客户端的命令不会插入到当前事务的执行中

可以看出,与MySQL这种关系型数据库事务的不同之处,MySQL的事务中只要一个命令出现异常,所有的命令都不会执行(回滚),而Redis无法做到这一点。

如图,我们在Redis中开启了一个事务,第二条hset命令和第一条set命令都操作了同一个key,执行时显然是要报错的,我们看第二条和第三条命令是否可以正常执行。

如图,exec执行事务队列中的命令,第二条命令报错,但是第一、三条命令都正常执行。所以,Redis的事务不具备原子性

除此之外,Redis在事务原子性方面的不足,还表现在以下几个方面:

单线程模型

Redis采用单线程处理请求,这虽然简化了设计,避免了锁的复杂性,但也限制了其垂直扩展能力。在多核现代服务器上,Redis的性能提升有限,无法充分利用多线程处理能力,导致高并发时的性能瓶颈。

从 Redis 6 开始,虽然可以通过配置文件显式启用多线程功能,但多线程被用于处理网络请求的读写操作,比如接受客户端连接、读取请求数据、发送响应。Redis 的核心执行逻辑仍然保持单线程模型。

不支持跨分片通信

在Redis Cluster模式下,key是通过哈希槽分配到不同的节点(分片)上的,因为Redis的多个分片实例之间无法相互通信,这意味着无法在多个分片之间实现一致的原子性事务。

Redis 中 WATCH 命令来监控一个或多个键的变化。在 WATCH 后,如果这些键在执行 MULTI 和 EXEC 之间发生了变化,Redis 会自动放弃执行事务。

WATCH key1 key2
MULTI
SET key1 "value1"
SET key2 "value2"
EXEC

假如我们在节点A上执行上述命令,key1在节点A、key2在节点B,当你执行 MULTI 和 EXEC 时,节点 A 将会尝试提交包含 SET key1 和 SET key2 的事务,但由于 key2 在节点 B 上,节点 A 无法知道 key2 是否已经被修改。

节点A不能跨节点监听到其他节点上键的变化,期间如果key2发生变化,所以就无法取消key1的操作,不能保证事务的原子性。

手动管理Key映射

为了解决上面跨分片的事务问题,Redis 给开发者提供了 hashtag 的方式,以确保这些Key映射到同一分片。hashtag 是一种通过在键名中使用特定的标记(例如 {})来限制哈希槽范围的方式。只有被括起来的部分会参与哈希计算,确保多个键在同一个分片上。

例如,key1 和 key2 默认情况下可能会分配到不同的分片,但如果你指定了 hashtag(如 {key1} 和 {key2}),Redis 会计算 {key1} 和 {key2} 中的大括号部分来计算哈希槽,从而确保这两个键落在同一个分片上。

SET {user:1000}:name "John"
SET {user:1000}:age 30

{user:1000}:name 和 {user:1000}:age 会被视为同一个“hashtag”部分,即 user:1000,这确保了这两个键会被映射到同一个分片

这种方式对业务代码的侵入性较强,增加业务代码的复杂性,并增加了管理复杂性。

EloqKV

EloqKV是一款兼容Redis协议的分布式Key-Value数据库,旨在解决这些不足之处。作为高性能、弹性扩展、支持事务的分布式数据库,EloqKV具有以下特点:

多线程架构

EloqKV支持多线程,能够利用现代多核服务器资源,提升吞吐量并实现垂直扩展。

分片之间支持通信

EloqKV的分布式架构支持不同分片之间的通信,使其能够在跨分片的事务中保持一致性。 EloqKV可以根据业务增长动态扩展,满足大规模分布式场景需求,并为开发者提供一致的体验。

测试环境

本次测试在两台服务器上进行配置:

  1. 服务器一:32核物理机,部署EloqKV/Redis实例。
  2. 服务器二:32核物理机,部署测试服务器。

本实验主要是为了验证多Key访问的原子性能力,因此Redis 没有开启 RDB、AOF,EloqK没有开启enable_data_store 和enable_wal。其他选项可以根据自己需要灵活配置。

场景模拟:社交软件中的点赞事务

在社交应用场景中,点赞操作通常包含两个步骤:更新点赞计数、将用户ID添加到点赞用户列表中。这两个步骤需要原子性,即它们要么全部成功,要么全部失败,以确保数据的一致性。

在Redis中,我们可以通过MULTI/EXEC命令来实现多步操作的事务,但这种事务仅在单分片内有效。在跨分片的场景中,Redis要求手动调整Key的分片映射,使其都在同一实例上。

相比之下,EloqKV能够在不同分片之间进行通信,直接支持多分片事务,这对于需要分布式事务的业务场景尤为重要。

实验Benchmark

在本实验中,我们使用Go语言编写了一段模拟代码,以实现上述社交软件点赞场景的事务操作。代码会统计QPS和Latency等性能指标,以评估在高并发场景下两种数据库的性能差异。

Go语言代码示例:

package main

import (
    "context"
    "fmt"
    "math/rand"
    "sync"
    "time"

    "github.com/go-redis/redis/v8"
)

const (
    totalRequests = 200000
    concurrency   = 100
    numUsers      = 1000000 // Number of unique users
    numPosts      = 1000000  // Number of unique posts
    maxRetries    = 10    // Maximum retry attempts for OCC conflicts
)

func likeOperation(client *redis.Client, ctx context.Context, userID string, postID string) error {
    for attempt := 0; attempt < maxRetries; attempt++ {
        pipe := client.TxPipeline()
        pipe.Incr(ctx, "post:"+postID+":like_count")
        pipe.SAdd(ctx, "post:"+postID+":liked_users", userID)

        _, err := pipe.Exec(ctx)
        if err == nil {
            return nil // Transaction succeeded, exit the retry loop
        }

        // Backoff strategy: add a small sleep before retrying
        time.Sleep(time.Millisecond * time.Duration(10*(attempt+1)))
    }
    return fmt.Errorf("transaction failed after %d retries", maxRetries)
}

func main() {
    ctx := context.Background()
    client := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })

    var wg sync.WaitGroup
    wg.Add(concurrency)

    rand.Seed(time.Now().UnixNano()) // Initialize random seed

    start := time.Now()
    for i := 0; i < concurrency; i++ {
        go func() {
            defer wg.Done()
            for j := 0; j < totalRequests/concurrency; j++ {
                userID := fmt.Sprintf("user%d", rand.Intn(numUsers))
                postID := fmt.Sprintf("post%d", rand.Intn(numPosts))
                
                // Attempt to execute the like operation with retries
                if err := likeOperation(client, ctx, userID, postID); err != nil {
                    fmt.Printf("Failed to complete transaction: %v\n", err)
                }
            }
        }()
    }

    wg.Wait()
    duration := time.Since(start)

    fmt.Printf("QPS: %.2f\n", float64(totalRequests)/duration.Seconds())
    fmt.Printf("Latency: %.2f ms\n", duration.Seconds()*1000/float64(totalRequests))
}

Python语言代码示例:

import redis
import random
import time
import threading

# Constants
total_requests = 200000
concurrency = 100
num_users = 1000000  # Number of unique users
num_posts = 1000000  # Number of unique posts
max_retries = 10  # Maximum retry attempts for OCC conflicts

# Redis client
client = redis.StrictRedis(host='localhost', port=6379, db=0)

# Function to perform like operation with retry logic
def like_operation(user_id, post_id):
    for attempt in range(max_retries):
        try:
            with client.pipeline() as pipe:
                pipe.incr(f"post:{post_id}:like_count")
                pipe.sadd(f"post:{post_id}:liked_users", user_id)
                pipe.execute()
            return  # Transaction succeeded
        except redis.exceptions.RedisError as err:
            # Retry in case of error (like optimistic concurrency control conflict)
            time.sleep(0.01 * (attempt + 1))  # Backoff strategy
    print(f"Transaction failed after {max_retries} retries")

# Worker function to simulate the likes operation
def worker():
    for _ in range(total_requests // concurrency):
        user_id = f"user{random.randint(0, num_users - 1)}"
        post_id = f"post{random.randint(0, num_posts - 1)}"
        like_operation(user_id, post_id)

# Main function to simulate concurrent requests
def main():
    threads = []
    start_time = time.time()

    # Create and start threads for concurrent execution
    for _ in range(concurrency):
        thread = threading.Thread(target=worker)
        threads.append(thread)
        thread.start()

    # Wait for all threads to finish
    for thread in threads:
        thread.join()

    duration = time.time() - start_time

    # Calculate and display the results
    qps = total_requests / duration
    latency = (duration * 1000) / total_requests
    print(f"QPS: {qps:.2f}")
    print(f"Latency: {latency:.2f} ms")

if __name__ == "__main__":
    main()

测试结果

我们通过运行上面高并发调用模拟点赞操作的代码,并分别记录在Redis和EloqKV下的QPS(每秒查询数)和平均延迟(Latency)指标。

Redis测试结果:10万QPS,延迟0.01ms

EloqKV测试结果:40万QPS,延迟0.00ms

从上面的测试结果我们可以得出结论:Redis在高并发环境下,由于单线程限制,Redis的QPS受到瓶颈限制,平均延迟较高。而EloqKV的多线程架构能够支持更高的QPS,延迟显著降低。

补充实验:跨分片事务

Redis要求所有涉及事务性操作的键必须映射到同一个分片上,所以Redis Cluster 默认不支持跨分片的事务操作。那么EloqKV是否支持跨分片的是事务场景。

这里我们同样使用python和go实现了跨分片事务的测试代码,在代码中只需要修改IP和端口,就能分别对 Redis Cluster 和 EloqKV 的分片事务测试。

Python代码如下:

from redis.cluster import RedisCluster
from redis.exceptions import RedisError

# 初始化 Redis Cluster 连接
startup_nodes = [{"host": "127.0.0.1", "port": "7000"}]
try:
    redis_client = RedisCluster(startup_nodes=startup_nodes, decode_responses=True)
except RedisError as e:
    print(f"Failed to connect to Redis Cluster: {e}")
    exit(1)

try:
    # Key1 和 Key2 的 hash slot 不同,确保它们分布在不同分片
    key1 = "user:1"  # 分片1
    key2 = "user:2"  # 分片2

    # 尝试在跨分片中使用事务(MULTI/EXEC)
    pipeline = redis_client.pipeline(transaction=True)
    pipeline.set(key1, "value1")
    pipeline.set(key2, "value2")
    pipeline.execute()

except RedisError as e:
    # 捕获异常,显示 Redis Cluster 的事务限制
    print(f"Error during transaction: {e}")
finally:
    redis_client.close()

go语言示例代码如下:

package main

import (
	"context"
	"fmt"
	"github.com/go-redis/redis/v8"
	"log"
	"os"
)

func main() {
	ctx := context.Background()

	// 初始化 Redis Cluster 客户端
	rdb := redis.NewClusterClient(&redis.ClusterOptions{
		Addrs: []string{"127.0.0.1:7000"}, 
	})
	defer rdb.Close()

	// 检查连接是否成功
	if err := rdb.Ping(ctx).Err(); err != nil {
		log.Fatalf("Failed to connect to Redis Cluster: %v", err)
		os.Exit(1)
	}

	// 定义两个键,确保它们映射到不同的分片
	key1 := "user:1" // 分片1
	key2 := "user:2" // 分片2

	// 尝试执行跨分片事务
	pipe := rdb.TxPipeline()
	pipe.Set(ctx, key1, "value1", 0)
	pipe.Set(ctx, key2, "value2", 0)

	_, err := pipe.Exec(ctx)
	if err != nil {
		// 捕获错误,展示跨分片事务失败的原因
		fmt.Printf("Error during transaction: %v\n", err)
	} else {
		fmt.Println("Transaction executed successfully")
	}
}

运行以上代码,因为在水平扩展场景下,Redis Cluster 默认不支持跨分片的事务操作,所以Redis Cluster会返回错误:CROSSSLOT Keys in request don’t hash to the same slot。

如果开启了重定向,虽然不同的key会被重定向到对应的节点上,但是最后exec执行也是失败的。

如果没有开启重定向,key无法重定向,在exec也会报错。

而EloqKV cluster可以正确处理跨分片事务请求,运行结果如图:

我们进入EloqKV的命令行,查看数据是否已经被放入到EloqKV中。

我们通过cluster keyslot进一步验证两条数据的key是否在不同的分片上。

如图,user:1 和 user:2 所在的solt在不同的分片上。

总结

通过本次测试,我们可以清晰地看到EloqKV在事务能力上相较Redis的显著优势:

1. 跨分片事务支持

EloqKV支持跨分片事务,实现了在水平扩展环境中的一致性事务处理,降低了对业务代码的侵入性。

2. 多线程架构

EloqKV的多线程架构在高并发场景下能够提供更高的QPS和更低的延迟,充分利用多核服务器资源,提升了整体性能。

3. 一致的开发体验

EloqKV提供了兼容Redis协议的接口,使开发者无需更改代码,即可在大规模分布式环境中获得一致的事务体验。

结语

EloqKV不仅为开发者提供了与Redis兼容的接口,还通过其高效的事务处理能力为复杂的分布式应用提供了更强大的支持,满足了现代业务对事务性和扩展性的需求。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值