136.使用Redis 解决分布式系统下的并发问题
文章目录
- 数据层搭建:GoRedis 助力高效交互
- Redis 并发解决方案:精准打击,逐个击破
- 原子操作:简单场景下的利器
- 事务:保证操作的原子性
- LUA 脚本:将逻辑移至 Redis 服务端执行
- 分布式锁:灵活控制并发访问
- 总结
在分布式系统和数据库的交互中,并发问题如同暗流般潜伏,稍有不慎就会掀起应用的惊涛骇浪。试想一下,我们正在构建一个股票交易平台,允许不同用户同时购买公司股票。每个公司都有一定数量的可用股票,用户只能在剩余股票充足的情况下进行购买。
Golang
与 Redis
的解决方案:构建稳固的交易系统
为了解决这个问题,我们可以借助 Golang
和 Redis
的强大功能,构建一个安全可靠的交易系统。
数据层搭建:GoRedis 助力高效交互
首先,我们使用 goredis
客户端库创建一个数据层(Repository
),用于与 Redis
数据库进行交互:
type Repository struct {client *redis.Client
}var _ go_redis_concurrency.Repository = (*Repository)(nil)func NewRepository(address, password string) Repository {return Repository{client: redis.NewClient(&redis.Options{Addr: address,Password: password,}),}
}
购买股票功能实现:并发问题初现端倪
接下来,我们实现 BuyShares
函数,模拟用户购买股票的操作:
func (r *Repository) BuyShares(ctx context.Context, userId,
companyId string, numShares int, wg *sync.WaitGroup) error {defer wg.Done()companySharesKey := BuildCompanySharesKey(companyId)// --- (1) ----// 获取当前可用股票数量currentShares, err := r.client.Get(ctx, companySharesKey).Int()if err != nil {fmt.Print(err.Error())return err}// --- (2) ----// 验证剩余股票是否充足if currentShares < numShares {fmt.Print("error: 公司剩余股票不足\n")return errors.New("error: 公司剩余股票不足")}currentShares -= numShares// --- (3) ----// 更新公司可用股票数量_, err = r.client.Set(ctx, companySharesKey, currentShares, 0).Result()return err
}
该函数包含三个步骤:
- 获取公司当前可用股票数量。
- 验证剩余股票是否足以满足用户购买需求。
- 更新公司可用股票数量。
看似逻辑清晰,但当多个用户并发执行 BuyShares
函数时,问题就出现了。
模拟并发场景:问题暴露无遗
为了模拟并发场景,我们创建多个 Goroutine
同时执行 BuyShares
函数:
const (total_clients = 30
)func main() {// --- (1) ----// 初始化 Repositoryrepository := redis.NewRepository(fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port), config.Redis.Pass)// --- (2) ----// 并发执行 BuyShares 函数companyId := "TestCompanySL"var wg sync.WaitGroupwg.Add(total_clients)for idx := 1; idx <= total_clients; idx++ {userId := fmt.Sprintf("user%d", idx)go repository.BuyShares(context.Background(), userId, companyId, 100, &wg)}wg.Wait()// --- (3) ----// 获取公司剩余股票数量shares, err := repository.GetCompanyShares(context.Background(), companyId)if err != nil {panic(err)}fmt.Printf("公司 %s 剩余股票数量: %d\n", companyId, shares)
}
假设公司 TestCompanySL
初始拥有 1000
股可用股票,每个用户购买 100
股。我们期望的结果是,只有 10
个用户能够成功购买股票,剩余用户会因为股票不足而收到错误信息。
然而,实际运行结果却出乎意料,公司剩余股票数量可能出现负数,这意味着多个用户在读取可用股票数量时,获取到的是同一个未更新的值,导致最终结果出现偏差。
Redis 并发解决方案:精准打击,逐个击破
为了解决上述并发问题,Redis
提供了多种解决方案,让我们来一一剖析。
原子操作:简单场景下的利器
原子操作能够在不加锁的情况下,保证对数据的修改操作具有原子性。在 Redis
中,可以使用 INCRBY
命令对指定 key
的值进行原子递增或递减。
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {defer wg.Done()// ... (省略部分代码) ...// 使用 INCRBY 命令原子更新股票数量_, err = r.client.IncrBy(ctx, companySharesKey, int64(-numShares)).Result()return err
}
然而,在我们的股票交易场景中,原子操作并不能完全解决问题。因为在更新股票数量之前,还需要进行剩余股票数量的验证。如果多个用户同时读取到相同的可用股票数量,即使使用原子操作更新,最终结果仍然可能出现错误。
事务:保证操作的原子性
Redis
事务可以将多个命令打包成一个原子操作,要么全部执行成功,要么全部回滚。通过 MULTI
、EXEC
、DISCARD
和 WATCH
命令,可以实现对数据的原子性操作。
MULTI
:标记事务块的开始。EXEC
:执行事务块中的所有命令。DISCARD
:取消事务块,放弃执行所有命令。WATCH
:监视指定的key
,如果key
在事务执行之前被修改,则事务执行失败。
在我们的例子中,可以使用 WATCH
命令监视公司可用股票数量的 key
。如果 key
在事务执行之前被修改,则说明有其他用户并发修改了数据,当前事务执行失败,从而保证数据的一致性。
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {defer wg.Done()companySharesKey := BuildCompanySharesKey(companyId)// 使用事务保证操作的原子性tx := r.client.TxPipeline()tx.Watch(ctx, companySharesKey)// ... (省略部分代码) ..._, err = tx.Exec(ctx).Result()return err
}
然而,在高并发场景下,使用事务可能会导致大量事务执行失败,影响系统性能。
LUA 脚本:将逻辑移至 Redis 服务端执行
为了避免上述问题,可以借助 Redis
的 LUA
脚本功能,将业务逻辑移至 Redis
服务端执行。LUA
脚本在 Redis
中以原子方式执行,可以有效避免并发问题。
local sharesKey = KEYS[1]
local requestedShares = ARGV[1]local currentShares = redis.call("GET", sharesKey)
if currentShares < requestedShares thenreturn {err = "error: 公司剩余股票不足"}
endcurrentShares = currentShares - requestedShares
redis.call("SET", sharesKey, currentShares)
该 LUA
脚本实现了与 BuyShares
函数相同的逻辑,包括获取可用股票数量、验证剩余股票是否充足以及更新股票数量。
在 Golang
中,可以使用 goredis
库执行 LUA
脚本:
var BuySharesScript = redis.NewScript(`local sharesKey = KEYS[1]local requestedShares = ARGV[1]local currentShares = redis.call("GET", sharesKey)if currentShares < requestedShares thenreturn {err = "error: 公司剩余股票不足"}endcurrentShares = currentShares - requestedSharesredis.call("SET", sharesKey, currentShares)
`)func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {defer wg.Done()keys := []string{BuildCompanySharesKey(companyId)}err := BuySharesScript.Run(ctx, r.client, keys, numShares).Err()if err != nil {fmt.Println(err.Error())}return err
}
使用 LUA 脚本可以有效解决并发问题,并且性能优于事务机制。
分布式锁:灵活控制并发访问
除了 LUA
脚本,还可以使用分布式锁来控制对共享资源的并发访问。Redis
提供了 SETNX
命令,可以实现简单的分布式锁机制。
在 Golang
中,可以使用 redigo
库的 Lock
函数获取分布式锁:
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {defer wg.Done()companySharesKey := BuildCompanySharesKey(companyId)// 获取分布式锁lockKey := "lock:" + companySharesKeylock, err := r.client.Lock(ctx, lockKey, redislock.Options{RetryStrategy: redislock.ExponentialBackoff{InitialDuration: time.Millisecond * 100,MaxDuration: time.Second * 3,},})if err != nil {return fmt.Errorf("获取分布式锁失败: %w", err)}defer lock.Unlock(ctx)// ... (省略部分代码) ...return nil
}
使用分布式锁可以灵活控制并发访问,但需要谨慎处理锁的释放和超时问题,避免出现死锁情况。
总结
Redis
提供了多种解决并发问题的方案,包括原子操作、事务、LUA 脚本和分布式锁
等。在实际应用中,需要根据具体场景选择合适的方案。
- 原子操作适用于简单场景,例如计数器等。
- 事务可以保证多个操作的原子性,但性能较低。
LUA
脚本可以将业务逻辑移至Redis
服务端执行,性能较高,但需要熟悉LUA
语法。- 分布式锁可以灵活控制并发访问,但需要谨慎处理锁的释放和超时问题。