1. 典型设计#

一个帖子系统, 用户可以发帖, 点赞帖子, 给帖子发表评论, 点赞评论, 回复评论

-- 用户表
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
    username VARCHAR(50) NOT NULL COMMENT '用户名',
    email VARCHAR(100) NOT NULL COMMENT '邮箱',
    password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY idx_username (username),
    UNIQUE KEY idx_email (email)
) COMMENT '用户表';

-- 帖子表
CREATE TABLE posts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '帖子ID',
    user_id BIGINT NOT NULL COMMENT '作者ID',
    title VARCHAR(200) NOT NULL COMMENT '标题',
    content TEXT NOT NULL COMMENT '内容',
    likes_count INT NOT NULL DEFAULT 0 COMMENT '点赞数',
    comments_count INT NOT NULL DEFAULT 0 COMMENT '评论数',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,2-已删除',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
    KEY idx_user_id (user_id) COMMENT '用户ID索引,用于查询用户的帖子列表',
    KEY idx_created_at (created_at) COMMENT '创建时间索引,用于按时间排序'
) COMMENT '帖子表';

-- 帖子点赞表
CREATE TABLE post_likes (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '点赞ID',
    post_id BIGINT NOT NULL COMMENT '帖子ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    UNIQUE KEY idx_post_user (post_id, user_id) COMMENT '帖子用户联合唯一索引,防止重复点赞',
    KEY idx_user_id (user_id) COMMENT '用户ID索引,用于查询用户的点赞列表'
) COMMENT '帖子点赞表';

-- 评论表
CREATE TABLE comments (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '评论ID',
    post_id BIGINT NOT NULL COMMENT '帖子ID',
    user_id BIGINT NOT NULL COMMENT '评论者ID',
    parent_id BIGINT DEFAULT NULL COMMENT '父评论ID,回复评论时使用',
    content TEXT NOT NULL COMMENT '评论内容',
    likes_count INT NOT NULL DEFAULT 0 COMMENT '点赞数',
    replies_count INT NOT NULL DEFAULT 0 COMMENT '回复数',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常,2-已删除',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
    KEY idx_post_id (post_id) COMMENT '帖子ID索引,用于查询帖子的评论列表',
    KEY idx_user_id (user_id) COMMENT '用户ID索引,用于查询用户的评论列表',
    KEY idx_parent_id (parent_id) COMMENT '父评论ID索引,用于查询评论的回复列表'
) COMMENT '评论表';

-- 评论点赞表
CREATE TABLE comment_likes (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '点赞ID',
    comment_id BIGINT NOT NULL COMMENT '评论ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    UNIQUE KEY idx_comment_user (comment_id, user_id) COMMENT '评论用户联合唯一索引,防止重复点赞',
    KEY idx_user_id (user_id) COMMENT '用户ID索引,用于查询用户的点赞列表'
) COMMENT '评论点赞表';

2. 帖子中应该放点赞数和回复数吗#

2.1. 数据一致性问题 - 事务#

优点是读取性能好, 减少数据库压力 , 显示帖子列表时无需连表统计, 缺点是会有写入一致性问题和并发问题, 假设用户点赞一个帖子,我们需要做两件事:

  1. 在 post_likes 表插入一条点赞记录
  2. 在 posts 表把这个帖子的 likes_count 加1
-- 第一步:插入点赞记录成功
INSERT INTO post_likes (post_id, user_id) VALUES (1, 1);

-- 第二步:更新帖子点赞数
-- 假设在这时候数据库突然崩溃了或者网络中断了
UPDATE posts SET likes_count = likes_count + 1 WHERE id = 1;

这就导致了数据不一致: post_likes 表显示用户点赞了, 但是 posts 表的点赞数没有增加, 我们可以通过加入事务来解决:

BEGIN;
    INSERT INTO post_likes (post_id, user_id) VALUES (1, 1);
    UPDATE posts SET likes_count = likes_count + 1 WHERE id = 1;
COMMIT;

通过使用事务, 若 BEGIN ... COMMIT 中的一个语句执行失败, 之前所有的操作都不会成功, 这就解决了数据一致性问题

2.2. 并发问题 - X锁#

下面先说一下一个常见的误解, 假如帖子当前有100个赞,两个用户 A 和 B 同时点赞:

-- 用户A的操作
UPDATE posts SET likes_count = likes_count + 1 WHERE id = 1;

-- 用户B的操作(同时进行, 则都会读取到 likes_count = 100)
UPDATE posts SET likes_count = likes_count + 1 WHERE id = 1;

有人会想到可能会出现下面问题:

  1. A读取到 likes_count = 100, B读取到 likes_count = 100
  2. A更新 likes_count = 101, B 更新 likes_count = 101

最终结果是 likes_count = 101,但实际上应该是102, 其实并不会出现这个问题, 这里需要指出两个关于事务和锁事实:

  • 在 MySQL 中,默认情况下 autocommit 是开启的(即 autocommit=1)。在这个模式下,每条单独的 SQL 语句 (增删改, 除了查) 都会被当作一个独立的事务来执行。SELECT 语句通常不涉及事务(除非是 SELECT ... FOR UPDATE 这种需要锁的语句)
  • 在数据修改操作(Update、Delete、Insert)中, InnoDB 会自动对受影响的行加上行级的排它锁(X 锁)
  • 锁只会在事务 commit 或者 rollback 的时候自动被释放, 这也是为什么锁必须配合事务使用

根据第二条事实, 我们知道当执行 UPDATE posts SET likes_count = likes_count + 1 WHERE id = 1; 时, MySQL 会自动为 id=1 的行加上一个 x 锁, 意味着在这个锁没有被释放前, 其它任何事务都不可以修改这行数据, 因为想要修改某行数据必须要获得这行数据的 x 锁 (MySQL 默认行为), 而这行数据的 x锁还没被释放,

下面使用如下表格展示用户A和用户B并发执行更新时的操作流程, 假设初始状态:posts 表中 id=1 的记录 likes_count=100:

时间 (T) 用户A的操作 用户B的操作 备注说明
T₀ —— —— 初始状态:点赞数 100
T₁ 执行 UPDATE ... 加排他锁并将值由 100 更新为 101 —— 用户A自动获得行锁,其他事务无法修改该行
T₂ ——(等待提交或后续操作) 尝试执行相同 UPDATE 语句但因行被锁,进入等待状态 用户B操作被阻塞,等待用户A释放锁
T₃ 提交事务,释放锁 —— 用户A提交后,行更新为 101,释放了锁
T₄ —— 获得锁后执行 UPDATE ... 将值由 101 更新为 102 用户B操作获得锁,基于最新数据进行更新
T₅ —— 提交事务 最终结果:点赞数102(累加 2)

可以看出, 并不需要手动加x锁, 只需要使用事务保证操作的原子性就好了, 因为UPDATE 会自动获得 x 锁, 不必担心并发问题, 如下:

BEGIN;
    -- 插入点赞记录
    INSERT INTO post_likes (post_id, user_id) VALUES (1, 1);
    -- 更新点赞数
    UPDATE posts SET likes_count = likes_count + 1 WHERE id = 1;
COMMIT;

至于防止用户重复点赞, 我们可以在 (post_id, user_id) 上建立索引, 若相同数据插入, INSERT INTO post_likes (post_id, user_id) VALUES (1, 1); 必然失败, 导致业务逻辑抛出异常, 下面的更新操作也不会发生, 然后我们捕获异常, 告诉用户点赞重复即可,

2.3. 事务 + 悲观锁#

既然每条修改语句都会先尝试获取排它锁, 然后才能修改数据, 为什么还会有并发问题呢? 上面的情况很简单, 所以没有问题, 我们来考虑一个复杂一些的问题,

假设有一个库存系统,需要先判断库存是否充足,再扣减库存。如果不使用显式加锁,可能会出现多个事务同时读取相同库存数量,然后都判断库存足够,导致库存扣减错误。

时间 (T) 用户A的操作 用户B的操作 备注说明
T₀ —— —— 初始状态:库存 stock = 10
T₁ 查询库存, stock = 10 —— 用户A读取库存,未加锁(普通 SELECT)
T₂ —— 查询库存, stock = 10 用户B也读取库存,双方看到的都是相同的初始库存
T₃ 库存足够10 ≥ 8, 加 X 锁, 更新库存 10 - 8 = 2, —— 用户A更新时加锁,库存实际变为 2
T₄ —— 库存足够10 ≥ 6, 尝试更新库存数据, 无法获取 x锁, 尝试更新失败 用户B的 UPDATE 操作因被 A 的锁阻塞,等待 A 提交
T₅ 提交事务,释放锁 —— 用户A提交后,锁释放,此时数据库中库存为 2
T₆ —— 获得x锁后执行 UPDATE 操作 10 - 6 = 4, 用户B执行更新时,虽然其早先读取到库存为 10,但更新操作是基于当前实际库存(2)进行扣减,即 2 - 6 = -4
T₇ —— 提交事务 最终库存变为 -4,出现库存不足但仍被扣减的问题

这种问题通常需要使用悲观锁(例如 SELECT ... FOR UPDATE), 即使用显式加锁可以解决这个问题:

-- 开启事务保证多个操作的原子性(数据一致性)
BEGIN;
    -- 手工显式加x锁,防止其他事务在判断和扣减期间修改库存
    SELECT stock FROM products WHERE product_id = 100 FOR UPDATE;
    -- 根据读取的库存进行判断
    IF (stock >= 5) THEN
        UPDATE products SET stock = stock - 5 WHERE product_id = 100;
    END IF;
COMMIT;

这里我们在事务开始前显式添加了 x锁, 这意味着若其它事务想修改 product_id = 100 这行数据, 必须先拿到这一行的 x锁, 而此时若事务 A 已经显式拿到了 product_id = 100 这一行数据的 x锁, 意味着事务 A 不结束, 该锁永远不会被释放, 也就是其它事务永远不可能拿到这行数据的x锁, 也就无法执行下面的流程 (比如: 查询编号为100的商品的剩余, 更新该行数据),

注意这里添加事务是为了保留显式添加的锁直到整个事务结束,

可以看出, 使用悲观锁(通过 SELECT ... FOR UPDATE 显示加 X 锁)的主要目的就是在执行更新前,确保读取到的数据是最新且不会被其他并发事务修改,从而保证基于该数据做出的判断是可靠的。如果判断通过,再执行更新操作,而这整个过程都在同一个事务内执行,确保了原子性和隔离性,避免数据竞争和不一致的问题。

小贴士: X锁 的加锁方式有两种,第一种是自动加锁,在对数据进行增删改的时候,都会默认加上一个X锁。还有一种是手工加锁,我们用一个FOR UPDATE给一行数据加上一个X锁, X锁在同一时刻只能被一个事务持有, 其它事务想获得, 必须等待

最常见的数据一致性问题就是多步骤其中一个步骤失败引起的, 比如假设你在银行 A 账户有 1000 元,你想转账 200 元到银行 B 账户, 正常情况下 从 A 账户扣除 200 元(余额变成 800), 向 B 账户增加 200 元(余额变成 1200), 假设在步骤 1 之后(A 账户变成 800),系统崩溃或网络异常,导致步骤 2 没有执行, 这就导致 A 账户已经减少了 200 元(800),但 B 账户仍然是 1000, 这也是数据一致性问题, 这种数据一致性问题我们可以添加事务 利用事务的原子性来解决,

数据一致性问题分为好多种, 比如上面用户A, B同时点赞, 导致点赞数丢失的问题, 比如多个人给一个账户转 100 块钱, A事务读取到此时账户余额为 100, B事务 也读取到账户余额为 100, 所以 A: 余额= 100 + 100 = 200, B事务 也是这样, 最后账户余额仅为 200 而不是 300, 导致数据一致性问题, 这种数据一致性问题可以通过 x 锁解决, 当然 MySQL 数据库默认加上了 x 锁, 我们不必担心

还有一种是需要判断再进行其他增减操作的, 比如高并发防止库存超卖, 我们需要先判断库存是否有剩余, 再进行扣减, 这个时候就有了两个操作 判断 + 扣除, 这个时候就需要使用悲观锁直接锁定或者使用乐观锁, 需要通过锁机制来确保“判断+扣减”作为一个整体原子操作执行, 通过一个版本号标识数据的状态, 在更新时检查版本是否一致, 如果一致, 说明数据未被其他线程修改, 可以安全更新;如果不一致,说明有并发修改,需要重试或失败处理。

SELECT quantity, version FROM stock WHERE product_id = 1001;
...
UPDATE stock 
SET quantity = quantity - 2, version = version + 1 
WHERE product_id = 1001 AND version = 1;

3. 帖子表放点赞数 高并发点赞 Redis + Kafka#

首先看一下最开始的实现:

@Transactional
public void likePost(Long postId, Long userId) {
    String likesUsersKey = "post:" + postId + ":likes_users";
    String likesCountKey = "post:" + postId + ":likes_count";
    // 1. 检查是否已点赞
    if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(likesUsersKey, userId))) {
        return;
    }

    // 2. Redis 操作:添加用户ID到点赞集合 & 计数+1
    redisTemplate.opsForSet().add(likesUsersKey, userId);
    redisTemplate.opsForValue().increment(likesCountKey, 1);

    // 3. 发送 Kafka 事件 (type=like)
    String event = "like," + postId + "," + userId;
    kafkaTemplate.send("like-topic", event);
}

可以看出我们这里检查和更新操作进行了分离, 最开始我想的是 Redis 的 Set 集合会保证点赞的唯一性, 又因为 Redis 服务是一个单线程进程, 所以即使一个用户同时进行两次点赞, 得到的检查都是 未点赞, 然后都执行数据更新操作, 也不会出现数据不一致问题, 因为在:

redisTemplate.opsForSet().add(likesUsersKey, userId);

这一步就会失败(Set 集合天然唯一性), 下面的自增1也不可能执行,

首先这么理解是不对的, opsForSet().add(...); 底层调用的是 Redis 的 SADD 命令, 如果添加的成员已经存在于集合中,Redis 不会抛出异常,而是简单地忽略该操作, 所以下面的代码(自增1)会继续执行, 那这就可能导致数据不一致问题:

步骤 线程A操作 线程B操作 Redis 中的实际情况
1 isMember(likes_users, 888) 返回 false likes_users = {}, likes_count = 0
2 isMember(likes_users, 888) 返回 false likes_users = {}, likes_count = 0
3 SADD(likes_users, 888),返回 1 likes_users = {888}, likes_count = 0
4 increment(likes_count),加 1 likes_users = {888}, likes_count = 1
5 SADD(likes_users, 888),返回 0 likes_users = {888}, likes_count = 1
6 increment(likes_count),又加 1 likes_users = {888}, likes_count = 2

从最终结果看, Set 中只有一个用户 (888), 但 likes_count 变成了 2, 这就是「点赞数比实际多」的不一致情况,

要解决这个问题可以利用 Redis 命令本身返回值并在代码中加以判断:

public void likePost(Long postId, Long userId) {
    String likesUsersKey = "post:" + postId + ":likes_users";
    String likesCountKey = "post:" + postId + ":likes_count";

    // sAddRet 要么是 1(成功加入,不在集合中),要么是 0(已存在,没加入)
    Long sAddRet = redisTemplate.opsForSet().add(likesUsersKey, userId);
    if (sAddRet != null && sAddRet > 0) {
        // 只有在成功新加了用户的时候才执行加1
        redisTemplate.opsForValue().increment(likesCountKey, 1);
        // 同时再发Kafka事件
        String event = "like," + postId + "," + userId;
        kafkaTemplate.send("like-topic", event);
    }
}

这样只有在成功加入到集合的时候, 才进行加1操作, 所以解决了上面的问题, 这样虽然可以解决, Redis 遇到多个操作如 检查 + 更新 这种场景的时候, 还是应该考虑利用分布式锁或者Lua脚本保证操作原子性来解决问题,

除此之外, 可以注意到上面的代码我们省略了先行的 isMember 判断, 因为我们的实现依赖 SADD 的返回值来判定是否是第一次点赞, 已经是判断了,

Spring 的 @Transactional 注解默认只对使用了关系型数据库(如 JPA / JDBC)的事务生效。对于 RedisTemplate 的操作,除非你做了额外的配置(例如启用 Redis 事务支持,或使用了 Lua 脚本实现原子性操作),否则 Redis 并不会因为 Spring 事务回滚而自动回滚。换句话说,一般情况下,Redis 操作默认是「非事务性」的,Spring 事务并不会对它生效。

4. 高并发防止库存超卖#

4.1. Redis + Lua 脚本#

除了悲观锁和乐观锁, 还可以使用 Redis 来解决这个问题, 首先可能会想到的是利用 Redis 单线程特性, 伪代码如下:

public boolean deductStock(String productId, int amount) {
    String key = "stock:" + productId;
    // 1. 检查库存
    Integer stock = redis.get(key);
    if (stock == null || stock < amount) {
        return false; // 库存不足
    }

    // 2. 原子扣减
    Integer newStock = redis.decrBy(key, amount);
    if (newStock < 0) {
        // 库存不足,手动回滚
        redis.incrBy(key, amount);
        return false;
    }
    return true; // 扣减成功
}

假设初始库存为 5, 两个线程 T1 和 T2 同时尝试扣减 3 个库存:

时间步 线程 T1 线程 T2 Redis 库存 备注
T1 GET 返回 5,检查 5 >= 3 5 T1 检查通过
T2 GET 返回 5,检查 5 >= 3 5 T2 检查通过
T3 DECRBY 3,返回 2 2 T1 扣减成功,newStock = 2
T4 检查 newStock = 2 >= 0,不回滚 2 T1 完成,库存合法
T5 DECRBY 3,返回 -1 -1 T2 扣减,newStock = -1
T6 检查 newStock = -1 < 0,回滚 3 2 T2 回滚,库存恢复到 2

库存最终值:2(T1 扣了 3,T2 扣了又回滚), 似乎问题解决了, 但实际上这只是表面现象, 问题依然存在:

虽然 decrBy 本身是原子的, 但前面的检查(get 和判断库存是否足够)与扣减之间不是一个原子操作, 当 decrBy 执行后,如果结果小于 0,则会调用 redis.incrBy 补偿库存,并返回扣减失败。这样虽然能保证最终库存不会维持在负值,但在短时间内可能出现库存负值的状态,而且多个并发请求可能都进行补偿操作:

  • 这会导致性能问题, 大量线程尝试扣减, 最终只有少数成功, 其他回滚, 浪费资源
  • 严重的情况是由于网络延迟等原因导致补偿操作不成功, 从而引起实际上的超卖问题

所以你看, 即使 Redis 是单线程, 所有发送到 Redis 服务器的指令都是一个个串行执行, 依然可能会出现并发问题,

改进建议 为了解决上述问题, 可以使用 Lua 脚本将库存检查和扣减操作封装成一个原子操作, 确保整个过程在 Redis 内部一次性执行, 从而消除检查与扣减之间的时间窗口, 例如, 可以使用如下 Lua 脚本来实现:

local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock >= tonumber(ARGV[1]) then
    return redis.call('decrby', KEYS[1], ARGV[1])
else
    return -1
end

伪代码:

public boolean deductStock(String productId, int amount) {
    String key = "stock:" + productId;
    String luaScript = "local stock = tonumber(redis.call('GET', KEYS[1]))\n" +
                       "local amount = tonumber(ARGV[1])\n" +
                       "if stock == nil then return -1 end\n" +
                       "if stock < amount then return -2 end\n" +
                       "local newStock = stock - amount\n" +
                       "redis.call('SET', KEYS[1], newStock)\n" +
                       "return newStock";
    // Lua 脚本在 Redis 内部执行, 效率极高
    Long result = redis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(String.valueOf(amount)));
    return result >= 0; // >= 0 表示扣减成功
}

总结 虽然这个方案通过补偿操作在逻辑上试图防止超卖,但由于库存检查与扣减操作之间不是原子性的,仍然存在在高并发场景下出现短暂负库存(即“超卖”)的风险。使用 Lua 脚本或分布式锁来保证整个扣减过程的原子性是更为稳妥的方案。

4.2. 分布式锁#

在方案二(Lua 脚本)中, 我们将“检查库存”和“扣减库存”封装成一个原子操作, 完全在 Redis 内部执行, 效率很高, 如果业务逻辑复杂, 例如扣减库存后需要异步更新数据库, 可以用 Redis 分布式锁来控制并发,

  1. 获取锁: 使用 SETNX(Set if Not Exists)加锁:
SET lock:1001 1 EX 10 NX  # 设置锁,10秒过期
  1. 扣减库存: 获取锁后,检查并扣减库存:
GET stock:1001
DECRBY stock:1001 2
  1. 释放锁: 操作完成后删除锁
DEL lock:1001

伪代码:

public boolean deductStock(String productId, int amount) {
    String lockKey = "lock:" + productId;
    String stockKey = "stock:" + productId;
    
    // 获取分布式锁
    boolean locked = redis.setNX(lockKey, "1", 10);
    if (!locked) {
        return false; // 获取锁失败, 被其他线程占用
    }
    
    try {
        // 检查库存
        Integer stock = redis.get(stockKey);
        if (stock == null || stock < amount) {
            return false;
        }
        
        // 扣减库存
        Integer newStock = redis.decrBy(stockKey, amount);
        if (newStock < 0) {
            redis.incrBy(stockKey, amount); // 回滚
            return false;
        }
        
        // 异步更新数据库
        asyncExecutor.submit(() -> {
            try {
                updateDatabase(productId, amount);
            } catch (Exception e) {
                // 数据库更新失败,回滚 Redis
                redis.incrBy(stockKey, amount);
                log.error("Database update failed, rolled back stock", e);
            }
        });
        return true;
    } finally {
        redis.del(lockKey); // 释放锁
    }
}

4.3. 总结#

Lua 脚本适用场景: 业务逻辑简单,只涉及 Redis 数据操作, Lua 脚本只能操作 Redis 的数据, 无法直接与外部系统(如数据库、消息队列)交互,

分布式锁适用场景: 库存扣减后需要与外部系统(如数据库)保持一致性