五分钟带你走进Redis并发问题

本文探讨了Redis单进程环境下并发问题的实例,通过事务和watch命令解决get/sets冲突,以及使用incr操作避免问题。还介绍了Redis事务的特性与使用方法,以及实践中的优化策略。
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";

执行shell php 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这一对。如果觉得本文对你有帮助的话,请你也不要吝啬你的赞,你们的支持是对我最大的鼓励。今天的Java知识分享就到这里!想要知道更多Java基础知识和面试资料的我这边整理了一个我自己的GitHub仓库:Java小白修炼手册,大家如果有需要可以自行查看

<think> 我们正在解决Java使用Redis并发+1的问题。根据引用资料,Redis虽然是单线程执行指令,但由于多个客户端指令到达Redis的时序无法保证,可能导致并发问题(如两个客户端同时读取同一个键的值,然后各自增加1写回,但结果可能只增加了1而不是2)。 解决方案思路: 1. 使用Redis的原子操作:Redis提供了原子性的INCR和INCRBY命令,专门用于对数值进行增加操作,避免了先GET再SET的非原子性操作来的并发问题。 2. 使用分布式锁:通过加锁来确保同一时刻只有一个客户端可以对键进行操作。但是引用[2]和[3]指出,分布式锁的实现需要注意超时问题,避免死锁。 3. 结合Lua脚本:将多个操作封装在一个Lua脚本中执行,因为Lua脚本在Redis中是原子执行的。 具体方案: 方案一:使用原子命令(推荐) 对于简单的数值增减,直接使用INCR或INCRBY命令。 例如,我们需要对键x进行加1操作: `INCR x` 相当于 `x = x + 1` 在Java中,使用Jedis或Lettuce等客户端库调用INCR命令: 示例代码(使用Jedis): ```java Jedis jedis = new Jedis("localhost"); long result = jedis.incr("x"); ``` 方案二:使用分布式锁 如果业务逻辑不仅仅是简单的增减,而是需要多个操作(例如先读取,再判断,再修改),则可以考虑分布式锁。 步骤: 1. 获取锁(使用SET命令的NX选项,即当键不存在时才设置成功,设置过期时间防止死锁) 2. 执行业务逻辑(包括对Redis的操作) 3. 释放锁(通过删除键来释放,但要注意释放的是自己加的锁) 但是,分布式锁需要注意超时问题实现一个健壮的分布式锁需要考虑很多细节(如锁续期、释放锁的原子性等)。因此,如果可以使用原子命令或Lua脚本,优先考虑它们。 方案三:使用Lua脚本 将复杂的操作封装成一个Lua脚本,然后通过EVAL或EVALSHA命令执行,保证原子性。 例如,实现一个复杂的增加逻辑(比如在满足某些条件时才进行增加): ```lua local key = KEYS[1] local value = redis.call('GET', key) if value and tonumber(value) > 10 then return redis.call('INCR', key) else return nil end ``` 在Java中执行Lua脚本: ```java Jedis jedis = ...; String script = "上面的Lua脚本"; Object result = jedis.eval(script, Collections.singletonList("x"), Collections.emptyList()); ``` 总结: 对于并发+1问题,首选方案是使用Redis的原子命令(如INCR)。如果业务逻辑复杂,则考虑使用Lua脚本。分布式锁可以作为备选方案,但要注意其复杂性和潜在问题。 引用说明: [^1] 提到了并发问题的原因:两个客户端同时读取然后写,导致结果不正确(预期为3,实际为2)。 [^2] 提到使用超时设置来规避问题,但不能完全解决,需要通过代码层面解决。 [^3] 提到了分布式锁的方案,强调利用Redis的单线程特性实现原子操作。 因此,我们推荐使用原子命令或Lua脚本来解决并发+1问题。 相关问题: 1. 除了INCR命令,Redis还有哪些原子命令可以用于解决并发问题? 2. 在Java中如何使用分布式锁来解决Redis并发问题? 3. Lua脚本在Redis中执行时有哪些注意事项? </think> ### Java使用Redis并发+1问题的解决方案 在Java中使用Redis处理并发+1操作时,常见的核心问题是非原子性操作导致的数据竞争(如引用[1]描述的场景)。以下是三种解决方案,按推荐优先级排序: #### 1. **使用Redis原子命令(推荐)** ```java // Jedis示例 Jedis jedis = new Jedis("redis-host"); long newValue = jedis.incr("counter_key"); // 原子增加1 ``` - **原理**:`INCR`是Redis提供的原子操作,避免`GET+SET`竞态问题[^1] - **优势**:无需额外锁开销,性能最佳 - **扩展命令**: - `INCRBY key increment`:指定步长增加 - `DECR`/`DECRBY`:原子减少 #### 2. **Lua脚本实现复杂原子操作** ```java String luaScript = "local current = tonumber(redis.call('GET', KEYS[1])) " + "if current >= 10 then " + // 添加业务判断 " return redis.call('INCR', KEYS[1]) " + "else " + " return -1 " + "end"; Object result = jedis.eval(luaScript, 1, "counter_key"); ``` - **原理**:Lua脚本在Redis中单线程原子执行[^3] - **适用场景**:需在增减前后添加业务逻辑(如阈值判断) #### 3. **分布式锁方案(备选)** ```java // Redisson示例 RLock lock = redisson.getLock("counter_lock"); try { lock.lock(2, TimeUnit.SECONDS); // 设置超时避免死锁[^2] int value = Integer.parseInt(jedis.get("counter_key")); jedis.set("counter_key", String.valueOf(value + 1)); } finally { lock.unlock(); } ``` - **注意事项**: - 必须设置锁超时时间(避免引用[2]提到的极端阻塞) - 推荐使用Redisson等成熟库代替手动实现 - 性能低于原子命令方案 --- ### 关键问题规避建议 1. **超时控制** - 分布式锁必须设置超时(如引用[2]所述),建议不超过500ms - Redis操作添加超时配置:`jedis.setTimeout(300)` 2. **缓存穿透预防** 结合引用[3]的方案: ```java if(!existKey("counter_key")) { if(tryLock("load_lock")) { // 双层检查 setKeyWithExpire("counter_key", loadFromDB(), 60); } } ``` 3. **集群环境适配** 在Redis Cluster中使用`{hash_tag}`确保键与锁在相同槽位: ```java String key = "{counter}_business_key"; // 保证原子操作的集群一致性 ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值