黑马点评-优惠券秒杀-03_basic_seckill_and_oversell

黑马点评优惠券秒杀三:最朴素下单为什么会超卖?

本文继续整理黑马点评 Redis 实战篇第 3 章「优惠券秒杀」。

前两篇分别讲了订单 ID 和秒杀券表结构。这一篇开始进入真正的秒杀下单:用户点击抢购后,后端到底做了什么?为什么最朴素的代码看起来没问题,一并发就会超卖?


1. 本文解决什么问题

这篇文章主要讲清楚:

text 复制代码
1. 秒杀下单的最朴素流程是什么。
2. 为什么要判断开始时间、结束时间和库存。
3. 超卖是怎么发生的。
4. 为什么 stock > 0 的条件更新能挡住超卖。
5. 防超卖和防重复下单为什么不是一回事。

先给结论:

超卖的根源是"判断库存"和"扣减库存"不是原子操作。解决思路是把库存判断条件放进数据库更新语句中,例如 stock = stock - 1 where voucher_id = ? and stock > 0,让数据库在扣库存时再次确认库存仍然大于 0。


2. 秒杀请求从哪里进来

用户点击抢购按钮后,请求进入 VoucherOrderController

java 复制代码
@PostMapping("/seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
    return voucherOrderService.seckillVoucher(voucherId);
}

这里的路径类似:

text 复制代码
POST /voucher-order/seckill/1

其中:

text 复制代码
1 = voucherId

也就是用户要抢购的优惠券 ID。


3. 最朴素秒杀流程

讲义里的初版秒杀逻辑大概是:

java 复制代码
public Result seckillVoucher(Long voucherId) {
    // 1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

    // 2.判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.fail("秒杀尚未开始!");
    }

    // 3.判断秒杀是否已经结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.fail("秒杀已经结束!");
    }

    // 4.判断库存是否充足
    if (voucher.getStock() < 1) {
        return Result.fail("库存不足!");
    }

    // 5.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            .update();

    if (!success) {
        return Result.fail("库存不足!");
    }

    // 6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(UserHolder.getUser().getId());
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(orderId);
}

这个流程非常符合直觉:

text 复制代码
查秒杀券
判断时间
判断库存
扣库存
创建订单
返回订单 ID

单线程下看起来没有问题。


4. 时间判断到底在判断什么

4.1 是否尚未开始

java 复制代码
voucher.getBeginTime().isAfter(LocalDateTime.now())

意思是:

text 复制代码
秒杀开始时间是否在当前时间之后

如果返回 true,说明:

text 复制代码
现在还没到开始时间

例如:

text 复制代码
beginTime = 20:00
now = 19:30

beginTime.isAfter(now)true,所以秒杀尚未开始。

4.2 是否已经结束

java 复制代码
voucher.getEndTime().isBefore(LocalDateTime.now())

意思是:

text 复制代码
秒杀结束时间是否已经早于当前时间

如果返回 true,说明:

text 复制代码
活动已经结束

例如:

text 复制代码
endTime = 18:00
now = 19:30

endTime.isBefore(now)true,所以秒杀已经结束。


5. 库存判断为什么看起来没问题

朴素代码里有:

java 复制代码
if (voucher.getStock() < 1) {
    return Result.fail("库存不足!");
}

它的意思很简单:

text 复制代码
如果库存小于 1,就不能卖。

在单线程下,这个判断完全正确。

比如:

text 复制代码
stock = 0 -> 返回库存不足
stock = 10 -> 继续下单

问题不是这个 if 写错了。

问题在于:

text 复制代码
判断库存和扣库存之间有时间差。

这个时间差在并发场景下非常危险。


6. 超卖是怎么发生的

假设现在只剩最后 1 张券:

text 复制代码
stock = 1

两个用户几乎同时发起请求。
数据库 请求2 请求1 数据库 请求2 请求1 #mermaid-svg-kYTLdhOCszi7IBwx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kYTLdhOCszi7IBwx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kYTLdhOCszi7IBwx .error-icon{fill:#552222;}#mermaid-svg-kYTLdhOCszi7IBwx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kYTLdhOCszi7IBwx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kYTLdhOCszi7IBwx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kYTLdhOCszi7IBwx .marker.cross{stroke:#333333;}#mermaid-svg-kYTLdhOCszi7IBwx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kYTLdhOCszi7IBwx p{margin:0;}#mermaid-svg-kYTLdhOCszi7IBwx .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kYTLdhOCszi7IBwx text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kYTLdhOCszi7IBwx .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-kYTLdhOCszi7IBwx .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx .sequenceNumber{fill:white;}#mermaid-svg-kYTLdhOCszi7IBwx #sequencenumber{fill:#333;}#mermaid-svg-kYTLdhOCszi7IBwx #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-kYTLdhOCszi7IBwx .messageText{fill:#333;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kYTLdhOCszi7IBwx .labelText,#mermaid-svg-kYTLdhOCszi7IBwx .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .loopText,#mermaid-svg-kYTLdhOCszi7IBwx .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kYTLdhOCszi7IBwx .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-kYTLdhOCszi7IBwx .noteText,#mermaid-svg-kYTLdhOCszi7IBwx .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-kYTLdhOCszi7IBwx .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kYTLdhOCszi7IBwx .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kYTLdhOCszi7IBwx .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kYTLdhOCszi7IBwx .actorPopupMenu{position:absolute;}#mermaid-svg-kYTLdhOCszi7IBwx .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-kYTLdhOCszi7IBwx .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kYTLdhOCszi7IBwx .actor-man circle,#mermaid-svg-kYTLdhOCszi7IBwx line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-kYTLdhOCszi7IBwx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询库存,读到 stock = 1查询库存,读到 stock = 1扣库存 stock = stock - 1扣库存 stock = stock - 1创建订单创建订单

关键在前两步:

text 复制代码
两个请求都在扣库存前读到了 stock = 1。

所以两个请求都认为:

text 复制代码
库存足够,我可以下单。

最后库存可能被扣成负数,或者虽然库存没有负数,但订单数量超过了库存数量。

这就是超卖。


7. 超卖的本质

超卖的本质不是:

text 复制代码
程序员不会写 if

而是:

text 复制代码
读取库存和扣减库存不是一个原子操作。

朴素流程里:

text 复制代码
先查库存
再判断库存
再扣库存

这几步中间,其他线程可以插进来。

所以多个线程可能基于同一个旧库存做决定。


8. 悲观锁和乐观锁的思路

解决超卖通常有两类思路。

8.1 悲观锁

悲观锁的想法是:

text 复制代码
我认为一定会有并发冲突,所以先加锁,让大家排队。

比如 Java 里的:

text 复制代码
synchronized
Lock

优点是直观。

缺点是性能可能下降,因为并发被串行化。

8.2 乐观锁

乐观锁的想法是:

text 复制代码
我先不加锁,更新时检查数据有没有被别人改过。

如果没被改过,就更新成功。

如果已经被改过,就更新失败。

库存扣减更适合用这种思路:

text 复制代码
更新库存时带上条件。
条件满足才扣库存。
条件不满足说明库存已经变化,更新失败。

9. 第一种乐观锁:库存必须和之前查到的一样

讲义里先给过一种写法:

java 复制代码
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .eq("stock", voucher.getStock())
        .update();

翻译成 SQL 思路:

sql 复制代码
update tb_seckill_voucher
set stock = stock - 1
where voucher_id = ?
  and stock = 查询时读到的库存

它的意思是:

text 复制代码
只有当数据库里的库存仍然等于我刚才查到的库存时,才扣减。

这能防超卖。

但成功率太低。

比如 100 个线程同时查到:

text 复制代码
stock = 100

第一个线程扣成功后,库存变成 99。

其他 99 个线程再更新时,条件 stock = 100 都不成立。

明明还有 99 张库存,却大量失败。

所以这个方案太严格。


10. 第二种乐观锁:只要求库存大于 0

更适合秒杀的是:

java 复制代码
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0)
        .update();

这里的 .gt("stock", 0) 是 MyBatis-Plus 的条件构造方法。

gt 表示:

text 复制代码
greater than,大于

所以它等价于 SQL 条件:

sql 复制代码
stock > 0

整体翻译成 SQL 思路就是:

sql 复制代码
update tb_seckill_voucher
set stock = stock - 1
where voucher_id = ?
  and stock > 0

这句 SQL 的含义是:

只有数据库当前库存仍然大于 0,才允许扣减库存。


11. 为什么 stock > 0 能挡住超卖

还是最后 1 张券:

text 复制代码
stock = 1

请求 1 执行:

sql 复制代码
update ...
set stock = stock - 1
where voucher_id = 1 and stock > 0

此时库存是 1,满足 stock > 0,更新成功。

库存变成:

text 复制代码
stock = 0

请求 2 再执行同样的 SQL。

这时库存已经是 0。

条件:

sql 复制代码
stock > 0

不成立。

所以请求 2 更新失败。

结果就是:

text 复制代码
最后一张券只能被一个请求扣掉。

12. 防超卖流程图

#mermaid-svg-EIJS4cs0rysZA6SR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EIJS4cs0rysZA6SR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EIJS4cs0rysZA6SR .error-icon{fill:#552222;}#mermaid-svg-EIJS4cs0rysZA6SR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EIJS4cs0rysZA6SR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EIJS4cs0rysZA6SR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR .marker.cross{stroke:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EIJS4cs0rysZA6SR p{margin:0;}#mermaid-svg-EIJS4cs0rysZA6SR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster-label text{fill:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster-label span{color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster-label span p{background-color:transparent;}#mermaid-svg-EIJS4cs0rysZA6SR .label text,#mermaid-svg-EIJS4cs0rysZA6SR span{fill:#333;color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .node rect,#mermaid-svg-EIJS4cs0rysZA6SR .node circle,#mermaid-svg-EIJS4cs0rysZA6SR .node ellipse,#mermaid-svg-EIJS4cs0rysZA6SR .node polygon,#mermaid-svg-EIJS4cs0rysZA6SR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .rough-node .label text,#mermaid-svg-EIJS4cs0rysZA6SR .node .label text,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape .label,#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape .label{text-anchor:middle;}#mermaid-svg-EIJS4cs0rysZA6SR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .rough-node .label,#mermaid-svg-EIJS4cs0rysZA6SR .node .label,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape .label,#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape .label{text-align:center;}#mermaid-svg-EIJS4cs0rysZA6SR .node.clickable{cursor:pointer;}#mermaid-svg-EIJS4cs0rysZA6SR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR .arrowheadPath{fill:#333333;}#mermaid-svg-EIJS4cs0rysZA6SR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EIJS4cs0rysZA6SR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EIJS4cs0rysZA6SR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EIJS4cs0rysZA6SR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EIJS4cs0rysZA6SR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EIJS4cs0rysZA6SR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EIJS4cs0rysZA6SR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster text{fill:#333;}#mermaid-svg-EIJS4cs0rysZA6SR .cluster span{color:#333;}#mermaid-svg-EIJS4cs0rysZA6SR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EIJS4cs0rysZA6SR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EIJS4cs0rysZA6SR rect.text{fill:none;stroke-width:0;}#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape p,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EIJS4cs0rysZA6SR .icon-shape .label rect,#mermaid-svg-EIJS4cs0rysZA6SR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EIJS4cs0rysZA6SR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EIJS4cs0rysZA6SR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EIJS4cs0rysZA6SR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否



请求进入秒杀接口
查询秒杀券
时间是否合法?
返回失败
执行条件更新
update stock = stock - 1 where voucher_id = ? and stock > 0
更新是否成功?
返回库存不足
创建订单
返回订单 ID


13. 这段代码防住了什么,没防住什么

它防住的是:

text 复制代码
库存超卖

也就是库存为 0 后不能继续扣减。

但它还没有防住:

text 复制代码
同一个用户重复下单

因为一个用户可以发两次请求:

text 复制代码
请求 1 扣库存成功,创建订单
请求 2 也可能扣库存成功,创建订单

只要库存还够,条件更新并不会关心是不是同一个用户。

所以:

防超卖和防一人一单是两个问题。


14. 易错点

1. 超卖不是库存判断写错了

stock < 1 在单线程下没问题。

并发下的问题是多个线程同时基于旧库存做判断。

2. stock > 0 是在数据库更新时判断

不是 Java 先判断完再更新。

关键是这条条件进入了 SQL:

sql 复制代码
where stock > 0

3. 乐观锁不一定非要有 version 字段

经典乐观锁常见写法是 version。

但这里的核心思想是:

text 复制代码
更新时带条件,条件不满足就说明发生了并发变化。

4. 防超卖不等于防重复下单

库存安全只是第一步。

一人一单还要额外处理。


15. 面试怎么回答

如果面试官问:秒杀为什么会出现超卖?

可以回答:

因为多个请求可能同时查询到相同的库存值,然后都认为库存充足并继续扣减。问题的根源是"判断库存"和"扣减库存"不是原子操作,中间存在并发窗口。

如果面试官问:怎么解决库存超卖?

可以回答:

可以使用乐观锁思想,把库存判断放进数据库更新语句中,例如 update tb_seckill_voucher set stock = stock - 1 where voucher_id = ? and stock > 0。这样只有库存仍然大于 0 时才扣减成功,最后一张券被扣掉后,后续请求由于条件不满足会更新失败。

如果面试官问:为什么不用 stock = 查询时库存

可以回答:

这种方式虽然能防止并发修改,但在秒杀场景下成功率太低。大量线程可能同时读到同一个库存值,只有第一个线程能成功,其余线程即使库存仍然充足也会失败。改成 stock > 0 更符合秒杀扣库存场景。


16. 总结

最朴素秒杀流程是:

text 复制代码
查券 -> 判断时间 -> 判断库存 -> 扣库存 -> 建订单

它的问题在于:

text 复制代码
判断库存和扣库存分离

高并发下,多个线程可能同时读到旧库存,导致超卖。

解决超卖的关键不是多写一个 if,而是把判断条件放进扣库存 SQL:

sql 复制代码
stock = stock - 1
where voucher_id = ?
  and stock > 0

到这里,我们解决了库存安全问题。

但下一篇还要继续解决另一个问题:

text 复制代码
同一个用户为什么还能重复下单?
相关推荐
兰令水1 小时前
leecodecode【双指针题2】【2026.5.26打卡-java版本】
java·开发语言·算法
ch.ju1 小时前
Java程序设计(第3版)第四章——引用
java·开发语言
逍遥德1 小时前
PostgreSQL --- 数组函数详解
数据库·sql·postgresql
.Cnn1 小时前
MySQL事务和Spring事务
数据库·后端·mysql·spring
霸道流氓气质1 小时前
在Qoder中指定JDK和Maven运行AI学习的SpringBoot项目的完整指南
java·人工智能·maven
老码观察2 小时前
设计模式实战解读(七):适配器模式——让不兼容的接口无缝协作
java·设计模式·适配器模式
garmin Chen2 小时前
rabbitmq(1):核心机制与 SpringAMQP 详解
java·rabbitmq·java-rabbitmq
福大大架构师每日一题2 小时前
redis 8.8.0 发布:新数据结构、字段级通知、INCREX、XNACK 全面升级,8.6 到 8.8 变化一文看懂
数据结构·数据库·redis
霸道流氓气质2 小时前
Spring Data JPA 完全指南
开发语言·数据库