Redis 单线程 为何却需要事务处理并发问题

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: Redis 单线程 为何却需要事务处理并发问题

Redis 是单线程处理,也就是命令会顺序执行。那么为什么会存在并发问题呢?

个人理解是,虽然 redis 是单线程,但是可以同时有多个客户端访问,每个客户端会有
一个线程。客户端访问之间存在竞争

简单的总结下,其实 redis 本事是不会存在并发问题的,因为他是单进程的,再多的 command 都是 one by one 执行的。我们使用的时候,可能会出现并发问题,比如 getset 这一对。

因为存在多客户端并发,所以必须保证操作的原子性。比如银行卡扣款问题,获取余额,判断,扣款,写回就必须构成事务,否则就可能出错。

redis 中的并发问题

使用 redis 作为缓存已经很久了,redis 是以单进程的形式运行的,命令是一个接着一个执行的,一直以为不会存在并发的问题,直到今天看到相关的资料,才恍然大悟~~

具体问题实例

有个键,假设名称为 myNum,里面保存的是阿拉伯数字,假设现在值为 1,存在多个连接对 myNum 进行操作的情况,这个时候就会有并发的问题。假设有两个连接 linkAlinkB,这两个连接都执行下面的操作,取出 myNum 的值,+1,然后再存回去,看看下面的交互:

linkA get myNum => 1
linkB get myNum => 1
linkA set muNum => 2
linkB set myNum => 2

执行完操作之后,结果可能是 2,这和我们预期的 3 不一致。

再看一个具体的例子:

<?php
require "vendor/autoload.php";
$client = new Predis\Client([
    'scheme' => 'tcp',
    'host' => '127.0.0.1',
    'port' => 6379,
]);
for ($i = 0; $i < 1000; $i++) {
    $num = intval($client->get("name"));
    $num = $num + 1;
    $client->setex("name", $num, 10080);
    usleep(10000);
}

设置 name 初始值为 0,然后同时用两个终端执行上面的程序,最后 name 的值可能不是 2000,而是一个 < 2000 的值,这也就证明了我们上面的并发问题的存在,这个该怎么解决呢?

redis 中的事务

redis 中也是有事务的,不过这个事务没有 mysql 中的完善,只保证了一致性和隔离性,不满足原子性和持久性。

redis 事务使用 multi、exec 命令

原子性,redis 会将事务中的所有命令执行一遍,哪怕是中间有执行失败也不会回滚。kill 信号、宿主机宕机等导致事务执行失败,redis 也不会进行重试或者回滚。

持久性,redis 事务的持久性依赖于 redis 所使用的持久化模式,遗憾的是各种持久化模式也都不是持久化的。

隔离性,redis 是单进程,开启事务之后,会执行完当前连接的所有命令直到遇到 exec 命令,才处理其他连接的命令。

一致性,看了文档,觉得挺扯的,但是貌似说的没有问题。

redis 中的事务不支持原子性,所以解决不了上面的问题。

当然了 redis 还有一个 watch 命令,这个命令可以解决这个问题,看下面的例子,对一个键执行 watch,然后执行事务,由于 watch 的存在,他会监测键 a,当 a 被修该之后,后面的事务就会执行失败,这就确保了多个连接同时来了,都监测着 a,只有一个能执行成功,其他都返回失败。

127.0.0.1:6379> set a 1
OK
127.0.0.1:6379> watch a
OK
127.0.0.1:6379> multi 
OK
127.0.0.1:6379> incr a
QUEUED
127.0.0.1:6379> exec
1) (integer) 2
127.0.0.1:6379> get a
"2"

失败时候的例子,从最后可以看出,test 的值被其他连接修改了:

127.0.0.1:6379> set test 1
OK
127.0.0.1:6379> watch test
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incrby test 11
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get test
"100"

我的问题如何解决

redis 中命令是满足原子性的,因此在值为阿拉伯数字的时候,我可以将 getset 命令修改为 incr 或者 incrby 来解决这个问题,下面的代码开启两个终端同时执行,得到的结果是满足我们预期的 2000

<?php
require "vendor/autoload.php";
$client = new Predis\Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
]);
for ($i = 0; $i < 1000; $i++) {
    $client->incr("name");
    $client->expire("name", 10800);
    usleep(10000);
}

@manzilu 提到的方法

评论中 manzilu 提到的方法查了下资料,确实可行,效果还不错,这里写了个例子

<?php
require "vendor/autoload.php";
$client = new Predis\Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
]);
class RedisLock
{
    public $objRedis = null;
    public $timeout = 3;
    /**
     * @desc 设置redis实例
     *
     * @param obj object | redis实例
     */
    public function __construct($obj)
    {
        $this->objRedis = $obj;
    }
    /**
     * @desc 获取锁键名
     */
    public function getLockCacheKey($key)
    {
        return "lock_{$key}";
    }
    /**
     * @desc 获取锁
     *
     * @param key string | 要上锁的键名
     * @param timeout int | 上锁时间
     */
    public function getLock($key, $timeout = NULL)
    {
        $timeout = $timeout ? $timeout : $this->timeout;
        $lockCacheKey = $this->getLockCacheKey($key);
        $expireAt = time() + $timeout;
        $isGet = (bool)$this->objRedis->setnx($lockCacheKey, $expireAt);
        if ($isGet) {
            return $expireAt;
        }
        while (1) {
            usleep(10);
            $time = time();
            $oldExpire = $this->objRedis->get($lockCacheKey);
            if ($oldExpire >= $time) {
                continue;
            }
            $newExpire = $time + $timeout;
            $expireAt = $this->objRedis->getset($lockCacheKey, $newExpire);
            if ($oldExpire != $expireAt) {
                continue;
            }
            $isGet = $newExpire;
            break;
        }
        return $isGet;
    }
    /**
     * @desc 释放锁
     *
     * @param key string | 加锁的字段
     * @param newExpire int | 加锁的截止时间
     *
     * @return bool | 是否释放成功
     */
    public function releaseLock($key, $newExpire)
    {
        $lockCacheKey = $this->getLockCacheKey($key);
        if ($newExpire >= time()) {
            return $this->objRedis->del($lockCacheKey);
        }
        return true;
    }
}
$start_time = microtime(true);
$lock = new RedisLock($client);
$key = "name";
for ($i = 0; $i < 10000; $i++) {
    $newExpire = $lock->getLock($key);
    $num = $client->get($key);
    $num++;
    $client->set($key, $num);
    $lock->releaseLock($key, $newExpire);
}
$end_time = microtime(true);
echo "花费时间 : ". ($end_time - $start_time) . "\n";

执行 shellphp setnx.php & php setnx.php&,最后会得到结果:

$ 花费时间 : 4.3004920482635
[2]  + 72356 done       php setnx.php
# root @ ritoyan-virtual-pc in ~/PHP/redis-high-concurrency [20:23:41] 
$ 花费时间 : 4.4319710731506
[1]  + 72355 done       php setnx.php

同样循环 1w 次,去掉 usleep,使用 incr 直接进行增加,耗时在 2s 左右。

而获取所得时候取消 usleep,时间不但没减少,反而增加了,这个 usleep 的设置要合理,免得进程做无用的循环

总结

看了这么多,简单的总结下,其实 redis 本事是不会存在并发问题的,因为他是单进程的,再多的 command 都是 one by one 执行的。我们使用的时候,可能会出现并发问题,比如 getset 这一对。

相关文章
|
2月前
|
Java API 调度
从阻塞到畅通:Java虚拟线程开启并发新纪元
从阻塞到畅通:Java虚拟线程开启并发新纪元
296 83
|
2月前
|
存储 Java 调度
Java虚拟线程:轻量级并发的革命性突破
Java虚拟线程:轻量级并发的革命性突破
240 83
|
4月前
|
机器学习/深度学习 消息中间件 存储
【高薪程序员必看】万字长文拆解Java并发编程!(9-2):并发工具-线程池
🌟 ​大家好,我是摘星!​ 🌟今天为大家带来的是并发编程中的强力并发工具-线程池,废话不多说让我们直接开始。
188 0
|
4月前
|
设计模式 运维 监控
并发设计模式实战系列(4):线程池
需要建立持续的性能剖析(Profiling)和调优机制。通过以上十二个维度的系统化扩展,构建了一个从。设置合理队列容量/拒绝策略。动态扩容/优化任务处理速度。检查线程栈定位热点代码。调整最大用户进程数限制。CPU占用率100%
318 0
|
4月前
|
存储 缓存 安全
JUC并发—11.线程池源码分析
本文主要介绍了线程池的优势和JUC提供的线程池、ThreadPoolExecutor和Excutors创建的线程池、如何设计一个线程池、ThreadPoolExecutor线程池的执行流程、ThreadPoolExecutor的源码分析、如何合理设置线程池参数 + 定制线程池。
JUC并发—11.线程池源码分析
|
5月前
|
存储 NoSQL Redis
阿里面试:Redis 为啥那么快?怎么实现的100W并发?说出了6大架构,面试官跪地: 纯内存 + 尖端结构 + 无锁架构 + EDA架构 + 异步日志 + 集群架构
阿里面试:Redis 为啥那么快?怎么实现的100W并发?说出了6大架构,面试官跪地: 纯内存 + 尖端结构 + 无锁架构 + EDA架构 + 异步日志 + 集群架构
阿里面试:Redis 为啥那么快?怎么实现的100W并发?说出了6大架构,面试官跪地: 纯内存 + 尖端结构 +  无锁架构 +  EDA架构  + 异步日志 + 集群架构
|
6月前
|
缓存 NoSQL 中间件
Redis的线程模型
Redis采用单线程模型确保操作的原子性,每次只执行一个操作,避免并发冲突。它通过MULTI/EXEC事务机制、Lua脚本和复合指令(如MSET、GETSET等)保证多个操作要么全成功,要么全失败,确保数据一致性。Redis事务在EXEC前失败则不执行任何操作,EXEC后失败不影响其他操作。Pipeline虽高效但不具备原子性,适合非热点时段的数据调整。Redis 7引入Function功能,支持函数复用,简化复杂业务逻辑。总结来说,Redis的单线程模型简单高效,适用于高并发场景,但仍需合理选择指令执行方式以发挥其性能优势。
173 6
|
9月前
|
缓存 NoSQL Redis
Redis经典问题:数据并发竞争
数据并发竞争是大流量系统(如火车票系统、微博平台)中常见的问题,可能导致用户体验下降甚至系统崩溃。本文介绍了两种解决方案:1) 加写回操作加互斥锁,查询失败快速返回默认值;2) 保持多个缓存备份,减少并发竞争概率。通过实践案例展示,成功提高了系统的稳定性和性能。
|
9月前
|
NoSQL Redis
单线程传奇Redis,为何引入多线程?
Redis 4.0 引入多线程支持,主要用于后台对象删除、处理阻塞命令和网络 I/O 等操作,以提高并发性和性能。尽管如此,Redis 仍保留单线程执行模型处理客户端请求,确保高效性和简单性。多线程仅用于优化后台任务,如异步删除过期对象和分担读写操作,从而提升整体性能。
157 1
|
10月前
|
安全 Java
线程安全的艺术:确保并发程序的正确性
在多线程环境中,确保线程安全是编程中的一个核心挑战。线程安全问题可能导致数据不一致、程序崩溃甚至安全漏洞。本文将分享如何确保线程安全,探讨不同的技术策略和最佳实践。
160 6